Skip to content

fix: emit the beta semantic token layer under the color-scheme scopes - #5239

Merged
pomfrida merged 8 commits into
mainfrom
fix/tokens-semantic-layer-color-scheme-scope
Jul 30, 2026
Merged

fix: emit the beta semantic token layer under the color-scheme scopes#5239
pomfrida merged 8 commits into
mainfrom
fix/tokens-semantic-layer-color-scheme-scope

Conversation

@pomfrida

@pomfrida pomfrida commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #5226

Problem

The Tokens Studio CSS export declared the semantic layer (--eds-background-*, --eds-text-*, …) in a :root block, while the scale aliases it references (--eds-accent-N, --eds-neutral-N, …) only exist under [data-color-scheme="light"|"dark"]. CSS custom properties substitute where they are declared, so the semantic tokens resolved once at the root and never re-resolved inside [data-color-scheme] subtrees — subtree colour-scheme switching (a dark panel inside a light app, per ADR-0004) was broken for the semantic layer, and Storybook needed an <html>-mirror workaround (#5224).

Fix

The semantic layer — the only layer that references the scheme-scoped scale aliases — is widened to :root, [data-color-scheme] by a new post-export step, scripts/widen-semantic-scope.mjs, chained before generate-css-bundle.mjs in the generate:css-bundle package script (so the release workflow needs no change). It is thereby also declared on any element carrying the attribute, and var() references substitute against the nearest scope. The script is idempotent and fails loudly if the export layout changes.

The saved EDS-CSS export configuration keeps the default rootSelector: ":root". An earlier revision of this PR set the export's global rootSelector instead, but that widened every non-dimensional layer and regressed the density cascade (the density base declares the same --eds-density-* names as the compact/relaxed variants, so re-declaring it on [data-color-scheme] elements clobbered a [data-density] ancestor's values — caught in review). The export format has no per-layer selector, hence the repo-side step.

  • Token content is byte-identical — the diff vs main is the semantic layer's selector line (source + bundle) only
  • generate:ts-tokens output is unchanged (the generator parses declarations, not selectors)
  • The <html>-mirror useEffect in the Storybook decorator is removed; the wrapper div's own data-color-scheme is now sufficient

Collision handling (names tracked in #5221): border-focus, text-disabled and border-disabled are declared in both the color-scheme layer and the semantic layer (including a --eds-border-focus: var(--eds-border-focus) self-reference). Both blocks apply to [data-color-scheme] elements at equal specificity, so the bundler concatenates the color-scheme files last — the scheme-specific values win and every resolved value matches the pre-widening state. This matters concretely: the incoming Chip (#5225) and Button (#5222) migrations use var(--eds-border-focus) for their focus rings, and letting the semantic self-reference win would have dropped focus outlines inside scoped subtrees. The duplicate names themselves remain a token-content bug to fix upstream (#5221).

Storybook wrapper canvas

The wrapper's background token — the legacy --eds-color-bg-neutral-surface — is only declared under :root, [data-color-scheme="light"] in the 2.x bundle (no dark value), so the story canvas stayed light in dark mode. Rather than patching the legacy pipeline (being replaced, not maintained), the wrapper now uses the beta semantic tokens --eds-background-container-canvas-default / --eds-text-primary.

Verification

Checked in a running Storybook from this branch (computed values via the browser, no <html> attribute present):

Context Value
--eds-background-container-canvas-default in light wrapper oklch(98% 0 0)
… inside nested <div data-color-scheme="dark"> oklch(19% .0014 242.96)
--eds-density-spacing-md at root (comfortable) 16px
… on a [data-density="compact"] container 12px
… on a data-color-scheme child inside that container 12px (ancestor density preserved — review finding 1)

Wrapper canvas follows the toolbar toggle: dark gives background: oklch(0.19 …) / color: oklch(0.91 …), light oklch(0.98 …) / oklch(0.37 …).

The Tokens Studio CSS export declared the semantic layer at :root while
the scale aliases it references only exist under [data-color-scheme]
scopes. Custom properties substitute where they are declared, so the
semantic tokens resolved once at the root and never re-resolved inside
[data-color-scheme] subtrees, breaking subtree colour-scheme switching
(ADR-0004/ADR-0013).

The saved EDS-CSS export configuration now sets
rootSelector: ':root, [data-color-scheme]' (was the ':root' default),
so every non-dimensional layer is also declared on any element carrying
the attribute and var() references substitute against the nearest
scope. Token content is unchanged - the diff is selector lines only.

The <html>-mirror workaround in the Storybook decorator is no longer
needed and is removed.

Closes #5226
@pomfrida

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown

Claude finished @pomfrida's task in 5m 27s —— View job


Review

  • Gather context (PR diff, generators, token layer files)
  • Review the token CSS selector change
  • Review the Storybook decorator change
  • Verify the PR's reproducibility / unchanged-output claims
  • Post review

The diagnosis in the PR body is correct — custom properties substitute where they are declared, so the semantic layer genuinely had to move inside the [data-color-scheme] scope, and keeping :root in the selector list preserves attribute-free usage. Dropping the <html> mirror from the Storybook decorator is a real improvement (it removed a cross-story global side effect), and useEffect is still needed on line 45 for the debug grid, so no dangling import.

I verified the mechanical claims locally and they hold:

  • node scripts/generate-css-bundle.mjs reproduces the committed variables.css byte-identically ✅
  • node scripts/generate-ts-tokens.mjs produces no diff — generate:ts-tokens output is unchanged as stated ✅
  • assert-no-light-dark.mjs still passes (one block per scheme, threshold 1) ✅

That said, widening the selector for every layer rather than just the semantic layer has two consequences I think need attention before this lands.


1. Density silently reverts to comfortable inside [data-color-scheme] subtrees

density/comfortable.css is now :root, [data-color-scheme], but density/compact.css and density/relaxed.css stay attribute-scoped — and all three declare the same 49 --eds-density-* names (verified: comfortable ∩ compact = 49, comfortable ∩ relaxed = 49).

Density is applied on an ancestor per AGENTS.md and ADR-0004, so:

<div>
  <div>
    
  </div>
</div>

The inner element matches [data-color-scheme], so it has a declared value for those tokens; inheritance only applies when the cascade yields nothing, so the ancestor's compact values are discarded for the whole subtree. Same-element (data-density="compact") still works, because variables.css:465 sorts after variables.css:414 at equal specificity — it's only the ancestor/descendant split that breaks. Pre-PR this was fine, since :root only ever matched <html>.

This also propagates into the semantic layer: semantic/default.css references --eds-density-corner-*, --eds-density-spacing-* and --eds-density-typography-*, so re-resolving semantic tokens at a [data-color-scheme] element now resolves them against comfortable density rather than the ancestor's density.

The semantic layer is the only layer that needs the wider selector — it's the one referencing scheme-scoped aliases. Primitives, colors, font and density all have concrete values or reference :root-level primitives, and re-declaring them buys nothing. If rootSelector can be set per export layer in the saved EDS-CSS configuration, scoping the widening to semantic fixes this and keeps the density cascade intact (semantic's var(--eds-density-*) lookups then pick up the inherited, correct density). Fix this →

2. Three semantic tokens change resolved value — including one that becomes invalid

--eds-border-focus, --eds-text-disabled and --eds-border-disabled are declared in both the color-scheme layer and the semantic layer. semantic/default.css sorts last in the bundle and now matches every [data-color-scheme] element at equal specificity (0,1,0), so it wins where the light/dark block previously did on descendants:

Token [data-color-scheme] descendant, before after
--eds-text-disabled var(--eds-neutral-7) var(--eds-neutral-5)
--eds-border-disabled var(--eds-neutral-7) var(--eds-neutral-2)
--eds-border-focus var(--eds-light-blue-7) / var(--eds-dark-blue-7) var(--eds-border-focus)

semantic/default.css:29 is --eds-border-focus: var(--eds-border-focus) — a self-reference. That's a cycle, so the property computes to the guaranteed-invalid value and any consumer's var(--eds-border-focus) falls back or becomes unset. It's already broken at :root today (the semantic block outranks the light/dark block on <html> for the same reason), so this is an upstream token-source bug, not something this PR introduced — but the PR extends its blast radius from "<html> only" to "every [data-color-scheme] subtree", which is where components will actually read it. Looks like a semantic token aliasing a same-named scheme token and collapsing to itself after prefix flattening; worth fixing in Tokens Studio.

Practically this makes "Token content is byte-identical — the diff is selector lines only" and "no contract change for consumers" true of the bytes but not of the resolved values. Nothing in the repo consumes these three yet (they're beta and unexported from package.json), so severity is low today — but it's the kind of thing that should be recorded rather than discovered during migration.

3. Minor points

  • preview.mjs:59 cites ADR-0013, which doesn't exist. documentation/adr/ stops at 0009-temporary-next-subpaths-for-eds-tokens-beta.md, and ADR-0013 appears nowhere else in the repo — the only hit is the comment added by this diff (the PR body cites it too). Either point at the ADR that actually covers subtree colour-scheme switching, or drop the reference. Fix this →
  • scripts/generate-css-bundle.mjs header is now inaccurate. It states "every mode file is :root- or attribute-scoped, so plain concatenation is conflict-free… source order does not affect resolution." With :root, [data-color-scheme] the widened blocks overlap the light/dark blocks on the same elements at equal specificity, so source order now decides the winner (that's exactly what produces finding 2). Worth updating the comment, and worth considering an assertion in the bundle script that no custom property is declared in both the color-scheme layer and a widened layer — it would have caught these three.
  • Consumer overrides on intermediate ancestors get reset. Any of the 459 tokens a consumer sets on a container is re-declared to the bundle default on nested [data-color-scheme] elements. Inherent to the approach and probably acceptable, but it's a documented-behaviour item since "set a token on a wrapper" is a common escape hatch.
  • The wrapper comment says "Both token bundles scope their tokens under [data-color-scheme]", which is a bit stronger than the PR body's own side note that legacy declares --eds-color-bg-neutral-surface only under :root, [data-color-scheme="light"]. Worth softening so the next reader doesn't rely on it.

Not verified

I could not check name collisions against the built legacy 2.x bundle — packages/eds-tokens/build/ isn't committed and dependencies aren't installed in this run. The mechanism is worth a look though: for any name legacy declares only under :root, [data-color-scheme="light"], the widened beta block now wins inside [data-color-scheme="dark"] subtrees, flipping the deliberate "legacy wins collisions" import order in preview.css. Scanning the committed sources I found no overlap between the beta root-block names and either src/css or Foundation/*.css, so this may well be moot.


Summary: the fix is the right one in principle, and the reproducibility claims check out. I'd narrow the widening to the semantic layer (finding 1 is a concrete regression), and either fix or explicitly record the --eds-border-focus self-reference before components start consuming the beta semantic layer.
• branch fix/tokens-semantic-layer-color-scheme-scope

pomfrida added 2 commits July 27, 2026 13:42
The wrapper used the legacy --eds-color-bg-neutral-surface, which has no
dark declaration in the 2.x bundle, so the story canvas stayed light in
dark mode. The beta semantic tokens re-resolve per [data-color-scheme]
subtree after the rootSelector change, so the canvas now follows the
toolbar toggle. Legacy is left untouched - it is being replaced, not
maintained.
Review follow-up on #5239. Widening every non-dimensional layer via the
export's global rootSelector regressed the density cascade: the density
base (comfortable) declares the same --eds-density-* names as the
compact/relaxed variants, so re-declaring it on every
[data-color-scheme] element clobbered a [data-density] ancestor's
values for the whole subtree.

The export format has no per-layer selector, so the saved EDS-CSS
configuration is reverted to the default rootSelector (:root) and the
semantic layer - the only layer that references the scheme-scoped scale
aliases - is widened repo-side by scripts/widen-semantic-scope.mjs,
chained before generate-css-bundle.mjs in the generate:css-bundle
package script so the release workflow needs no change. The script is
idempotent and fails loudly if the export layout changes.

Also drops a comment reference to ADR-0013 (not merged yet, PR #5200)
and documents the #5221 name-collision caveat in the bundle header.
@pomfrida

Copy link
Copy Markdown
Collaborator Author

Review findings addressed in cb4119b:

1. Density regression — fixed. The saved EDS-CSS export configuration is reverted to the default rootSelector: ":root" (updated on the platform), and the widening is now done repo-side for the semantic layer only: scripts/widen-semantic-scope.mjs, chained before the bundler in the generate:css-bundle package script, so the release workflow is untouched. The export format has no per-layer selector (checked exports schema --format css), hence the post-export step. Verified in Storybook: --eds-density-spacing-md on a data-color-scheme child inside a [data-density="compact"] container stays 12px (was clobbered to 16px with the global widening), while subtree colour-scheme switching for the semantic layer still works.

2. Token collisions — documented, deferred to #5221. The three duplicate names (border-focus self-reference, text-disabled, border-disabled) are upstream token-content bugs already tracked in #5221 (called out in #5226's problem statement as well). The bundle-script header now records that the widened semantic block wins those at equal specificity and why ordering can't fix it. The suggested no-duplicate assertion would fail today because the duplicates exist; worth adding once #5221 lands — noted there.

3. Minor points — fixed. The ADR-0013 reference is dropped from the decorator comment (the ADR is still unmerged in #5200), the wrapper comment now says the legacy bundle scopes its scheme-dependent tokens rather than all of them, and the bundle header's "source order does not affect resolution" claim is corrected. Consumer-override reset on scoped elements is now limited to the semantic layer, which is the layer where per-subtree re-declaration is the intended behaviour.

Not verified / legacy collisions: with the widening narrowed to the semantic layer, the overlap surface against legacy is the semantic names only — the committed-source scan finding no overlap should carry over, and the "legacy wins collisions" import order in preview.css is unaffected for every layer that stays :root-scoped.

The widen step is chained in the generate:css-bundle package script,
but running the bundler directly would silently bundle an unwidened
semantic layer and regress subtree colour-scheme switching (#5226).
The bundler now fails loudly instead.
@pomfrida

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @pomfrida's task in 6m 4s —— View job


Review

  • Gather context (fetch base, read the new script + diff)
  • Review widen-semantic-scope.mjs
  • Review the bundler assertion + header
  • Verify reproducibility and last round's findings
  • Post review

The narrowing to the semantic layer is the right call and it's correctly done. I re-verified last round's findings against cb4119b/374083d:

  • Finding 1 (density regression) is genuinely fixed. Only semantic/default.css carries the widened selector; colors, density/comfortable, font and primitives are back to plain :root. Semantic has zero name overlap with all three density files, so nothing clobbers a [data-density] ancestor — and the 49 var(--eds-density-*) references in the semantic layer now resolve at the scoped element against the inherited density, which is exactly the desired behaviour.
  • Overlap with the color-scheme layer is exactly the three documented names (eds-border-focus, eds-text-disabled, eds-border-disabled) — no others crept in. Deferring the content fix to Author import-ready Tokens Studio JSON for the missing beta colour tokens #5221 with the caveat recorded in the script headers is reasonable.
  • widen-semantic-scope.mjs is idempotent (already widened, no diff), generate-css-bundle.mjs reproduces the committed variables.css byte-identically, and assert-no-light-dark.mjs still passes ✅
  • The new guard fires before writeFile, so a run against an unwidened source exits 1 and leaves the committed bundle untouched — I reproduced it by narrowing the source ✅
  • generate-ts-tokens.mjs is selector-agnostic (parseCssVariables regexes --([\w-]+):\s*([^;]+);, line 78), so the unchanged-TS-output claim holds structurally. The release workflow also runs generate:ts-tokens before generate:css-bundle, so in CI it reads the pre-widened file anyway ✅
  • useEffect is still used for the debug grid (preview.mjs:44) — no dangling import. The wrapper's two tokens exist in the beta bundle and collide with nothing in legacy src/css or Foundation/*.css

One new issue, then smaller ones.


1. Prettier would rewrite the widened selector, and both scripts then hard-fail with a misleading error

widen-semantic-scope.mjs:44-52 matches with css.startsWith(':root {') and generate-css-bundle.mjs:65 asserts css.startsWith(':root, [data-color-scheme] {') — both exact-string, single-line.

Prettier breaks comma-separated CSS selector lists onto one line each. That's observable in this repo: preview.css:10-11 is fieldset, / legend {, and Foundation/elements.css needs /* prettier-ignore */ to keep .eds-elements :where(h1), .eds-elements .eds-heading-1 { inline. So Prettier's canonical form of the new selector is:

:root,
[data-color-scheme] {

packages/eds-tokens has "prettier:check": "prettier --check src/" (package.json:56), which covers both src/tokens/css/semantic/default.css and the bundled src/tokens/css/variables.css — neither is in .prettierignore (only packages/eds-tokens/build/* is). This PR makes those the first non-canonical files in the directory.

I reproduced the failure by writing the two-line form:

widen-semantic-scope: …/semantic/default.css does not start with ":root {" — the export layout changed, review #5226 before proceeding
generate-css-bundle: …/semantic/default.css does not start with ":root, [data-color-scheme] {" — run scripts/widen-semantic-scope.mjs before bundling

Both exit 1, and the message blames the export layout when in fact the file is already correctly widened. prettier:check isn't wired into CI (verified — checks.yaml runs build / test / lint:all / types, no prettier job), so this isn't red CI today. The realistic trigger is a dev opening the file in VS Code with editor.formatOnSave, or anyone running prettier --write src/: the next generate:css-bundle breaks the token pipeline pointing at the wrong cause.

Cheap fix: make both matches whitespace-tolerant regexes (/^:root\s*\{/ and /^:root,\s*\[data-color-scheme\]\s*\{/) and emit the prettier-canonical two-line form, so the file is stable under both tools. Fix this →

2. The guard covers only semantic/default.css, while the bundler globs the directory

The bundle script header says the directory is globbed "so future changes to the export set are followed automatically", but both the widening (widen-semantic-scope.mjs:39) and the assertion (generate-css-bundle.mjs:63) hard-code the single semantic/default.css path.

If the EDS-CSS export ever emits a second file under semantic/, it gets concatenated into the bundle with the narrow :root selector, the assertion on default.css still passes, and the exact bug this PR fixes ships silently. A rename fails loudly (good); an addition doesn't. Widening every semantic/*.css and asserting each closes it. Fix this →

3. Nothing guards the committed bundle — and eds-tokens' tests don't run in CI

The assertion protects the generate path (source, pre-bundle); the committed variables.css itself is unguarded. There's direct precedent for testing this class of bug: packages/eds-tokens/src/__tests__/density-aliases.test.ts exists purely to guard the legacy pipeline's identical "declared at :root, never re-resolved in the subtree" cascade bug (#5090), with a header explaining that type-level tests can't catch it. A few lines asserting the semantic block in variables.css is :root, [data-color-scheme] — and that the density blocks aren't — would be the natural analogue.

Worth knowing before you invest in that, though: packages/eds-tokens' vitest suite doesn't run in CI. Root package.json:51 test enumerates test:utils, test:core-react, test:lab, test:data-grid-react, test:color-palette-generator — no test:tokens — and checks.yaml runs pnpm run test. So density-aliases.test.ts and typography-shape.test.ts are dead in CI today. Pre-existing and out of scope here, but it's what turns "add a test" into an actual guard. Fix this →

4. Minor

  • Duplicated constant. WIDE in widen-semantic-scope.mjs:42 and WIDENED in generate-css-bundle.mjs:66 are linked only by a // Must match WIDE in that script comment. Exporting it from the widen script removes the drift risk — and folds into fix 1, since both become the same regex.
  • 113 custom properties re-declared per scoped element (49 of them resolving through --eds-density-*). Fine at current usage — I verified no /next component sets data-color-scheme, only stories do — but worth recording as the cost of the approach if per-row/per-cell scheme scoping ever shows up.
  • Verification table row reads oddly. "… inside nested <div>oklch(19% .0014 242.96)" under the light wrapper: a plain nested <div> should inherit the light value, since --eds-neutral-1 is inherited from the wrapper. Presumably that row is a nested data-color-scheme="dark" div — worth relabelling so the table doesn't read as evidence of the opposite behaviour.
  • parseArgs in the new script is copied from the bundler, so --help exits 1 with invalid arguments. Consistent with the existing script — noting it only so it doesn't look accidental.

Summary: the narrowed fix resolves last round's regression cleanly and the reproducibility claims all check out. Only finding 1 seems worth doing before merge (a one-line-each regex change that makes the pipeline survive a prettier --write); 2 and 3 are hardening, and the #5221 deferral is well documented.
• branch fix/tokens-semantic-layer-color-scheme-scope

Review follow-up on #5239, round two.

- The widened selector is matched with whitespace-tolerant regexes and
  emitted in Prettier's canonical one-selector-per-line form, shared
  between the widen script and the bundler via semantic-scope.mjs, so
  a prettier --write no longer breaks the pipeline with a misleading
  'export layout changed' error.
- Every semantic/*.css file is widened and asserted, mirroring the
  bundler's directory glob - a file the export adds later cannot slip
  through with the narrow :root selector.
- src/tokens/* is added to .prettierignore (root and package-local):
  the directory is machine output and was never Prettier-conformant
  (the export emits double-quoted attribute selectors and no trailing
  newline, failing prettier:check on main too), and hand-formatting it
  would be reverted by the next export run. The package prettier:check
  now passes.
@pomfrida

Copy link
Copy Markdown
Collaborator Author

Round-two findings addressed in bbdc0e4:

1. Prettier fragility — fixed, with one correction to the premise. The selector is now matched with whitespace-tolerant regexes and emitted in Prettier's canonical one-selector-per-line form, with the pattern shared between both scripts via scripts/semantic-scope.mjs (also resolves the duplicated-constant minor). However: the generated directory was never Prettier-conformant to begin with — the export emits double-quoted attribute selectors ([data-color-scheme="dark"]) and no trailing newline, so prettier --check src/ fails on the same 33 files on main too; this PR's files were not the first. The right fix for that class of problem is the same one build/* already has: src/tokens/* is now in .prettierignore (root and package-local), since the directory is machine output and any hand-formatting would be reverted by the next export run. The package prettier:check passes for the first time. The tolerant regexes stay as defence in depth.

2. Semantic glob gap — fixed. The widen script now widens every semantic/*.css it finds and the bundler asserts every one of them, mirroring its own directory glob. A rename still fails loudly; an addition can no longer ship narrow.

3. Bundle-level regression test + eds-tokens tests not running in CI — agreed, but deferred to its own issue. Wiring test:tokens into the root test script changes what the Checks workflow runs for every PR (and interacts with the build-before-test ordering that typography-shape.test.ts already depends on), which is beyond this PR's scope. Will open a dedicated issue covering the missing test:tokens wiring plus the variables.css scope-assertion test modelled on density-aliases.test.ts.

Verified after the changes: the chained generate:css-bundle is idempotent and reproduces the committed bundle, the bundler exits 1 on an unwidened semantic file before writing, generate:ts-tokens output is unchanged, and assert-no-light-dark still passes.

@pomfrida

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @pomfrida's task in 8m 13s —— View job


Review

  • Gather context (latest commit, new scripts, .prettierignore)
  • Review the shared semantic-scope.mjs + widen/assert regexes
  • Review the .prettierignore change and the corrected prettier premise
  • Verify reproducibility, idempotency and the glob fix
  • Post review

Round three looks good. Your correction to my premise is right, and I confirmed it rather than taking it on trust: even with the two-line selector, prettier --check still rejects semantic/default.css — the export's long var() declarations exceed the print width, so Prettier wants to wrap --eds-corner-radius-rounded-outer and ~35 typography lines. .prettierignore really is the right lever, not the selector form. Prettier's own output does keep your :root, / [data-color-scheme] { two lines untouched, so WIDE is canonical as claimed.

Verified locally (deps installed, all reruns left the tree clean):

Check Result
Chained generate:css-bundle idempotent, reproduces committed variables.css byte-identical ✅
Package prettier:check passes; with --ignore-path /dev/null the two CSS files fail — the ignore is what fixes it ✅
Whitespace tolerance: rewrote selector as :root, [data-color-scheme]\t{ already widened, bundle still produced ✅
Bundler guard on a narrowed source exits 1 before writeFile; variables.css md5 unchanged ✅
Widen script on the narrowed file restores byte-identically ✅
generate:ts-tokens on the widened tree no diff — order-independent, so the workflow's ts-before-bundle order is safe either way ✅
Name overlaps recomputed semantic ∩ each density file = 0; semantic ∩ color-scheme = exactly the 3 documented names; 113 semantic declarations ✅
eslint on the new scripts + preview.mjs clean ✅
Root ignore packages/eds-tokens/src/tokens/* excludes the subdirectories too (gitignore directory semantics) ✅
Callers of the bundler only tokens_studio_release.yaml via the package script — nothing bypasses the widen step ✅

One thing I checked because removing an <html> attribute is exactly where it bites: no /next component portals out of the wrapper. There's no createPortal anywhere under components/next/; Tooltip uses popover="hint", Autocomplete popover="auto", Dialog showModal() — top-layer promotion doesn't move nodes in the DOM, so they keep the wrapper as an ancestor and inherit the scheme. Dropping the mirror is safe. The wrapper's two tokens are declared only in the beta semantic layer (no legacy declaration of either name), and the wrapper only renders when isNext, so legacy stories are untouched.

Nothing blocking. Four small items, all optional.


1. The widen script is non-recursive while the bundler asserts recursively — a nested semantic file deadlocks the pipeline

widen-semantic-scope.mjs:46 uses a flat readdir(DIR), but generate-css-bundle.mjs:71 asserts every file whose first path segment is semantic, from a { recursive: true } glob. So the two globs don't actually match, which is the one gap the round-two fix aimed at.

Reproduced by adding semantic/second.css and semantic/extra/nested.css, both narrow:

widen-semantic-scope: widened src/tokens/css/semantic/second.css        # top level: handled
generate-css-bundle: src/tokens/css/semantic/extra/nested.css is not widened to
  ":root, [data-color-scheme]" — run scripts/widen-semantic-scope.mjs before bundling

The nested file is never widened, and the failure message prescribes a remedy that can't fix it — re-running the widen script is a no-op, so the release pipeline is stuck until someone edits the script. Failing loudly beats shipping narrow, so this is strictly better than before; it's just one word away from being fixable by the message it prints. readdir(DIR, { recursive: true }) (and joining the returned relative path) closes it. Fix this →

2. WIDE_RE accepts non-canonical whitespace but never normalises it

widen-semantic-scope.mjs:55 treats any WIDE_RE match as done, so the :root, [data-color-scheme]\t{ variant I tested survives into the committed bundle. In practice nothing will produce that now (the directory is Prettier-ignored, and the release pipeline always starts from a fresh narrow export), so this is cosmetic — but else if (WIDE_RE.test(css) && !css.startsWith(WIDE)) rewrite would make the committed bytes a function of content only, whatever shape the input arrived in.

3. The release workflow still describes the bundle step as pure concatenation

tokens_studio_release.yaml:88-95 — step name "Bundle CSS into variables.css", comment "Concatenate the generated CSS into one committed bundle (adds no behaviour on top of the generated files…)". That step now also rewrites a generated source file in place (src/tokens/css/semantic/default.css), which shows up in the auto-PR diff. The automated PR gets no CI and no reviewer context beyond its body, which likewise lists only the export outputs and the bundle. A clause in the step comment and the PR body would save the next person diffing a tokens-release PR from wondering why the export "changed" a selector. Fix this →

4. Minor

  • Two .prettierignore copies to keep in sync. The package-local file exists because Prettier resolves the ignore file from cwd — correct — but prettier --check src/ --ignore-path ../../.prettierignore in the package script would give the same result from one source. Also worth noting the root file's new block sits directly above a pre-existing duplicate packages/eds-icons/src/data.ts line, and the file still has no trailing newline (both pre-existing).
  • src/tokens/* also stops format-checking the generated TS modules. I appended a deliberately unformatted line to src/tokens/ts/semantic/light.ts and pnpm run lint stayed silent, so eslint's prettier/prettier rule honours the ignore file too — consistent, and they're generated + conformant today, so no CI trap either way. src/tokens/README.md was already covered by the root README.md pattern, so nothing is newly unchecked there.
  • The deferred follow-up isn't linked yet. Agreed that wiring test:tokens into the root test script (and a variables.css scope assertion modelled on density-aliases.test.ts) is out of scope here — worth dropping the issue number into the thread once it exists so the deferral is traceable from this PR.

Summary: the hardening does what it says — I could not break the pipeline with a prettier --write, a narrowed source, or an odd-whitespace selector, and the bundle is reproducible byte-for-byte. Finding 1 is the only one I'd consider fixing before merge, and even it fails safe. The .prettierignore reasoning and the #5221 deferral both hold up.
• branch fix/tokens-semantic-layer-color-scheme-scope

…elease workflow

Review follow-up on #5239, round three.

- The widen script now globs semantic/ recursively, matching the
  bundler's assertion glob - a nested file the export adds later gets
  widened instead of deadlocking the pipeline with a remedy message
  that is a no-op.
- A selector that matches the tolerant regex but not the canonical
  form is normalised, so the committed bytes are a function of content
  only.
- The release workflow's bundle step and auto-PR body now mention the
  semantic-layer widening, so the selector diff in tokens-release PRs
  is traceable to #5226 instead of looking like an export change.
@pomfrida

Copy link
Copy Markdown
Collaborator Author

Round-three items addressed in 5e41f85:

1. Recursive mismatch — fixed. The widen script now globs semantic/ recursively, matching the bundler's assertion glob. Verified with a nested semantic/extra/nested.css: previously the chain deadlocked with a no-op remedy message; now the file is widened and the chained generate:css-bundle succeeds.

2. Non-canonical whitespace — normalised. A selector matching the tolerant regex but not the canonical form is rewritten to WIDE, so the committed bytes are a function of content only (verified with the :root, [data-color-scheme]\t{ variant).

3. Workflow description — updated. The release workflow's bundle step is renamed/documented and the auto-PR body now notes that the semantic/*.css selector diff comes from widen-semantic-scope.mjs (#5226), so tokens-release PR reviewers aren't left wondering why the export "changed" a selector.

4. Deferred follow-ups — now traceable: #5246 (run the eds-tokens vitest suite in CI) and #5247 (regression test asserting the variables.css scope structure, modelled on density-aliases.test.ts). The two .prettierignore copies stay: the package-local file also covers ad-hoc prettier --write src/ runs from the package directory, which a --ignore-path flag on the package script alone would not.

…th the widened semantic block

The widened semantic layer and the color-scheme scope rules both apply
to [data-color-scheme] elements at equal specificity, so source order
decides the three duplicate names tracked in #5221. With the previous
alphabetical order the semantic block sorted last and won - and its
--eds-border-focus is a self-reference, computing to the
guaranteed-invalid value, which would have dropped focus outlines for
the incoming Chip (#5225) and Button (#5222) migrations inside scoped
subtrees (both use var(--eds-border-focus) for their focus ring).

The bundler now ranks color-scheme/* after every other layer, so the
scheme-specific values win on scoped elements and every resolved value
matches the pre-widening state. Verified in the browser:
--eds-border-focus resolves to the light/dark blue per subtree, and
text-disabled/border-disabled keep their scheme values.
@pomfrida

Copy link
Copy Markdown
Collaborator Author

One more hardening after a pre-merge worst-case pass (48353e6): the round-two review's finding 2 turned out to matter concretely — the incoming Chip (#5225) and Button (#5222) migrations both use var(--eds-border-focus) for their focus rings, and with alphabetical concatenation the widened semantic block's --eds-border-focus self-reference won on [data-color-scheme] elements, computing to the guaranteed-invalid value and dropping focus outlines inside scoped subtrees.

The bundler now concatenates color-scheme/* after every other layer, so the scheme-specific values win the three #5221 collisions and every resolved value matches the pre-widening state. Verified in the browser: --eds-border-focus resolves to the light/dark blue per subtree (per-subtree switching for it now works too), and text-disabled/border-disabled keep their scheme values. Cross-layer name overlaps were re-checked declared-name against declared-name: semantic ∩ {colors, font, primitives, density} = 0, so the reorder affects exactly the three tracked names.

@pomfrida
pomfrida merged commit 470a35e into main Jul 30, 2026
15 checks passed
@pomfrida
pomfrida deleted the fix/tokens-semantic-layer-color-scheme-scope branch July 30, 2026 11:53
@github-actions github-actions Bot mentioned this pull request Jul 30, 2026
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.

Emit the semantic token layer under the [data-color-scheme] scopes

1 participant