A theme is data, markup is code, and a menu bar has three rows not two
The Starting Point
The design layer. A deployment should be able to look like the client's product rather than like mine — and a customer should be able to author that themselves and hand the file to another deployment.
Which reopens a decision I made in early September: "a theme is one colour." That was right, and it's been outgrown.
What Actually Happened
The line the whole step turns on
A theme is data. Markup is code. And the boundary between them is the design rather than a limitation of it.
Concretely: the structural axes and the style choices are bounded sets of named options, and that is the security model rather than a decoration of it. Every value that reaches a style block is a string the platform wrote itself. The file only chooses between them.
The brand colour is the one free value, and it's safe for a different reason — it's parsed to OKLCH and re-emitted as eleven derived shades, so nothing the file contains reaches the page as written.
There's a test that a colour carrying a closing brace is refused rather than emitted, and that no token value can contain one.
Strict when authored, forgiving when read
The theme file validator and the theme reader are deliberately different:
- A file being uploaded is being authored, so a typo like
stuctureis named as a mistake and the author is told what it could have been. - A theme already live is being read, so an option a later release withdrew degrades to the default rather than blanking a page on a deployment nobody has touched in a year.
And every problem is collected rather than thrown at the first, because fixing a file one error per upload is a miserable afternoon.
Then I got the colour model wrong. Twice.
The large surfaces — the bars — are the one place the named-options rule bends and takes a free colour. That bend is deliberate: a brand is often specifically a navy, and "one of our three dark blues" is a colour scheme with somebody's logo on it, not whitelabelling.
It's safe because the text colour is derived, never chosen. Pick a dark bar and the text goes light; pick a pale one and it goes dark. Neither was asked for. That's the whole difference between offering a colour and offering a mistake.
Attempt one: the menu colour reached only the strip the logo sits in. So a side menu came out as a coloured header above an uncoloured list — which reads as two things that happen to be next to each other, not as one menu.
Attempt two: I fixed the mapping and then realised a top menu has three rows, not two. The logo row, the row of sections, and the row that opens underneath one. They were sharing two colours, so setting them produced neither of the two things anyone would expect.
Three surfaces now, one each.
And each takes exactly what it was given. The sub-menu used to be a computed shade of the menu, on the reasoning that two tiers of one navigation should look related. That's true, and it is not ours to decide: somebody choosing black, white and black in that order means it, and a control that quietly returns something near the colour you typed is a control you stop trusting.
But the hover is still derived, and that isn't a contradiction. The bars take what they're given because they're three separate decisions somebody makes deliberately. A hover is the same element in a different state — nobody wants to set it, and asking them to would be asking them to do arithmetic.
One more thing I hadn't thought about: a white plate behind the logo. Most client logos are drawn for white paper. A dark navy logo on a dark navy bar disappears, and that's the difference between a whitelabelled deployment and one somebody has to apologise for.
The active-state colour that would have been a smear
An active navigation entry was bg-brand-50 text-brand-700. On somebody's navy bar that's a pale blue smear. It's bg-current/10 now, so it belongs to whatever surface it lands on.
Everything sitting in a bar inherits it now instead of naming a colour: navigation entries, group headings, the account control, the menu button.
Tabs were the default and nobody could see them
Both shipped themes declared record_layout: panels. The shape being ported is tabs, so that was simply the wrong default.
And the tabbed layout was sharing a three-column grid with the details panel, so a tab carrying a table had two-thirds of a screen and scrolled for no reason. It takes the full width now, with details above the strip reading across the page.
Four tests changed with the default — and each said something worth keeping rather than being silenced. Two now name the tab they're reading, which is the point of tabs: one panel loaded, not nine.
The CSS build was depending on which pages I'd visited
This one's genuinely nasty and I nearly missed it.
Module views were never scanned for CSS classes. Only the core package was configured as a source. Every module's classes were arriving by accident, through the compiled-view directory — which only holds a page once somebody has opened it.
So a clean build, before anybody visited a module screen, produced a stylesheet missing its classes. Silently.
Now every package is a source, and the compiled-view directory is no longer one — because a build whose output depends on which pages were visited first differs between two machines on the same commit.
What Worked
Measure the cost of an axis before committing to it. Density and page width turned out to be one token each, because the CSS framework compiles every spacing utility as calc(var(--spacing) * n) and the platform never hard-coded a spacing value. So the real cost of a density axis isn't rescaling — it's touch targets, and there's now a floor: under pointer: coarse anything you tap keeps 2.75rem.
Mobile measured rather than assumed. The navigation below 1024px was genuinely broken, eight grids didn't reflow, and 57 already did. Three of the eight were the record page's detail rows — the most-read screen in the platform — where a label in a third of 375px wraps to three lines.
Preview before you wear it. Activating a theme that turns out to be unreadable, on a live deployment, with no way to have seen it first, is a mistake somebody makes once and remembers for years — and it lands on every user at once.
A parameter applies a theme for one request, every page says loudly it's not in use, and nothing is stored. The middleware unsets it on terminate() rather than trusting the request to end: the theme is a singleton, and under a worker that keeps the container between requests a preview set on one request would still be set on the next. Found by a test asserting the preview hadn't been stored.
Assets are not part of the theme file. A theme is portable — exported, handed to another deployment — and a path into one deployment's storage means nothing in another's. The theme names the assets it expects; the deployment owns the pictures.
The navigation reads the registry. The source's own version is a thousand lines with six hundred of inline CSS, and it names its modules in variables and click handlers — so it needs an edit every time a module is added. This one reads the same navigation registry the sidebar does, in both orientations, with keyboard behaviour the source doesn't have.
And there's finally a menu below 1024px.
What I Learned
- Named options are a security model, not a limitation. If every value reaching a stylesheet is a string you wrote, an uploaded theme file can't inject anything.
- Derive the text colour from the chosen background, never let it be chosen. That's the difference between offering a colour and offering a mistake.
- Derive a hover; don't derive a sibling surface. One is arithmetic nobody wants to do. The other is a decision somebody made deliberately and your "helpful" shade is you overriding them.
- Count the rows in your own layout before mapping colours onto it. I got it wrong twice.
- Check what your CSS build actually scans. Mine was picking up module classes from a cache directory, so the output depended on browsing history.
- A validator should be strict on upload and forgiving on read. Same file, different moments, different failure costs.
- Unset a request-scoped singleton on terminate rather than assuming the container dies.
- Measure the mobile problem instead of estimating it. "Eight grids and the navigation" is actionable; "mobile needs work" isn't.
Tech Notes
- Stack: PHP 8.4, Laravel, Tailwind 4 with OKLCH tokens, Blade
- Theme stored as its file, verbatim, not as columns — columns would make every new option a migration and give the schema two definitions to disagree about
- Four structural axes (navigation, record page, density, page width) + style options (corners, elevation, typeface), all named options
- Five themeable surfaces, free colour, derived text and hover; brand parsed to OKLCH and re-emitted as eleven fixed-lightness shades
- Export produces exactly the file that upload accepts
bin/checkgreen at 430 core / 297 app tests
# spacing axis is free because of this:
padding: calc(var(--spacing) * 4)
Zero Focus Dev
Breaking things, learning fast, sharing it all.