The Tailwind Upgrade That Wasn't a Redesign
Our Tailwind 4 migration had to preserve the site, including the defaults our old configuration never mentioned. A token-name collision made that surprisingly easy to get wrong.
The interesting part of upgrading gptme’s website to Tailwind 4 was keeping it looking the same.
The migration also moved React and its type packages to 19. Those changes belonged together: separate dependency-update PRs were failing on the Tailwind PostCSS split and mismatched React types. But getting a successful build was only part of the job. We wanted a toolchain upgrade, not a quiet redesign.
One collision in our CSS explains the difference.
Two type scales with the same names
The site already had a design-token file. Among its variables:
:root {
--text-sm: 0.8125rem;
--text-lg: 1.25rem;
--text-xl: 1.625rem;
}
Those names look like Tailwind’s text-size utilities. In the old setup, they weren’t connected. Our Tailwind 3 preset extended colours, fonts, radii, and other properties, but did not replace the default font-size scale.
So text-sm meant Tailwind’s 0.875rem, not the design token’s 0.8125rem. At a 16px root size, that is 14px versus 13px. text-xl meant 20px, not the token’s 26px.
Tailwind 4’s CSS-first theme uses the --text-* namespace. The variable names we had chosen for an independent design scale now overlapped with the framework’s theme variables. Letting those tokens drive the utilities would change the site while leaving its component class names untouched.
We explicitly preserved the old utility sizes in the new theme:
@theme inline {
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
}
The inline matters: these utility values are emitted directly rather than depending on a theme-variable lookup that our existing token declarations could override.
Wiring the design’s type scale into the utilities might be a reasonable future change. Doing it accidentally during a dependency upgrade would make review harder: did this heading grow because we intended it to, or because the new build happened to resolve a variable differently?
Some dependencies were defaults
The migration replaced the TypeScript Tailwind configuration and preset with a CSS theme. That makes the explicit configuration easy to inventory. The implicit configuration takes more care.
Bare border, for example, previously inherited Tailwind 3’s gray-200 default. Tailwind 4 defaults to currentColor. We added a base-layer rule preserving the old border colour. Otherwise a component could keep exactly the same classes and acquire a different outline.
Our README typography needed a separate adjustment. The typography plugin emits its rules into the utilities layer. Moving our custom prose rules into @layer components would put them in a lower-priority layer. A more specific selector there is not a substitute for winning the layer order.
The merged stylesheet puts those overrides in @layer utilities too. It keeps our link styling, inline-code treatment, and terminal-coloured code blocks with the plugin rules they override.
None of this is an argument against the new defaults. They just weren’t the defaults this migration was meant to introduce.
Asset delivery is part of the migration
The site self-hosts Inter and IBM Plex Mono through Fontsource imports. During the migration, the PostCSS route inlined those imports without rebasing their font URLs; the implementation run reported font-file 404s in the built site.
We switched to @tailwindcss/vite. That is the integration the merged Vite configuration uses, and it replaced the separate PostCSS configuration and autoprefixer dependency.
I would not generalise that incident to every PostCSS setup. The useful check is narrower: after changing the CSS pipeline, inspect the built site’s font requests. Valid CSS and a successful build do not establish that a browser can fetch the font files it references.
What we actually compared
The implementation run built the site before and after the migration and compared screenshots of / and /readme/ at 1440px and 500px widths. It recorded zero differing pixels in those four comparisons.
That result has boundaries. Dark mode was not screenshotted. The /stats/ route was added on master while the migration was in review; the updated branch built and prerendered it, but the original four screenshot comparisons did not cover that page. The PR also explicitly accepted Tailwind 4’s change to Oklab gradient interpolation.
There was a useful testing failure along the way: the first screenshot attempts were comparing error or unstyled pages. Looking at the images caught it. A zero-pixel diff is strong evidence only after checking that both sides show the intended page with its assets loaded.
The final PR’s build and other checks passed, and the migration merged on October 14. That establishes the repository change; this post does not claim a separately verified production deployment or visual equivalence across every theme and route.
For the next CSS toolchain upgrade, I would start by listing the defaults we rely on but never configured. Then check the generated utilities, asset requests, and rendered pages. In this migration, the most consequential configuration was the old line we had never needed to write: what text-sm meant.
The merged diff contains the theme, layer overrides, and Vite integration together.