Skip to content

fix(tokens): stop tailwind-tokens.css self-referencing (breaks light mode where Tailwind theme output is unlayered) - #119

Merged
Shrinks99 merged 2 commits into
mainfrom
fix/theme-config-cyclic-tokens
Jul 28, 2026
Merged

fix(tokens): stop tailwind-tokens.css self-referencing (breaks light mode where Tailwind theme output is unlayered)#119
Shrinks99 merged 2 commits into
mainfrom
fix/theme-config-cyclic-tokens

Conversation

@Shrinks99

@Shrinks99 Shrinks99 commented Jul 28, 2026

Copy link
Copy Markdown
Member

Problem

Importing @eqtylab/equality/theme-config.css left every token resolving to nothing in light mode in our docs site (Astro + Starlight + Tailwind v4). Dark mode was fine. Anything built on the tokens — including Equality's own <Button> — rendered unstyled.

Root cause

build-tokens.js generated tailwind-tokens.css as:

@theme inline {
  --color-background: var(--color-background); /* ...×260 */
}

Tailwind emits those into :root,:host, so the shipped CSS contains :root,:host { --color-background: var(--color-background) }. That declaration is cyclic → invalid at computed-value time, so the token computes to nothing.

Whether that dead declaration wins comes down to cascade layers, and that is decided by the consumer's build:

  • Plain Vite/PostCSS apps (governance-studio, explorer, gateway): Tailwind's theme output lands inside @layer theme. Unlayered declarations always beat layered ones, so the real light values Equality scopes to html (unlayered) win. Light works — which is why this was invisible for so long.
  • Starlight: @astrojs/starlight-tailwind ships its own top-level @theme {}; when Tailwind merges it, the emitted theme-variable block comes out unlayered. Now it's a pure specificity fight — :root (0,1,0) outranks html (0,0,1) — so the cycle wins and light collapses to nothing. Dark survives because html[data-equality-theme='dark'] (0,1,1) still wins.

This is not a Tailwind-version or import-style issue — verified that 4.1.18 / 4.2.1 / 4.3.3 all emit the block into @layer theme for a plain input, and it's the presence of starlight-tailwind's unlayered @theme that hoists it out.

Fix

Register the colour tokens in a non-inline @theme carrying their real light values, instead of @theme inline self-references:

@theme inline {
  --color-mixed-light: color-mix(...);   /* genuinely inline — needs per-element --mix-color */
  --color-mixed-dark:  color-mix(...);
}

@theme {
  --color-background: color(display-p3 1 1 1);   /* real value, no cycle */
  /* ...×260 */
}
  • Utilities still compile to background-color: var(--color-background) — so consumer --color-* overrides (html-scoped and wrapper-scoped, e.g. governance-studio's brand tokens) are unchanged.
  • The emitted :root,:host block now holds a plain value, so it's harmless whether it ends up layered or not.
  • Dark keeps overriding via its more specific selector.

Diff is just the generator plus the regenerated tailwind-tokens.css.

Verification (headless Chrome, real built CSS, full 260-token theme-config)

Pipeline Light Dark .bg-* utility --color-* override
Starlight — TW 4.3.3 + starlight-tailwind (was broken) ✅ resolves n/a
Plain Vite — TW 4.1.18 (governance-studio) ✅ html + wrapper both win

Before the fix, the same Starlight probe returned empty custom properties in light; after, --color-background = color(display-p3 1 1 1), --color-text-primary = color(display-p3 0 0 0), --color-lilac-400 resolves, and the .bg-background utility computes correctly.

Downstream

  • governance-platform-docs can drop its temporary relative deep-import workaround of dist/generated/color-vars.css once it bumps to the release containing this fix.
  • governance-studio / explorer / gateway: no change required; their output and override behaviour are unaffected.

🤖 Generated with Claude Code

Shrinks99 and others added 2 commits July 28, 2026 17:51
…nces

The token build generated `@theme inline { --color-x: var(--color-x) }` for
every colour, which Tailwind emitted as `:root,:host { --color-x: var(--color-x) }`.
That declaration is cyclic and so invalid at computed-value time. Wherever a build
leaves Tailwind's theme output unlayered — e.g. Starlight, whose
`@astrojs/starlight-tailwind` ships its own top-level `@theme` and unlayers the
emitted block — the `:root` rule (0,1,0) outranks the real Light values we scope to
`html` (0,0,1), so every token resolved to nothing in Light mode. Dark was
unaffected because `html[data-equality-theme='dark']` (0,1,1) still won.

Register the colour tokens in a non-inline `@theme` carrying their real Light
values. Utilities still compile to `var(--color-*)` (so consumer overrides are
unchanged) and the emitted `:root,:host` block now holds a plain value instead of a
self-reference, so tokens resolve whether or not the block is layered. Dark keeps
overriding via its more specific selector. The genuinely-inline `--color-mixed-*`
entries stay in their own `@theme inline` block.

Verified in headless Chrome against the full theme-config (260 tokens) through both
a Starlight (Tailwind 4.3.3 + starlight-tailwind, previously broken) and a plain
Vite app (Tailwind 4.1.18) pipeline: Light and Dark both resolve, utilities resolve,
and `html`/wrapper `--color-*` overrides still win.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Shrinks99
Shrinks99 merged commit 23e0cc4 into main Jul 28, 2026
1 check passed
@Shrinks99
Shrinks99 deleted the fix/theme-config-cyclic-tokens branch July 28, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant