Skip to content

feat(tokens): resolve design tokens to values outside CSS - #1351

Merged
tenphi merged 4 commits into
mainfrom
feat/cub-4050-resolve-token-values
Aug 25, 2026
Merged

feat(tokens): resolve design tokens to values outside CSS#1351
tenphi merged 4 commits into
mainfrom
feat/cub-4050-resolve-token-values

Conversation

@tenphi

@tenphi tenphi commented Aug 24, 2026

Copy link
Copy Markdown
Member

Closes CUB-4050.

The gap

Some consumers render into a surface our stylesheets do not reach — Stripe Elements' own iframe, a CodeMirror / Monaco theme object, a Vega spec. Those take colors, lengths and font descriptors as values, so var(--purple-color) is useless to them. There was no supported way to ask the kit for a token's resolved value, so callers reached for getComputedStyle and carried their own guard (useStripeCardStyle in console-ui is the case that filed this).

Doing it by hand has two failure modes, and both fail silently:

  1. The wrong element. Root declares the token block on <body> (useGlobalStyles('body', …) in GlobalStyles.tsx), so <html>, a detached node, and a tree that has not mounted Root yet are all outside it.

  2. What comes back. Tasty auto-registers an @property rule for every custom property whose type it can infer, so an undeclared token does not read back empty — it reads back that rule's initial-value. Confirmed in Chromium against this branch:

    getComputedStyle(document.body).getPropertyValue('--purple-color')  → 'oklch(0.571 0.0971 280.3)'
    getComputedStyle(document.documentElement).getPropertyValue('--purple-color')  → 'rgba(0, 0, 0, 0)'
    getComputedStyle(document.documentElement).getPropertyValue('--t3-font-size')  → '0px'
    

    A placeholder is a plausible-looking value, so it propagates into an invisible chart series or a zero-height font rather than throwing.

What this adds

Six exports, in src/tokens/resolve.ts:

resolveTokenValue('#purple');    // 'oklch(0.571 0.0971 280.3)'
resolveTokenValue('$space-md');  // '8px'
resolveTokenValues(['#surface', '#surface-text', '#border']); // one getComputedStyle call, keyed by the tokens as written
resolvePresetValues('s3');       // { fontFamily: 'ui-monospace, …', fontSize: '14px', lineHeight: '20px', … }

plus useTokenValue() / useTokenValues() / usePresetValues() — the same readers as hooks, re-resolved when the palette is re-seeded (usePaletteVersion) or the scheme / contrast tier flips (prefers-color-scheme / prefers-contrast, and the data-schema / data-contrast attributes those fall back from, since neither source sees the other's changes). The object useTokenValues() returns keeps its identity while the values do, so it is usable as a dependency.

All six take { element, fallback }. element reads a local override instead of the document — a subtree with its own tokens prop, or one under a differing data-schema — and resolves against that element's own document, so a node in a same-origin iframe works. fallback is what comes back instead of null, including under SSR where there is no DOM.

Token names follow tasty's own DSL: #name is a color (--name-color), $name is everything else, and a raw --name is taken as written.

The placeholder guard

A value matching one of tasty's registered initials (rgba(0, 0, 0, 0), 0px, 0deg, 0s, 0) is treated as "not declared here": the helper returns null (or fallback) and warns once in development, naming the property and the likely cause.

Some tokens are legitimately placeholder-shaped, and discarding those would be its own bug. #clear really is transparent; $sharp-radius really is 0px; $t3-letter-spacing really is 0. So the guard cross-checks the declared value from getTokens() and lets those through. That is the only reason getTokens() moved from tokens/index.ts into tokens/all-tokens.tsresolve.ts needs it, and index.ts re-exports resolve.ts, so importing it back from the barrel would have been a cycle. The re-export means nothing that imports getTokens / TOKENS had to change.

resolvePresetValues() deliberately never warns: a preset leaving fontStyle or iconSize out is ordinary, not a miss. It also falls back to the document's --font-sans for the presets that inherit the sans stack rather than naming it (everything but the s* family).

Tests

  • resolve.test.tsx (jsdom, 15 tests) covers the logic. jsdom implements neither @property nor its initials, so the fixture declares the placeholders on <html> by hand — the same shape a browser produces.
  • resolve.browser.test.tsx (4 tests) covers the assumption underneath it, which only a real engine can answer: that tasty still registers those rules, with those initials. That set lives in another package, and nothing else in this repo would notice it drifting.

pnpm test 2122 passed, pnpm test:browser 131 passed, pnpm lint clean, pnpm build clean. tsc --noEmit reports the same 18 pre-existing errors as main — none in the new files.

Docs: a Resolving Tokens Outside CSS section in Usage.docs.mdx. Changeset: minor.

🤖 Generated with Claude Code


Note

Low Risk
Additive API and a non-styling marker token; behavior is isolated to new exports with broad test coverage, though incorrect $tokens-applied wiring would cause widespread null/fallback reads for integrators.

Overview
Adds a minor public API for reading design tokens as literal values (colors, lengths, typography) when consumers cannot use var(--…) — e.g. Stripe Elements, editor themes, chart specs.

resolveTokenValue / resolveTokenValues / resolvePresetValues plus matching useTokenValue / useTokenValues / usePresetValues hooks read from the DOM (default <body> where Root applies tokens), accept { element, fallback }, and return null or fallback when tokens are not in effect. Trust is gated by a new $tokens-applied marker in the token block (not value heuristics), so plausible tasty @property defaults off-surface are rejected while real values like transparent or 0px still resolve. Hooks re-run on palette re-seed, dark/high-contrast changes (matchMedia + data-schema / data-contrast), and use SSR-safe hydration via useSyncExternalStore + a layout effect.

getTokens / TOKENS move to all-tokens.ts so resolve.ts can import them without a barrel cycle; exports from @cube-dev/ui-kit are unchanged. Docs gain a Resolving Tokens Outside CSS section; jsdom and browser tests cover the resolver and the $tokens-applied assumption.

Reviewed by Cursor Bugbot for commit 6180693. Bugbot is set up for automated code reviews on this repo. Configure here.

Adds `resolveTokenValue()`, `resolveTokenValues()` and `resolvePresetValues()`
with matching `useTokenValue()` / `useTokenValues()` / `usePresetValues()` hooks,
for consumers rendering into a surface our stylesheets do not reach.

They read from `<body>`, where `<Root>` declares the token block, and refuse the
`@property` initial values an undeclared token reads back as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cube-ui-kit Ready Ready Preview Aug 25, 2026 9:10am

Request Review

@changeset-bot

changeset-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6180693

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cube-dev/ui-kit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-069b6f8.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 498.88 KB (+0.31% 🔺) Yes 🎉
Tree shaking (just a Button) 118.91 KB (0% 🟰) Yes 🎉

Compared against main at ee97f36run 32760719990, 2026-08-24T18:08:47Z.

To see which modules changed, download the size-limit-statoscope-report artifact from this run and open report.html.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

Comment thread src/tokens/resolve.ts
Comment thread src/tokens/resolve.ts
Review found `resolveTokenValue` dropping real values: `#scrollbar-outline`
reaches `transparent` through `#clear`, and `$h2-letter-spacing` is declared in
`em`, so neither matched the literal allowlist and both came back `null`.

The allowlist was the wrong instrument. Tasty registers `@property` defaults
with real initial values — off the token block `--gap` reads `4px`, not `0px` —
so a value cannot say whether it is the kit's, and `--gap` was being returned as
if it were.

Adds `$tokens-applied`, declared alongside the tokens, and reads that instead.
Also matches the server's markup while hydrating, so a consumer rendering a
resolved value into SSR'd output no longer trips a mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tenphi
tenphi merged commit 2caf107 into main Aug 25, 2026
16 checks passed
@tenphi
tenphi deleted the feat/cub-4050-resolve-token-values branch August 25, 2026 09:14

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6180693. Configure here.

Comment thread src/tokens/resolve.ts
appearance === HYDRATING
? unresolved(tokens, fallback)
: resolveTokenValues(tokens, options),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hooks warn on first client render

Medium Severity

On CSR, useResolvedTokens and usePresetValues call resolveTokenValues in the useState initializer. That read happens before the token block is committed — the same-pass-as-Root case the layout effect exists to cover — so isTokenSurface fails and readValue fires the off-surface warning. warnOnce then swallows a later real miss of the same token. Hydration avoids this by using unresolved(), but a client createRoot tree does not.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6180693. Configure here.

Comment thread src/tokens/resolve.ts
token: string,
options: ResolveTokenOptions = {},
): string | null {
return useResolvedTokens([token], token, options)[token];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Token change returns undefined value

Medium Severity

useTokenValue indexes the previous render's record by the new token, so swapping the argument yields undefined instead of string | null. useTokenValues and usePresetValues likewise keep the old record until useLayoutEffect runs. State is only created in the useState initializer and patched in a layout effect, with no render-time reset when key / preset changes. Child layout effects therefore observe the stale result, which is the typical place Stripe, CodeMirror, and Monaco themes are applied.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6180693. Configure here.

@tenphi tenphi mentioned this pull request Aug 25, 2026
tenphi added a commit that referenced this pull request Aug 25, 2026
Conflicts were the markdown unwrap (#1345) meeting the `scheme` → `schema`
rename: took main's unwrapped docs and re-applied the rename, then
re-added the two new doc sections unwrapped.

Also dedupes the watcher: `resolve.ts` (#1351) had grown its own
appearance store over the same two attributes and two media queries, so
it now subscribes through `subscribeSchema()`, which owns the definition.
Its stricter guards (no `matchMedia`, no `MutationObserver`) moved into
that module with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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