DEV Community

Phong Designs AI System
Phong Designs AI System

Posted on

Your tokens are correct and dark mode still broke the screen

The token file was clean. Two tiers, aliased, no copied values, reviewed by two people. Then somebody added a dark theme and 127 places on the screen did not move.

None of them looked wrong before that. That is the part worth sitting with. A value that was typed in and a value that resolved through a role are visually identical. The difference only exists in what happens next.

This is not a tooling problem. You can hit it with Style Dictionary, Tokens Studio, native Figma variables, plain CSS custom properties, or all four. It is a binding-tier problem, and it stays invisible until it is expensive.

The one rule that decides whether dark mode is a switch or a rebuild

Two tiers, each with one job.

Primitive holds raw values and no opinions. Colour ramps, spacing scale, type sizes, radii. It is the only file you edit to rebrand.

Semantic holds roles. What a value is for. This is the only tier with modes.

// 1-primitive.json: no opinions, no modes
{
  "color": {
    "brand": { "500": { "$value": "#6366F1", "$type": "color" } },
    "slate": { "900": { "$value": "#0F172A", "$type": "color" } }
  }
}
Enter fullscreen mode Exit fullscreen mode
// 2-semantic.json: roles, and the only tier that knows about modes
{
  "color": {
    "action": {
      "primary": {
        "background": {
          "$value": "{color.brand.500}",
          "$type": "color",
          "$description": "Fill of the single primary action on a screen. Not for links, not for selected rows, not for status."
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Bind a button to color.brand.500 and dark mode has nothing to swap. It is a fixed value. It will stay that exact indigo on a dark surface whether or not anybody can read the label on it.

Bind it to color.action.primary.background and the swap happens underneath. The button follows for free, in both modes, forever.

That is the whole rule, and almost nobody disputes it. The reason it gets broken constantly is that reaching past a layer always works in the moment. The screen looks right. The rule only earns its keep later, when a value has to mean something different in a different context and there is nothing in the file that knows the difference between this blue and the colour a primary action uses.

An alias tier that mirrors primitives 1:1 is two names and one decision

Here is the version that passes review and still breaks.

// This looks like two tiers. It is one.
"color.surface.raised": { "$value": "{color.slate.50}" },
"color.surface.default": { "$value": "{color.white}" },
"color.surface.sunken": { "$value": "{color.slate.100}" }
Enter fullscreen mode Exit fullscreen mode

Three roles, three primitives, one-to-one, forever. Nothing is wrong on the page. But a tier only earns its place when it can refuse something. If every semantic name maps to exactly one primitive and no role ever absorbs a decision, you have two files and one decision, and the second file is a rename.

The test I use: can this role say no? color.surface.raised has to be able to refuse a case that color.surface.default takes. If it cannot, nothing stops a component reaching past it to the raw value for convenience, because the role is not carrying any information the primitive did not already carry.

And when a layout genuinely needs a value the scale lacks, the answer is to add the semantic role, then point at it, not to let one component resolve straight to a primitive. A component reaching past semantic for convenience is how a token set quietly loses the ability to change anything centrally.

The pairs nobody listed

Now the half that bites hardest, because it survives every check.

Contrast is a property of a pair. Your token file holds colours, one at a time. So the pairs get measured from a list somebody wrote down, and building the screen invents pairs that were never on it.

  • A label on a tinted chip
  • An initial on an avatar ground
  • A caption on the far end of a gradient
  • White on a status fill

If your built list is the same length as your planned list, it was copied rather than enumerated.

Three specific failures worth naming, because each one passes with both tokens individually correct:

1. The worst ground is the one nearest the text colour, not the darkest one. Dark text is hardest on the darkest stop. Light text is hardest on the lightest. A table that measures both modes against the same background has measured one of them not at all.

2. Text on a status fill flips with the mode. Your amber gets lighter in dark mode, white on it drops under 3.0, and nothing in the token file says so because both values are fine on their own. A colour used as a fill and the same colour used as text are two different roles, even at the same hex: the fill is measured against what sits on it, the text against every ground it can land on.

3. A translucent surface makes the text's background a computation. The colour actually behind the text is in no token file and changes as the page scrolls.

/* The real background here is a composite, not a token */
.card {
  background: rgb(255 255 255 / 0.72);
}
Enter fullscreen mode Exit fullscreen mode

Composite the fill over the worst ground stop and measure that. And note the consequence: a translucent surface is only valid over the ground it was measured against. Move the same card so it overlaps a hero image or another card, and the percentage showing through is no longer the canvas, and its overlapping strip goes muddy while every number still checks out.

Opacity is not how text gets de-emphasised

This one is short and it is the most common.

/* measured 5.2, renders 2.8 */
.caption { color: var(--color-content-primary); opacity: 0.55; }

/* measure this instead */
.caption { color: var(--color-content-secondary); }
Enter fullscreen mode Exit fullscreen mode

The compositor applies opacity after the measurement. So the number in your audit is right and the pixels on the screen are not. Opacity is legal on a surface, measured through. It stays illegal on text.

The half a token set cannot hold

A token set holds colour, spacing, radius, type size, elevation. That is a real and useful floor.

It cannot hold:

  • what leads the screen
  • the size contrast between the largest block and the smallest type
  • how much of the screen is empty
  • how many distinct block shapes there are

So token wins every conflict has a consequence nobody intends: what a token can express gets decided, and what it cannot express nobody decides at all. The screen reads as basic while every value is correct.

A locked type scale is the clearest case. It gives you correct sizes. It is not a hierarchy. Every value on the page can be right while the largest type sits in the middle of it, which leaves the screen with no head and makes every local fix break something else.

The cheap fix is a ladder per screen, head down to caption, stepping down once at each level. Ten lines before any markup. It turns hierarchy into something a screen can fail, rather than something you eyeball.

Make the rules mechanical, or they get forgotten silently

Every rule in here has the same shape: it can be satisfied at magnitude zero unless something fails without it.

Two checks that cost almost nothing:

The toggle is a test, not a convenience. Build one mode and reach the other with a toggle in the file. One click forces every colour through a semantic variable, so a value that fails to change is a hard-coded defect, and you fix it in the tokens, never on the screen. Building both modes by hand doubles the work, lets the two drift, and hides exactly what the toggle would have exposed.

Give every token a failing condition. One line per token saying what it must not be used for. Not usage guidance, an exclusion.

"$description": "Fill of the single primary action on a screen.
                 Not for links, not for selected rows, not for status."
Enter fullscreen mode Exit fullscreen mode

A linter cannot check that sentence. A reviewer can, and it is the difference between a variable and a decision. A rule with no failing condition cannot be broken, so nobody notices when it is used in the wrong place, and consistency in the wrong role is the expensive failure, because it is the quiet one.

There is a side effect I did not expect when we started doing this. A request that cannot be refused by any existing rule is almost always a genuine gap. So the exclusions end up working as an intake filter too: either a new case falls inside a stated boundary, or it names something nobody has ruled on yet, which is the honest version of a new decision.

What this does not solve

A closed, exclusion-carrying token set stops invention. It says nothing about whether the set is any good. Constrain a team, or an agent, to a badly designed system and you get consistent, coherent, thoroughly wrong output, delivered fast.

And it is work. Every genuinely new requirement now needs a decision instead of an improvisation. That is the point, and on a small project it can cost more than the drift would have.

The set I use, free

The two-tier set this is written from is public: 698 tokens, W3C DTCG format with alias syntax, Light and Dark already paired on every mode-aware role, plus a Figma plugin that imports both as Variables in dependency order. Free for commercial work, no attribution. It also feeds Style Dictionary and Tokens Studio, since the format is standard.

Free 2-tier design tokens + Figma Variables import plugin

If you import it, I am curious which semantic role you had to add first. That answer usually says more about a product than its brief does.

And if you have ever run the dark-mode toggle on a build you thought was clean, I would like to know what your number was. Mine was 127, and I did not see it coming.

Top comments (0)