SKIP TO CONTENT
Carlos Barahona sigil CARLOS BARAHONA
← JOURNAL

ADMIN PANEL · 9 MIN

Typed fields, untyped CSS

The point of putting a design system into a CMS is that someone can change it without opening a code editor. That only pays off if the fields are typed: colour inputs that show a swatch, font pickers with a known registry, a radius field that explains what --radius drives. Typed fields are the entire value proposition.

CSS themes are the opposite of typed. A theme is whatever custom properties somebody decided to declare. So the import box (paste a tweakcn export, fill the whole form) is a closed schema accepting an open format, and every hard part of this feature comes from that one mismatch.

The paste box has to survive real CSS

The version of the parser you write first is a single regex over selector { body }. It works on the example in your head and dies on the first real export, because tweakcn nests its rules:

@layer base {
  :root { --primary: oklch(0.53 0.19 262); }
}

So the scanner walks the string tracking brace depth instead, and recurses into conditional group rules (@layer, @media, @supports, @scope, @container) to find the rule blocks inside them.

Two things in there only exist because real input taught them. The first is how a selector gets read:

const raw = css.slice(selectorStart, bodyStart)
const selector = raw.slice(raw.lastIndexOf(';') + 1).trim()

Everything since the previous } is candidate selector text, but a real stylesheet opens with statement at-rules like @import "tailwindcss"; and @custom-variant dark (...);. Without slicing to what follows the final ;, the selector reads as @import "tailwindcss"; :root, starts with @, and the block gets discarded as an at-rule. Every token silently vanishes.

The second is that @theme blocks are dropped on purpose. They hold Tailwind mappings, not values:

@theme { --color-primary: var(--primary); }

Importing that would store the literal string var(--primary) as your primary colour, giving you a theme that renders as nothing at all.

Not every variable has a home

Mapping the parsed properties onto field paths is where the closed schema starts refusing things, so the importer returns three channels rather than one:

export type ThemeImportResult = {
  skipped: string[]
  updates: FormUpdate[]
  warnings: string[]
}

updates is the easy path. The other two are the design work.

A font stack that isn’t in the registry can’t go in the select field. It falls through to the custom-stack escape hatch, and raises a warning that says what will actually happen rather than that something happened:

--font-heading: “Whitney” is not in the font registry, stored as a custom stack. Nothing will load that family unless it is installed on the visitor’s device.

That sentence is the whole point of the warning. The alternative, accepting the family and storing it and moving on, fails at render time on someone else’s machine, which is the worst place for it to fail.

Two more get named instead of swallowed. A shared token that differs between the light and dark blocks keeps the light value and says so. And --shadow is dropped with an explanation: tweakcn emits it for Tailwind v3, and v4 has no bare shadow utility for it to drive, so importing it would create a token nothing reads.

Everything else lands in skipped, and the UI lists it.

The escape hatch is what makes lossy safe

A list of skipped variables is only useful if you can do something about it, so the collection has a custom tokens array (a CSS variable name, a light value, a dark value) behind one validator:

validate: (value) =>
  typeof value === 'string' && /^--[\w-]+$/.test(value.trim())
    ? true
    : 'Must be a custom property name, e.g. --my-token'

And the exporter reads them back out alongside the modelled tokens. That closes the loop: paste a theme, get told which four properties didn’t map, paste those four into custom tokens, export, and the CSS that comes out is the CSS that went in. The schema stays typed and opinionated, and it still never eats your data.

Modelling everything would have been the other answer. It’s worse: every new token in the ecosystem becomes a migration, and you’re still lossy the day someone invents one you haven’t got.

Owning the output means owning the cascade

Emitting the CSS turned out to have the subtlest bug in the feature. The var map starts from a complete default set:

const vars = { ...DEFAULT_TOKENS[mode], ...DEFAULT_SHARED_TOKENS }

That looks defensive. It’s load-bearing. The injected theme CSS is unlayered and the globals.css defaults are layered, and an unlayered :root { --primary } outranks a layered [data-theme='dark'] { --primary } regardless of selector. Emit a partial dark set, only the tokens the editor actually filled in, and every unset dark token falls back to the unlayered light :root value instead of the layered dark default. Dark mode quietly fills with light colours, and it looks like a theming bug rather than a cascade one.

Source order matters for the same family of reasons. :root and [data-theme='dark'] both have specificity (0,1,0), so the dark block is emitted second on purpose. Nothing about that is visible if you only ever test one mode.

There’s also a sanitizer on the way out, because these values are written verbatim into a <style> element and a value containing </style><script> would be stored XSS. Editors are authenticated, so it’s not much of a threat, but it’s one function at the single point every consumer passes through, and it costs nothing.

Two panels, not a toggle

A per-mode token system can’t be reviewed one mode at a time. Toggling between light and dark throws away the comparison, which is the thing you are actually judging. So the preview route renders both modes at once, side by side, from the same document.

That creates a precedence problem the rest of the app doesn’t have. The frontend layout has already injected the active site theme at :root, and this page needs to show the theme being edited, in two different modes, on one page. Neither of those is a :root job.

So the panels take their tokens as inline style properties instead:

<div data-theme={mode} style={themeToVarMap(theme, mode)}>

Inline styles outrank both the cascade layer holding the defaults and the unlayered injected block, so there is no precedence fight left to reason about. It’s the third floor of the same building as the exporter: defaults are layered, the live site theme is unlayered, the preview is inline, and each one beats the last on purpose.

The data-theme attribute on the wrapper is what makes dark: variants resolve inside it, and that only works because the dark variant in globals.css is declared with bare, non-descendant selectors. An element has to be able to answer to its own theme attribute rather than only an ancestor’s.

All of it runs through useLivePreview at depth: 0, so the panels repaint as you type, before anything is saved. Underneath them, a toggle dumps every resolved token into a table with swatches for both modes. That’s the view you want when a colour looks wrong and you need to know whether it came from your edit or from a default you never touched.

Twenty-eight blocks and the one that can’t nest

The page builder started with the six blocks the template ships and is now at twenty-eight, grouped in the source by the order they arrived: core marketing first, then editorial and media, then utility.

The count isn’t the interesting part. The exclusion is. There’s a Section container that holds columns, and those columns accept the same block array everything else does, defined in a module that never imports Section:

/** Every block an editor can place, except the `Section` container itself. */
export const nestableBlocks: Block[] = [FeatureGrid, FeatureSplit, Stats /* ...25 more */]

Put Section in its own nestable list and an editor can nest containers into each other without limit. Payload will let them. It keeps letting them right up until the recursion in the renderer blows the stack, which is a runtime failure produced entirely by content, with no bad code anywhere to find.

The module boundary earns its keep twice over. The array is built at module-eval time, so a cycle between it and the section config would leave one of them holding undefined instead of a Block, and that failure surfaces as a block quietly missing from the picker rather than as an error.

It’s the same shape as the custom tokens array, pointed the other way. Give editors an open-ended system, then find the single constraint that keeps it from eating itself.

What’s still open

The light/dark split is decided by a selector allowlist: :root, html, :host on one side, .dark and the [data-theme] variants on the other. That matches how tweakcn and shadcn write themes, which is the input this was built for.

It does not match a theme written against prefers-color-scheme. The scanner recurses into @media correctly, finds the :root inside it, and then classifies it by selector alone, so a dark block guarded by a media query is read as light, and since later blocks win, it overwrites the light values on its way past. The fix is to carry the enclosing at-rule down through the recursion and let a prefers-color-scheme: dark context override the selector’s verdict. It hasn’t bitten yet because nothing I’ve pasted writes themes that way, which is exactly the kind of reasoning that stops being true the first time someone else uses it.