feat(tokens): resolve design tokens to values outside CSS - #1351
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 6180693 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
📦 NPM canary releaseDeployed canary version 0.0.0-canary-069b6f8. |
🏋️ Size limit report
Compared against main at ee97f36 — run 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. |
🧪 Storybook is successfully deployed!
|
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ 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.
| appearance === HYDRATING | ||
| ? unresolved(tokens, fallback) | ||
| : resolveTokenValues(tokens, options), | ||
| ); |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 6180693. Configure here.
| token: string, | ||
| options: ResolveTokenOptions = {}, | ||
| ): string | null { | ||
| return useResolvedTokens([token], token, options)[token]; |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 6180693. Configure here.
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>


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 forgetComputedStyleand carried their own guard (useStripeCardStyleinconsole-uiis the case that filed this).Doing it by hand has two failure modes, and both fail silently:
The wrong element.
Rootdeclares the token block on<body>(useGlobalStyles('body', …)inGlobalStyles.tsx), so<html>, a detached node, and a tree that has not mountedRootyet are all outside it.What comes back. Tasty auto-registers an
@propertyrule for every custom property whose type it can infer, so an undeclared token does not read back empty — it reads back that rule'sinitial-value. Confirmed in Chromium against this branch: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: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 thedata-schema/data-contrastattributes those fall back from, since neither source sees the other's changes). The objectuseTokenValues()returns keeps its identity while the values do, so it is usable as a dependency.All six take
{ element, fallback }.elementreads a local override instead of the document — a subtree with its owntokensprop, or one under a differingdata-schema— and resolves against that element's own document, so a node in a same-origin iframe works.fallbackis what comes back instead ofnull, including under SSR where there is no DOM.Token names follow tasty's own DSL:
#nameis a color (--name-color),$nameis everything else, and a raw--nameis 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 returnsnull(orfallback) 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.
#clearreally istransparent;$sharp-radiusreally is0px;$t3-letter-spacingreally is0. So the guard cross-checks the declared value fromgetTokens()and lets those through. That is the only reasongetTokens()moved fromtokens/index.tsintotokens/all-tokens.ts—resolve.tsneeds it, andindex.tsre-exportsresolve.ts, so importing it back from the barrel would have been a cycle. The re-export means nothing that importsgetTokens/TOKENShad to change.resolvePresetValues()deliberately never warns: a preset leavingfontStyleoriconSizeout is ordinary, not a miss. It also falls back to the document's--font-sansfor the presets that inherit the sans stack rather than naming it (everything but thes*family).Tests
resolve.test.tsx(jsdom, 15 tests) covers the logic. jsdom implements neither@propertynor 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 test2122 passed,pnpm test:browser131 passed,pnpm lintclean,pnpm buildclean.tsc --noEmitreports the same 18 pre-existing errors asmain— 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-appliedwiring would cause widespreadnull/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/resolvePresetValuesplus matchinguseTokenValue/useTokenValues/usePresetValueshooks read from the DOM (default<body>whereRootapplies tokens), accept{ element, fallback }, and returnnullorfallbackwhen tokens are not in effect. Trust is gated by a new$tokens-appliedmarker in the token block (not value heuristics), so plausible tasty@propertydefaults off-surface are rejected while real values liketransparentor0pxstill resolve. Hooks re-run on palette re-seed, dark/high-contrast changes (matchMedia+data-schema/data-contrast), and use SSR-safe hydration viauseSyncExternalStore+ a layout effect.getTokens/TOKENSmove toall-tokens.tssoresolve.tscan import them without a barrel cycle; exports from@cube-dev/ui-kitare unchanged. Docs gain a Resolving Tokens Outside CSS section; jsdom and browser tests cover the resolver and the$tokens-appliedassumption.Reviewed by Cursor Bugbot for commit 6180693. Bugbot is set up for automated code reviews on this repo. Configure here.