Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
153820 stories
·
33 followers

Moving away from Tailwind, and learning to structure my CSS

1 Share

Hello! 8 years ago, I wrote excitedly about discovering Tailwind.

At that time I really had no idea how to structure my CSS code and given the choice between a pile of complete chaos and Tailwind, I was really happy to choose Tailwind. It helped me make a lot of tiny sites!

I spent the last week or so migrating a couple of sites away from Tailwind and towards more semantic HTML + vanilla CSS, and it was SO fun and SO interesting, so here are some things I learned!

As usual I’m not a full-time frontend developer and so all of my CSS learning has happened in fits and starts over many years.

it turns out Tailwind taught me a lot

When I started thinking about structuring CSS, I was intimidated at first: I’m not very good at structuring my CSS! But then I started reading blog posts talking about how to structure CSS (like A whole cascade of layers or How I write CSS in 2024) and I realized a couple of things:

  1. Every CSS code base has a bunch of different things going on (layouts! fonts! colours! common components!)
  2. It’s extremely useful to have systems or guidelines to manage each of those things, otherwise things descend into chaos
  3. Tailwind has systems for some of these, and I already know those systems! Maybe I can imitate the systems I like!

For example, Tailwind has:

the systems I’m going to talk about

I’m going to talk about a few aspects of my CSS codebase and my thoughts so far what kind of rules I want to impose on the codebase for each one. Some of them are copied from Tailwind and some aren’t.

  1. reset
  2. components
  3. colours
  4. font sizes
  5. utility classes
  6. the base
  7. spacing
  8. responsive design
  9. the build system

1. reset

I just copied Tailwind’s “preflight styles” by going into tailwind.css and copying the first 200 lines or so.

I noticed that I’ve developed a relationship with Tailwind’s CSS reset over time, for example Tailwind sets box-sizing: border-box on every element (which means that an element’s width includes its padding):

* { box-sizing: border-box; }

I think it would be a real adjustment for me to switch to writing CSS without these, and I’m sure there are lots of other things in the Tailwind reset (like html {line-height: 1.5;}) that I’m subconsciously used to and don’t even realize are there.

2. components

This next part is the bulk of the CSS!

The idea here is to organize CSS by “components”, in a way that’s spiritually related to Vue or React components. (though there might not actually be any Javascript at all in the site)

Basically the idea is that:

  1. Each “component” has a unique class
  2. The CSS for one component never overrides the CSS for any other component
  3. Each component has its own CSS file

So editing the CSS for one component won’t mysteriously break something in another component. And probably like 80% of the CSS that I would actually want to change is in various component files, so if I’m editing a 100-line component, I just have to think about those 100 lines. It’s way easier for me to think about.

For example, this HTML might be the .zine “component”.

<figure class="zine horizontal">
    <img src="whatever.jpg">
</figure>

And the CSS looks something like this, using nested selectors:

.zine {
  ...
  &.horizontal {
    ...
  }
  &.vertical {
    ...
  }
  &:hover {
    ...
  }
}

I haven’t done anything programmatic (like web components or @scope) that ensures that components won’t interfere with each other, but just having a convention and trying my best already feels like a big improvement.

Next: conventions to maintain some consistency across the site and keep these components in line with each other!

3. colours

colours.css has a bunch of variables like this which I can use as necessary. Colour is really hard and I didn’t want to revisit my use of colour in this refactor, so I left this alone.

The only guideline I’m trying to enforce here is that all colours used in the site are listed in this file.

:root {
  --pink: #fea0c2;
  --pink-light: #F9B9B9;
  --red: #f91a55;
  --orange: rgb(222, 117, 31);
  ...
}

4. font sizes

One thing I appreciated about Tailwind was that if I wanted to set a font size, I could just think “hm, I want the text to be big”, write text-lg, and be done with it! And maybe if it’s not big enough I’d use xl or 2xl instead. No trying to remember whether I’m using em or px or rem.

So I defined a bunch of variables, taken from Tailwind, like this:

  --size-xs: 0.75rem;
  --line-height-xs: 1rem;

  --size-sm: 0.875rem;
  --line-height-sm: 1.25rem;

Then if I want to set a font size, I can do it like this. It’s a little more verbose than Tailwind but I’m happy with it for now.

h3 {
  font-size: var(--size-lg);
  line-weight: var(--line-weight-lg);
}

5. utilities

There are some things like buttons that appear in many different components. I’m calling these “utilities”.

I copied some utility classes from Tailwind (like .sr-only for things that should only appear for screenreader users).

This section is pretty small and I try to be careful about making changes here.

6. the base

“base” styles are styles that apply across the whole site that I chose myself. I have to keep this section really small because I’m not confident enough to enforce a lot of styles across the whole site. These are the only two I feel okay about right now, and I might change the <section> one:

/* put a 950px column in the middle of each <section> */
section {
  --inner-width: 950px;
  padding: 3rem max(1rem, (100% - var(--inner-width))/2);
}

a {
  color: var(--orange);
}

I think for the base styles it’s going to be easiest for me to work kind of bottom up – first start with almost nothing in the base styles, and then move some styles from the components into base styles as I identify common things I want.

7. spacing

I haven’t completely worked out an approach to managing padding and margins yet. I’m definitely trying to be more principled than how I was doing it in Tailwind though, where I would just haphazardly put padding and margins everywhere until it looked the way I wanted.

Right now I’m working towards making the outer layout components in charge of spacing as much as possible. For example if I have a <section> with a bunch of children that I want to have space between them, I might use this to space the children evenly:

section > *+* {
  margin-top: 1rem;
}

Some inspiration blog posts:

8. responsive design: use more grid!

The way I was doing responsive design in Tailwind was to use a lot of media queries. Tailwind has this md:text-xl syntax that means “apply the text-xl style at sizes md or larger”.

I’m trying something pretty different now, which is to make more flexible CSS grid layouts that don’t need as many breakpoints. This is hard but it’s really interesting to learn about what’s possible with grid, and it’s a good example of something that I don’t think is possible with Tailwind.

For example, I’ve been learning about how to use auto-fit to automatically use 2 columns on a big screen and 1 column on a small screen like this:

  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), max-content));
  justify-content: center;

I also used grid-template-areas a lot which is an amazing feature that I don’t think you can use with Tailwind.

Some inspiration:

9. the build system: esbuild

In development, I don’t need a build system: CSS now has both built in import statements, like this:

@import "reset.css";
@import "typography.css";
@import "colors.css";

and built in nested selectors, like this:

.page {
  h2 { ...}
}

If I want, I can use esbuild to bundle the CSS file for production. That looks something like this.

esbuild style.css --bundle --loader:.svg=dataurl  --loader:.woff2=file --outfile=/tmp/out.css

Even though I usually avoid using CSS and JS build systems, I don’t mind using esbuild (which I wrote about in 2021 here) because it’s based on web standards and because it’s a static Go binary.

why migrate away from Tailwind?

A few people asked why I was migrating away from Tailwind. A few factors that contributed are:

  • Tailwind has become much more reliant on a build system since 2018, I think it’s impossible (?) to use newer versions of Tailwind without using a build system. So I’ve been using Tailwind v2 for years. (there’s also litewind apparently)
  • It’s always been true that you’re supposed to use Tailwind with a build system, but I’ve never really done that, so I have 2.8MB tailwind.min.css files in a lot of my projects and it feels a little silly.
  • I’m a lot better at CSS than I was when I started using Tailwind
  • Ultimately Tailwind is limiting: if you want to do Weird Stuff in your CSS, it’s not always possible with Tailwind. Those limits can be extremely useful (a lot of this post is about me reimplementing some of Tailwind’s limits!) but at this point I’d like to be able to pick and choose.
  • I ended up with sites that mixed both vanilla CSS and Tailwind in the same project and that was not fun to maintain
  • I got curious about what writing more semantic HTML would feel like.

CSS features I’m curious about

While doing this I learned about a lot of CSS features that I didn’t use but am curious about learning about one day:

that’s all for now!

I still feel happy that I started using Tailwind, even if I’m moving away from it now. I learned a lot from using it and I can still use some parts from it in my sites even after deleting tailwind.min.css.

Thanks to Melody Starling who originally designed and wrote the CSS for wizardzines.com, everything cool and fun about the site is thanks to Melody.

Also I read so many incredible blog posts about CSS while working on this (from CSS Tricks, Smashing Magazine, and more), I’ve tried to link some of them throughout this post and I really appreciate how much folks in the CSS community share their practices.

Read the whole story
alvinashcraft
just a second ago
reply
Pennsylvania, USA
Share this story
Delete

The Coding Harness Behind GitHub Copilot in VS Code

1 Share

Learn why the coding harness around GitHub Copilot in VS Code matters as models, tools, agents, and providers evolve.

Read the full article

Read the whole story
alvinashcraft
13 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Creating a Simple MCP Server in .NET

1 Share

In my previous post, I covered how to interact with a local LLM from .NET using LM Studio. In this post, I’m going to take that a little further and build an MCP server - which lets tools like Cursor, VS Code, and LM Studio itself call into your code.

MCP (Model Context Protocol), it’s an open standard that lets AI tools discover and invoke “tools” that you define. It’s like a contract between an AI assistant and your code: you describe what your tools do, and the AI decides when to call them. The full code is in my offline-ai project.

MCP Server

Here’s the important (and most of the) code in Program.cs for an MCP server:

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddSingleton<McpFileLogger>();
builder.Services.AddSingleton<LmStudioClient>(sp =>
{
    var logger = sp.GetRequiredService<McpFileLogger>();
    return new LmStudioClient(baseUrl, configModel, logger);
});

builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();

await builder.Build().RunAsync();

Obviously Microsoft are doing the heavy lifting here, but I strongly suspect the weight isn’t too large!

The Important Bits…

  • AddMcpServer() - registers the MCP server in the DI container.
  • WithStdioServerTransport() - tells it to communicate over stdin/stdout, which is how MCP Clients (like LM Studio) talk to the server.
  • WithToolsFromAssembly() - automatically discovers all the tools in your assembly.

That’s all she wrote. The next part is defining the tools…

Defining a Tool

An MCP tool is just a static method with a couple of attributes. Here’s one that creates a file:

[McpServerToolType]
public static class FileTools
{
    [McpServerTool, Description("Creates or overwrites a file at the specified path with the given content.")]
    public static string CreateFile(
        McpFileLogger logger,
        [Description("Full or relative path where the file will be created")]
        string path,
        [Description("Content to write into the file")]
        string content)
    {
        var resolvedPath = Path.GetFullPath(path);
        var directory = Path.GetDirectoryName(resolvedPath);
        if (!string.IsNullOrEmpty(directory))
            Directory.CreateDirectory(directory);

        File.WriteAllText(resolvedPath, content);
        return $"Successfully created file at: {resolvedPath} ({content.Length} bytes)";
    }
}

The class has [McpServerToolType] and each tool method has [McpServerTool]. The Description attributes are important - they’re what the AI reads to decide whether to call your tool and what to pass it. The parameters annotated with [Description] become the tool’s input schema.

The McpFileLogger parameter is resolved from the DI container automatically. You can inject any registered service into your tool methods - the MCP SDK handles it.

Here’s a second tool on the same class:

[McpServerTool, Description("Appends content to an existing file, or creates the file if it does not exist.")]
public static string AppendToFile(
    McpFileLogger logger,
    [Description("Full or relative path to the file")]
    string path,
    [Description("Content to append to the file")]
    string content)
{
    var resolvedPath = Path.GetFullPath(path);
    var directory = Path.GetDirectoryName(resolvedPath);
    if (!string.IsNullOrEmpty(directory))
        Directory.CreateDirectory(directory);

    File.AppendAllText(resolvedPath, content);
    return $"Appended {content.Length} bytes to: {resolvedPath}";
}

All very straightforward!

Adding LLM-Backed Tools

Because the MCP server connects to a local LLM via LM Studio, We also need tools that let the AI client chat with the local model:

[McpServerToolType]
public static class LmStudioTools
{
    [McpServerTool, Description("Send a message to the local LLM and get a chat-style response.")]
    public static async Task<string> ChatWithLlm(
        LmStudioClient lmStudio,
        McpFileLogger logger,
        [Description("The message or prompt to send to the LLM")]
        string message)
    {
        var response = await lmStudio.ChatAsync(message);
        return response;
    }
}

The LmStudioClient is the same HttpClient wrapper from the previous post - it calls LM Studio’s OpenAI-compatible endpoint at http://localhost:1234/v1/chat/completions. Because it’s registered in the DI container, the MCP SDK injects it automatically.

The NuGet Package

There’s a single NuGet package (still in preview at the time of writing):

Install-Package ModelContextProtocol -Version 0.9.0-preview.2

Along with the standard hosting packages:

<ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
    <PackageReference Include="ModelContextProtocol" Version="0.9.0-preview.2" />
</ItemGroup>

Plugging It Into Your Client

Once you’ve got the server, you need to tell your AI tool where to find it. Tools like Cursor, VS Code, et al.have an MCP config (my understanding is this is a non-standard standard):

{
  "mcpServers": {
    "mcp-server-offline-llm": {
      "command": "C:\\path\\to\\publish\\McpServerOfflineLlm.exe",
      "args": []
    }
  }
}

The exe needs to be self-contained; otherwise you need the SDK.

Logging

Because MCP uses stdin / stdout for its protocol, you can’t use Console.WriteLine for debugging. Anything you write to stdout gets interpreted as MCP messages. You need to write to stderr instead:

Console.Error.WriteLine("Bad things happened!");
Console.Error.WriteLine("Or... good things happened - all are errors!");

Bad Intentions

The project also includes a standalone host that demonstrates something more interesting: using the LLM to classify user intent before deciding which tool to call.

The idea is that instead of relying on keyword matching (e.g. “does the message contain the word ‘create’?”), you ask the LLM to classify the user’s intent as structured JSON:

{
  "intent": "create_file",
  "confidence": 0.95,
  "reason": "Explicit create with path and content",
  "path": "C:\\temp\\notes.txt",
  "content": "Hello World"
}

If the confidence is above a threshold (0.8), the host calls the MCP tool. Otherwise, it falls back to standard chat. The classification uses a low temperature (0.1) for deterministic output - the same structured JSON technique from the previous post.

This is essentially using one LLM call to decide whether to make a second one - or to call a tool instead. It’s a useful pattern if you want your MCP server to be smarter about when it acts.

Summary

Not exactly rocket science for the implementation. The MCP server itself is about 12 lines. Each tool is a static method with attributes. The SDK handles discovery, serialisation, transport, and DI injection. If you can write a .NET method, you can write an MCP tool.

It’s interesting because, since using tools like Cursor, Claude, et al., I’ve started moving away from .Net - it has too many dependencies - which is suddenly the most arduous part of writing code. But MS have done an amazing job of making the MCP abstraction simple and easy to use.

The full source is on GitHub.

References

Model Context Protocol Specification

ModelContextProtocol NuGet Package

LM Studio

Programmatic Interaction with a Local LLM in .NET

Read the whole story
alvinashcraft
18 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Aspire 13.3.3

1 Share

What's New in Aspire 13.3.3

Patch release for Aspire 13.3 with fixes for debug log level leaking into user resources, Keycloak HTTPS endpoint token invalidation, and endpoint materialization in HostResourceWithEndpoints.

🐛 Fixes

  • 🔇 Debug log level leaking into user resourcesLogging__LogLevel__Default=Debug set by the app host was being inherited by all user resources, silently changing their logging verbosity. The app host now uses ASPIRE_APPHOST_LOGLEVEL instead, which is scoped to Aspire processes only. (#17071, backported via #17078)
  • 🔑 Keycloak HTTPS primary endpoint — Fixed a regression where Keycloak tokens became invalid after an app host restart because the HTTPS endpoint port was dynamic. When developer certificates are enabled, Keycloak's primary endpoint is now upgraded to HTTPS directly, and the endpoint name is set to http to enable standard http+https:// service discovery URLs. (#17058, backported via #17063)
  • 🔌 Endpoint materialization in HostResourceWithEndpoints — Endpoints configured via HostResourceWithEndpoints are now correctly materialized, ensuring endpoint resolution and service discovery work as expected. (#17091, backported via #17092)

🏷️ Housekeeping

  • ⬆️ Bumped DCP (Microsoft.DeveloperControlPlane) from 0.23.5 → 0.23.6 — includes fixes for Kubernetes OpenAPI generator types that caused [SHOULD NOT HAPPEN] failed to update managedFields errors. (#17070)
  • 🚀 Bumped branding to 13.3.3 (#17088)

Full commit: a4615e7c6def6cba4703cdbd84009cd3da9a261b

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete

Call For Papers Listings for 5/15

1 Share

A collection of upcoming CFPs (call for papers) from across the internet and around the world.

The post Call For Papers Listings for 5/15 appeared first on Leon Adato.

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete

Job listings for week ending 5/15

1 Share

Job postings that came across my desk, slack, email, discord, etc this week.

The post Job listings for week ending 5/15 appeared first on Leon Adato.

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories