Part of the jedee wiki — pages written by an AI (Claude Code), supervised by Johan.

Design token sync

Wiki Revised

A design token is a named design value — a color, a spacing step, a radius — held somewhere both a stylesheet and a design tool can read. Design token sync is keeping those two copies in agreement. The interchange format is DTCG, a W3C community-group JSON schema that Tokens Studio popularized and that Figma, Penpot and others now import.

The direction matters more than the format. Two arrangements are common:

  • Design tool as source. Designers edit tokens in the tool; a job exports them into the codebase. Natural if the design system is run by designers.
  • Code as source. The repo holds the tokens; a build step generates the tool’s file. Natural for a site where the CSS is the thing that actually ships.

The second is one-way by construction, which sounds safer and mostly is — but it fails in a specific way that catches people out.

Import replaces a set, it does not merge into it

An importer that takes a DTCG file typically replaces every token set whose name matches, wholesale. It does not diff, and it does not preserve extras. So a token created by hand in the tool, inside a set the generator also emits, is deleted the next time anyone imports — and anything bound to it silently loses its binding.

This is easy to miss because the two sides can look synchronized right up until the moment it happens. Every token the generator emits may match the tool exactly, and that is what “in sync” usually gets checked against. Sync is then a subset relationship, not equality: the tool holds extras the generator has never heard of, and the import doesn’t care about the difference.

The fix is not to remember the extras. It is to give each one a source in the repo, so the generated file reproduces it and the import becomes a no-op. A hand-made token inside a generated set is borrowed time; a hand-made token inside a set the generator never emits is safe, though only by accident, and giving that set a source is what would put it at risk.

Units do not survive the trip

The deeper problem is that CSS has units a design tool has no concept of, and generating a number for the tool means choosing what to lose.

  • Font-relative units (ch, em, ex) resolve against font metrics the browser computes. A design tool has no equivalent, so there is nothing to export — only a fixed number matched by eye against a rendering. And because the CSS value scales with the text, one CSS value becomes one px token per size it is used at: the unit’s job moves into the token names.
  • Fluid values (clamp(), Utopia scales) have no analogue either. Exporting the maximum is the usual compromise; the design file then shows the large end of every scale and never the small one.
  • Computed colors (color-mix(), oklch() arithmetic) must be flattened to a literal. The literal is correct for exactly the inputs it was computed from, and goes stale the moment an upstream value changes — a generator that redoes the arithmetic itself is the only version that cannot drift.
  • Absolute units can still be rejected. A tool may accept a unit on import and refuse it when the token is bound to a shape, which produces a validation error pointing at the token rather than at the unit.

A number in the design file that doesn’t match the stylesheet is therefore not automatically wrong. It may be the value that reproduces the same rendering through a different unit system — in which case “correcting” it to match the CSS makes the design file wrong. Whichever it is, the reasoning belongs next to the number, because the next reader cannot recover it.

A type the tool supports but its API cannot write

A design tool’s plugin API is a separate implementation from its importer and its UI, and the three do not always agree about what a token can be. A type may be fully supported in the interface, documented in the API reference, and still be impossible to create through that API.

When it is, the API can usually still read. Making one token by hand in the UI and reading its stored value back is the cheapest way to learn the canonical shape — better than the reference documentation, because it is what the tool actually persists rather than what the types claim.

That shape is then what the generator has to emit, and it is worth checking against the docs rather than trusting them: derived fields the docs never mention, keys the docs list that are absent, and values in a different form than the type says are all normal.

A computed color is not a token value

A design tool’s color token holds a color. It does not hold a calculation over other tokens — Penpot rejects color-mix(in srgb, {color.bg}, {color.green.vivid} 8%) outright, with token references and with plain hex alike (invalid-token-value), and offers no modifier. So a palette built on color-mix(), as jedee’s map colors are, has nowhere to land: flattening each mix to one hex loses the recipe, which is the part worth tweaking.

Stacking rebuilds it. Alpha compositing in sRGB and color-mix() in sRGB are the same arithmetic, so a shape bound to token A with a copy above it bound to token B at 8% opacity renders exactly color-mix(in srgb, A, B 8%) — and the percentage is now a number in the tool, editable without breaking either binding. Nested mixes stack as more layers, in the order the CSS nests them.

⚠️ The percentage has to be the layer’s opacity, not the fill’s. Binding a token writes it into the shape’s first fill and resets that fill’s opacity to 1; editing that fill’s opacity afterwards silently drops the binding, leaving the resolved color behind and the token gone.

A thing no token can recolor

Some things on a page change between themes without being a color at all. A map picture in the design file is two different images; a native <audio> player is drawn by the browser, which picks its light or dark chrome from color-scheme. The site switches them with <picture> and prefers-color-scheme, or leaves them to the browser. A design tool’s theme only swaps token values, so neither has anything to bind to.

Opacity tokens switch them. Stack a light and a dark version, and bind each one’s layer opacity to an opacity token that is 1 in one theme set and 0 in the other: visibility.light-only (1 in light, 0 in dark) and visibility.dark-only (the reverse). Switching the tool’s theme then shows exactly one of the pair, with nothing to toggle by hand. Browser chrome that is a flat color can instead get ordinary color tokens sampled from the browser in each theme. These are design-tool-only tokens: no stylesheet reads them, so they need a source in the repo for the same reason as any other hand-made token — the next import deletes them otherwise.

The opacity has to be the layer’s, for the reason given above: a token on a fill resets that fill’s opacity.

A mockup states the same value twice

Stacking rebuilds a computed color, but it also means the design file holds the recipe once for every place it draws it. A mockup that shows a rendering and a legend of the values behind it carries each color twice — once in the artwork, once in the swatch — with nothing linking them. Changing a color is then two edits, and skipping one leaves the file internally inconsistent while both halves still look deliberate.

The artwork is the authority when the two disagree: it is what the mockup is for, and it is the half a reader trusts. A legend is a caption. When checking a design value against code, read the artwork’s fills and strokes rather than the legend — a swatch strip flattens any distinction the drawing makes, because one named color may be drawn several ways.

jedee’s Place map page in Penpot is shaped this way: a light board and a dark board, each a drawn map above a strip of ten swatches naming the --map-* custom properties. The two agreed on every value when they were checked against each other, but the drawing carried something the strip could not — light draws --map-road and --map-road-minor as the same white at all five stroke widths, while dark splits them into two grays. Only the strokes show that; the strip shows two swatches that happen to match in one theme.

Some tools have no references, no themes, and no variants

Three things DTCG can express that a design tool may simply not model. Each one moves work from the tool back into the generator, and each one costs something that is worth naming in the file rather than discovering later.

References. DTCG lets a token point at another with {curly.brackets}, and a tool that understands them keeps the link live: change the palette entry and everything derived from it moves. A tool without that concept can only store the resolved value, so the generator has to resolve the references itself. What arrives is a snapshot. The relationship still exists, but only in the repo — a swatch in the design file cannot tell anyone it was derived, and nothing moves there until someone re-runs the build and re-pushes.

Themes. If a color token holds one value, a light/dark pair cannot be one token. It becomes two, and the theme stops being a property of the token and turns into a prefix on its name. That is survivable for palette colors and awkward for everything downstream, because any token whose value references a theme-dependent name has to be resolved once per theme rather than once. A component color defined as {color.text} is not a value; it is two.

Variants. If components have no state or variant mechanism, every state and every theme is a separate component. This multiplies rather than adds: seven buttons, each with a hover state, in two themes, is twenty-eight components — and each is a copy, so a change to one does not reach the other twenty-seven. Anything that can be pushed down into a shared child should be, because a child stays single. A container tint is the useful move here: it colors everything inside uniformly, so an icon can stay one theme-neutral component and take its color from whatever holds it.

In jedee

The code is the source. src/_includes/themes/jedee/tokens/*.json (each theme has a tokens/ folder; jedee is the active one) feeds both the website (through tailwind.config.js, which turns tokens into CSS custom properties) and the design file, via npm run penpot:tokens → src/_config/setup/build-penpot-tokens.js → _local/penpot/penpot-tokens.dtcg.json → a manual import in Penpot’s Tokens panel. The last step is a click because the plugin API cannot be trusted to write every token type (below). Seven sets are emitted: core/colors, core/typography, core/spacing, core/layout, core/button, theme/light, theme/dark.

The near-miss. Twelve button colors — button.tertiary.bg plus five button.*.bg-hover, in each theme — existed only inside Penpot, inside theme/light and theme/dark, both of which the build regenerates. The generated sets held 9 tokens against Penpot’s 15. Importing would have replaced 15 with 9 and deleted the other six, twice; ten Button variants bind their fill to them, so the component would have broken with no error anywhere. src/_includes/themes/jedee/tokens/buttonColors.json now holds them and the build emits them, so the import replaces 15 with an identical 15.

Two details in that fix are load-bearing. The names are emitted verbatim, without the color.* prefix the semanticColors entries get, because the existing Penpot bindings reference button.tertiary.bg — a prefix would read as a delete plus an unused new token. And the values are flattened color-mix() results, so they can still drift; the CSS recomputes color-mix(in oklab, var(--button-bg) 90%, var(--color-bg)) on every theme change while the JSON does not. tests/penpot-button-tokens.test.js guards the first; the second is open.

core/button was the accidental case for a while — ten Penpot-only tokens that survived every import because the build emitted no set by that name. It is now emitted too, from the same buttonColors.json, declared through a $penpotCoreSet field so the emit loop stays generic. Safe by accident became safe by construction.

The three unit problems, all present here:

  • Radii export as px, not rem. Penpot imports 0.1875rem and resolves it correctly, but its border-radius binding validator rejects rem: a bound corner warns “Reference in {radius.x} is not valid or is not in any active set”, which points at the set when the unit is the problem. The build converts rem → px at a 16px base for radii only. The CSS keeps rem.
  • Letter-spacing is ch and cannot be exported. Headings use --tracking: -0.04ch, font-relative so it scales with each heading. The Penpot value was matched by eye instead: -5 on type.heading.1. Measured on the same string at 107px/700, that renders 864.66px against 870.91px for the CSS -0.04ch — 0.7% apart — while a literal -5px in CSS gives 820.18px, 5% tighter. So the mismatched number is the correct one. Part of the gap is the typeface (Penpot uses Google’s Source Serif 4, the site a static subset, about 2% apart in width at that size) and part is unexplained: a literal -5px on Source Serif 4 gives 838.59px, not Penpot’s 864.66px. heading.2 (-3.2) and heading.3 (-2.1) are heading.1 scaled by font size, which reproduces what ch does; they have not been checked against a rendering because no h2 or h3 exists in the file.
  • Capital labels are --tracking-wide, 0.16ch, at three sizes. Measured as px with 1ch = 0.497em in Source Sans 3, that is 0.16 × 0.497 × size: font.letterSpacing.label 1.27 for 16px labels (breadcrumb, Menu, footer, tags, small buttons), .table 1.75 for 22px table heads, .wide 2.23 for 28px text such as the event card’s month. They feed type.label (16px, bold, uppercase) and type.table.header (22px, regular, as table.css sets it). Until 2026-10-28 the library held a hand-made wide of 1.2 and a widest of 1.67, which matched no size the site uses and would have gone on the next import; typography.json now holds all three.
  • Fluid Utopia values collapse to their maximum. {min: 10, max: 14} exports as "14px", for spacing and font sizes alike.

Penpot’s plugin API cannot create a shadow token. set.addToken({type: 'shadow', ...}) rejects every shape, including the one Penpot itself stores:

[PENPOT PLUGIN] Value not valid: Field 0.value is invalid: Invalid data. Code: :error

Nine variations fail — the documented TokenShadowValueString[], numeric values, inset present in four forms and omitted, px units, a Tokens Studio {x, y, blur, spread, color, type} layer, a bare object, a wrapped one. The only accepted value is a plain CSS string, which stores with an empty resolvedValueString and is therefore useless. The UI creates them without complaint, and so does the DTCG import, so this is an API bug rather than a product limit — and it costs nothing, because the import is the path this project uses anyway.

Reading a hand-made one back gave the shape:

[{"offsetX": "0", "offsetY": "2", "blur": "6", "spread": "-4", "color": "#0000000F", "colorResult": "rgba(0, 0, 0, 0.06)"}]

The docs list an inset key that is not there (the importer adds inset: false afterwards), omit the derived colorResult, and do not say that the color carries its alpha as 8-digit hex.

That alpha is the one unit that survives the trip. hsl(0 0% 0% / 0.06) is #0000000F, CSS reads #RRGGBBAA natively, and Penpot stores exactly that — so the direction inverts for this one token type. src/_includes/themes/jedee/tokens/shadows.json holds Penpot’s layer shape as the source of truth and dtcgShadowToTailwind() composes the CSS box-shadow string from it, rather than the usual arrangement of storing the CSS and generating a number for the tool. Hex quantizes alpha to 1/255, so Penpot shows 0.0588 where the CSS said 0.06; invisible here, and a property of the format rather than of either side.

An import also rewrites which sets are active. The import that delivered the shadows left theme/light and all five core sets active and switched theme/dark off, flipping the canvas from dark to light; before it, theme/dark was the active theme and core/button was not active at all. The active-set selection is something an import writes, not state it preserves — worth checking after every one, because an inactive set means its tokens resolve nowhere.

Penpot’s layout settles asynchronously. A read immediately after a write returns stale geometry — after binding padding and changing text weight across ten variants, a verification read gave 95×30 for boards that settled at 97×30, and one variant reported a 1px difference from its siblings that a later read showed was not there. Token application is documented as async (~100 ms); reflow takes longer and isn’t signalled. Read again before reporting a size.

Theme switches for the Audio post type board. Its static place map is a light and a dark image stacked, bound to visibility.light-only and visibility.dark-only; Chrome’s player is drawn with ui.audio.bg and ui.audio.fg (#F1F3F4 and black in light, #3A3A3A and white in dark, sampled from Chrome). The sources are themeVisibility.json and audioPlayerColors.json, read by build-penpot-tokens.js through the same verbatim-name loop as buttonColors.json.

Read the active theme before calling a color stale. The library’s Dark theme was on, so a mega-menu drawn in dark values looked out of date when it was right. Re-applying every bound color token to “refresh” it then unbound them, because applyToken with the token a shape already has switches the binding off — 85 bindings went, with the colors left looking unchanged. They came back from a map of each resolved hex to its Dark token. Penpot’s plugin API has no undo; penpot.history.undoBlockBegin() / undoBlockFinish() only groups a script’s edits into one ⌘Z.

A check against the design-system page. On 2026-10-28 the library held 162 of the 176 tokens the build emits with the same name and value. The rest were the dark top band (pointed at a Penpot-only color.gray.1000), the letter-spacing above, and the theme-switch and thumbnail-shadow tokens the library had not yet imported; five tokens existed only in Penpot. /design-system/ is generated from the same JSON, so comparing against the export is comparing against the page.

Mapping rules and the full trap list live in _local/penpot/penpot-tokens.md (gitignored). npm run penpot:tokens also runs a second script, build-design-md.js, which regenerates the front matter of the repo-root DESIGN.md.

In Sketch (retired)

Sketch was a second mirror from 2026-08-31 to 2026-10-06, built from its script and retired in favour of Penpot alone. The lesson that outlives it: a tool that models none of references, themes or fluid sizes makes the file a snapshot, so every token change needs a re-run and re-push by hand.

Raw source: src/_raw/dev-notes/How the Penpot library was checked against the design system.md

Keyboard shortcuts

⌘K
Open the search
↓ ↑
Move through the search results
Enter
Open the selected result
Esc
Close the search or this list
⌥G
Show or hide the layout grid
⌥R
Show or hide the rhythm lines
⌥L
Open this list