Skip to content

feat(useSchema): expose the ambient schema and contrast tier to JS - #1362

Merged
tenphi merged 5 commits into
mainfrom
andrew/cub-4118-expose-the-ambient-scheme-and-high-contrast-to-js-useschema
Aug 25, 2026
Merged

feat(useSchema): expose the ambient schema and contrast tier to JS#1362
tenphi merged 5 commits into
mainfrom
andrew/cub-4118-expose-the-ambient-scheme-and-high-contrast-to-js-useschema

Conversation

@tenphi

@tenphi tenphi commented Aug 25, 2026

Copy link
Copy Markdown
Member

Describe changes

Exposes the ambient viewing conditions — the color scheme and the contrast tier — to JS. Closes CUB-4118.

@dark / @hc already answer this in CSS, and for styling that remains the answer ({ '': light, '@dark': dark, '@hc': hc } repaints on a scheme flip with no re-render). Two cases a state map cannot serve:

  1. Surfaces the stylesheet does not reach — a Vega spec, a CodeMirror/Monaco theme, a third-party iframe. They take values, not CSS.
  2. Control state that is not styling — a control whose value is the ambient condition, e.g. the App Theme editor's Light/Dark and Normal/High-contrast preview selectors.

Consumers re-implement the expansion today: Cloud's useResolvedScheme() (packages/console-ui/src/styles/adaptive-color.ts) hand-builds it from usePreferences() + useSyncExternalStore, and AppThemeCustomizationPage.tsx adds a prefers-contrast: more listener plus a MutationObserver for the high-contrast half. Both are copies of a definition the kit owns. So was the kit's own theme builder story, which this PR deletes in favour of the new hooks.

API

import { useHighContrast, useSchema } from '@cube-dev/ui-kit';

const schema = useSchema();          // 'light' | 'dark' — the JS answer to `@dark`
const isHighContrast = useHighContrast(); // boolean     — the JS answer to `@hc`

Outside React: resolveSchema(), resolveHighContrast(), subscribeSchema(listener) (fires on a change to either condition). Type: ColorSchema.

Merged main in (18 commits). Two things came out of that: the conflicts were #1345's markdown unwrap meeting this rename — took main's unwrapped docs and re-applied it — and resolve.ts from #1351 had meanwhile grown its own appearance store over the same two attributes and two media queries. It now subscribes through subscribeSchema(), so there is one observer for the document rather than one per concern; its stricter guards (no matchMedia, no MutationObserver) moved into the owning module with it.

New module src/utils/react/useSchema.ts is the single owner of the definition — it builds the @dark / @hc strings that Root registers via setGlobalPredefinedStates() and reads the same two conditions from JS, so the CSS and JS answers cannot drift apart.

Implementation notes:

  • One shared MutationObserver (data-schema / data-contrast on <html>) plus the two media-query listeners, refcounted across all subscribers and torn down when the last one unmounts.
  • Snapshots are primitives, so useSyncExternalStore needs no memoization and never warns about an uncached snapshot. Under SSR the hooks render 'light' / false and re-render after hydration.
  • Faithful to the CSS on the edge the hand-rolled copies got wrong: the media fallback is gated on the attribute being absent (!@root(schema)), so <html data-schema="light"> stays light on a dark OS, and a present-but-unknown value reads as light rather than falling through to the preference.
  • Root-level only, and deliberately not a generic state reader — tasty's state vocabulary mixes element-local states (hovered, pressed) with ambient ones, and only the ambient half is answerable without an element. See the ticket for why the generic version was rejected.

Naming — schema, everywhere (breaking)

One word for one concept: the attribute is data-schema and the state is @root(schema=…), so the whole kit follows it now. Beyond the new hooks, the last commit renames three pre-existing public surfaces, with no aliases (per the repo's no-back-compat rule):

Before After
renderColorTokens({ scheme }), renderPaletteTokens({ scheme }), RenderPaletteOptions.scheme { schema }
<CubeLogo scheme="dark">, <CubeFullLogo scheme> schema
probe tokenOptions.scheme, CLI pnpm probe --scheme tokenOptions.schema, --schema

Internals, stories, tests and docs follow (SCHEMA_SWAP, withColorSchema, colorSchemaBridge, prose). Two deliberate exceptions: prefers-color-scheme keeps its spelling (that name belongs to the CSS media feature, not us), and SchemeIcon is untouched — it wraps a sitemap drawing and means the other thing.

Cloud call sites to update: renderColorTokens/renderPaletteTokens options, any <CubeLogo scheme>, and yarn probe --scheme. --schema hc still means light + high contrast.

Checklist
  • Pipeline is passed
  • Tests are added (including unit tests and stories in the storybook)
  • Tests are passed successfully
  • Changeset(s) is(are) added
  • You have passed the threshold of the library size
  • Commit message follows commit guidelines

Closes: CUB-4118

Other information

Verification

  • 7 new unit tests (src/utils/react/useSchema.test.tsx): media-query path, attribute-wins-over-preference, unknown-value-reads-as-light, live attribute flip, live preference change, and watcher re-attach after the last subscriber leaves. Full suite: 2100 passed / 1 skipped.
  • Live in Storybook (Getting Started/Theming → Theme builder): with no attribute and a dark-preferring OS the switches came up Dark / Normal; setting data-schema="light" + data-contrast="high" flipped them to Light / High contrast, no console errors — both resolution paths through the hooks in a real browser.
  • pnpm size: 512.19 kB / 515 kB and 121.81 kB / 125 kB.
  • After the merge and the rename sweep: full suite green (2192 passed / 1 skipped, including resolve.test.ts which exercises the rewired watcher), pnpm probe tokens --schema dark works, pnpm audit-docs --component=CubeLogo reports the same pre-existing findings as main (stroke, isChecked, scrollMargin) and nothing about the renamed prop, pnpm audit-defaults leaves the registry unchanged. pnpm chromatic:check fails identically on main (341 modules vs a 170 budget) — pre-existing, not from this branch.

Unrelated finding: pnpm size fails on a stale dist/ — a build predating the tasty 3.1.0 bump still imports TastyBatchProvider, which 3.1.0 no longer exports. pnpm build first and it passes; nothing to fix in source.

No new stories: the hooks have no visual surface of their own, and the theme builder story now exercises them.


Note

Medium Risk
Breaking renames across palette rendering, logos, and probe CLI require consumer updates; new ambient-condition hooks are additive but affect how @dark/@hc are defined at startup via Root.

Overview
Adds useSchema(), useHighContrast(), and non-React resolveSchema() / resolveHighContrast() / subscribeSchema() so JS can read the same ambient light/dark and high-contrast conditions as the @dark / @hc states (<html data-schema> / data-contrast first, then prefers-*). Root now registers AMBIENT_PREDEFINED_STATES from useSchema.ts instead of duplicating those strings, so CSS and JS stay aligned.

Breaking: renames schemeschema everywhere public with no aliases — renderColorTokens({ schema }), <CubeLogo schema>, probe --schema, Storybook withColorSchema / colorSchemaBridge, plus docs and internal naming. The theme builder drops its hand-rolled document watcher in favor of the new hooks.

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

`@dark` / `@hc` answer the ambient viewing conditions in CSS, but two
places cannot use a state map: surfaces that take values rather than CSS
(a Vega spec, a CodeMirror/Monaco theme, an iframe) and controls whose
value *is* the condition. Consumers re-implemented the expansion instead
— Cloud's `useResolvedScheme()` and the App Theme page's own observer,
and the kit's own theme builder story too.

`useScheme()` / `useHighContrast()` (plus `resolveScheme()`,
`resolveHighContrast()`, `subscribeScheme()` outside React) read the same
conditions from JS. The new module owns the `@dark` / `@hc` strings that
`Root` registers, so the CSS and JS answers cannot drift apart.

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

changeset-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5459f4c

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

@vercel

vercel Bot commented Aug 25, 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 4:38pm

Request Review

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-8c0f42f.

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 500.19 KB (+0.08% 🔺) Yes 🎉
Tree shaking (just a Button) 118.96 KB (0% 🟰) Yes 🎉

Compared against main at 09d0a20run 32867273009, 2026-08-25T15:40:37Z.

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

One word for one concept: the attribute is `data-schema` and the state is
`@root(schema=…)`, so the hooks follow it — `useSchema()`,
`resolveSchema()`, `subscribeSchema()`, `ColorSchema`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tenphi tenphi changed the title feat(useScheme): expose the ambient scheme and contrast tier to JS feat(useSchema): expose the ambient schema and contrast tier to JS Aug 25, 2026
tenphi and others added 2 commits August 25, 2026 18:21
BREAKING CHANGE: `renderColorTokens`/`renderPaletteTokens` take `schema`
instead of `scheme`, `<CubeLogo>`/`<CubeFullLogo>` take a `schema` prop,
and the probe uses `tokenOptions.schema` / `--schema`.

One word for one concept: the attribute is `data-schema` and the state is
`@root(schema=…)`, so the API, the internals, the stories and the docs
follow it. `prefers-color-scheme` is untouched — that name is the CSS
media feature's, not ours. `SchemeIcon` is untouched too: it wraps a
sitemap drawing and means the other thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
@tenphi
tenphi merged commit cc13939 into main Aug 25, 2026
16 checks passed
@tenphi
tenphi deleted the andrew/cub-4118-expose-the-ambient-scheme-and-high-contrast-to-js-useschema branch August 25, 2026 17:14
@tenphi tenphi mentioned this pull request Aug 25, 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.

1 participant