From 31b4a0ec05014ddaa72f945f202edf3bbab0e631 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Tue, 25 Aug 2026 18:04:09 +0200 Subject: [PATCH 1/4] feat(useScheme): expose the ambient scheme and contrast tier to JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@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 --- .changeset/lucky-hooks-follow-scheme.md | 5 + src/components/Root.tsx | 12 +- src/stories/Theming.docs.mdx | 32 ++++ src/stories/Theming.stories.tsx | 113 +++--------- src/stories/Usage.docs.mdx | 15 ++ src/utils/react/index.ts | 8 + src/utils/react/useScheme.test.tsx | 173 +++++++++++++++++ src/utils/react/useScheme.ts | 235 ++++++++++++++++++++++++ 8 files changed, 497 insertions(+), 96 deletions(-) create mode 100644 .changeset/lucky-hooks-follow-scheme.md create mode 100644 src/utils/react/useScheme.test.tsx create mode 100644 src/utils/react/useScheme.ts diff --git a/.changeset/lucky-hooks-follow-scheme.md b/.changeset/lucky-hooks-follow-scheme.md new file mode 100644 index 000000000..21031fdcb --- /dev/null +++ b/.changeset/lucky-hooks-follow-scheme.md @@ -0,0 +1,5 @@ +--- +'@cube-dev/ui-kit': minor +--- + +Added `useScheme()` and `useHighContrast()` — the JS answer to the `@dark` and `@hc` states, for the two places a state map cannot reach: surfaces that take values rather than CSS (a Vega spec, a CodeMirror/Monaco theme, an iframe) and controls whose value *is* the ambient condition. Both follow the `` / `` opt-in first and `prefers-color-scheme` / `prefers-contrast` second, exactly as the states do, and re-render on a change. `resolveScheme()`, `resolveHighContrast()` and `subscribeScheme()` cover the same ground outside React. Styling still belongs in a state map. diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 04db1d9fc..c36984827 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -16,6 +16,7 @@ import { NavigationAdapter } from '../providers/navigation.types'; import { TrackingProps, TrackingProvider } from '../providers/TrackingProvider'; import { PaletteConfig, setPaletteConfig } from '../tokens/palette-config'; import { EventBusProvider } from '../utils/react/useEventBus'; +import { AMBIENT_PREDEFINED_STATES } from '../utils/react/useScheme'; import { extractStyles } from '../utils/styles'; import { TASTY_VERSION, VERSION } from '../version'; @@ -31,12 +32,11 @@ import type { i18n as I18nInstance } from 'i18next'; // → forces dark scheme // → forces high-contrast scheme // Otherwise falls back to the user's `prefers-color-scheme` / `prefers-contrast`. -setGlobalPredefinedStates({ - '@dark': - '@root(schema=dark) | (!@root(schema) & @media(prefers-color-scheme: dark))', - '@hc': - '@root(contrast=high) | (!@root(contrast) & @media(prefers-contrast: more))', -}); +// +// The strings live in `useScheme.ts`, which also reads the same two conditions +// from JS (`useScheme()` / `useHighContrast()`) — one definition, so the CSS and +// the JS answers cannot drift apart. +setGlobalPredefinedStates(AMBIENT_PREDEFINED_STATES); configure({ colorSpace: 'rgb', diff --git a/src/stories/Theming.docs.mdx b/src/stories/Theming.docs.mdx index 15fa9029c..baf20f87d 100644 --- a/src/stories/Theming.docs.mdx +++ b/src/stories/Theming.docs.mdx @@ -701,6 +701,38 @@ subscribePaletteConfig(() => { `getPaletteConfig().themes.danger.saturation` is a number even if nobody ever set it. `DEFAULT_PALETTE_CONFIG` is the shipped baseline in the same shape. +## Reading the ambient scheme + +The palette config is what the app *asked for*; the scheme and contrast tier are +what the viewer is *seeing* — `` / `prefers-color-scheme`, and +`` / `prefers-contrast`. They are not part of the config, +so `getPaletteConfig()` says nothing about them: + +```tsx +import { useHighContrast, useScheme } from '@cube-dev/ui-kit'; + +const scheme = useScheme(); // 'light' | 'dark' +const isHighContrast = useHighContrast(); // boolean +``` + +Both resolve exactly as the `@dark` / `@hc` states do — attribute opt-in first, +media query only when the attribute is absent — and re-render when either input +changes. Outside React: `resolveScheme()`, `resolveHighContrast()` and +`subscribeScheme(listener)`, which fires on a change to either condition. + +Reach for these only where CSS cannot go. Styling belongs in a state map +(`{ '': …, '@dark': …, '@hc': … }`), which repaints on a scheme flip with no +re-render at all. What the states cannot reach is a surface that takes values +rather than CSS — a Vega spec, a CodeMirror or Monaco theme, an iframe — or a +control whose value *is* the condition, like the Light/Dark and +Normal/High-contrast selectors over the [theme builder](#theme-builder). + +Note the distinction from `contrastLevel`: the level is a config seed the app +supplies and can read back, while high contrast is a viewing condition the +document is in. They also pair up — `renderColorTokens({ scheme, highContrast })` +takes both, so a region can be previewed in the conditions the page is actually +showing. + ## `` `` accepts the same config as a prop, applied during render so the first diff --git a/src/stories/Theming.stories.tsx b/src/stories/Theming.stories.tsx index 41719e3cd..cbc1b0864 100644 --- a/src/stories/Theming.stories.tsx +++ b/src/stories/Theming.stories.tsx @@ -31,14 +31,17 @@ import { Switch, Tag, tasty, + useHighContrast, usePaletteConfig, usePaletteVersion, + useScheme, } from '../index'; import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Styles, Tokens } from '@tenphi/tasty'; import type { ReactNode } from 'react'; import type { + ColorScheme, PaletteConfig, PaletteNumericSeed, PaletteSeed, @@ -1189,87 +1192,6 @@ function CodePanel() { // Theme builder // ============================================================================ -/** - * A preview renders one concrete variant, so there is no `auto` here: `auto` is a - * *preference* ("follow the OS"), and a flat token value cannot express one. - */ -type SchemeChoice = 'light' | 'dark'; - -/** - * Scheme and contrast tier are **viewing conditions**, not theme settings — the - * same theme renders in all four of them, and a designer moves between them to - * check their work rather than to change it. They live over the preview for - * that reason, and nothing they do reaches the palette config. - */ -interface ViewingConditions { - scheme: SchemeChoice; - highContrast: boolean; -} - -/** What the document is showing right now, per the states `Root` registers. */ -function readViewingConditions(): ViewingConditions { - if (typeof document === 'undefined') { - return { scheme: 'light', highContrast: false }; - } - - const schema = document.documentElement.getAttribute('data-schema'); - const contrast = document.documentElement.getAttribute('data-contrast'); - - return { - scheme: - schema === 'dark' || schema === 'light' - ? schema - : matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light', - highContrast: contrast - ? contrast === 'high' - : matchMedia('(prefers-contrast: more)').matches, - }; -} - -/** - * The document's own scheme and contrast tier, kept live. - * - * This is what makes the two switches over the preview *follow* without an - * `Auto` option to explain: they start on whatever the page is already showing, - * and until someone presses one they keep tracking it. Flipping Storybook's - * dark-mode toolbar with a light preview stranded inside a dark page is exactly - * the state that reads as broken. - * - * Both inputs have to be watched, because both can decide the answer: the - * attribute when `Root`'s opt-in is set, the media query otherwise. - */ -function useDocumentConditions(): ViewingConditions { - const [conditions, setConditions] = useState(readViewingConditions); - - useEffect(() => { - const sync = () => setConditions(readViewingConditions()); - const observer = new MutationObserver(sync); - const queries = [ - matchMedia('(prefers-color-scheme: dark)'), - matchMedia('(prefers-contrast: more)'), - ]; - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-schema', 'data-contrast'], - }); - queries.forEach((query) => query.addEventListener('change', sync)); - - // The attribute can land before the effect runs, so re-read on mount rather - // than trusting the value the first render happened to see. - sync(); - - return () => { - observer.disconnect(); - queries.forEach((query) => query.removeEventListener('change', sync)); - }; - }, []); - - return conditions; -} - // ---------------------------------------------------------------------------- // Controls // ---------------------------------------------------------------------------- @@ -2065,7 +1987,7 @@ function ThemePreview({ scheme, }: { tokens: Tokens; - scheme: SchemeChoice; + scheme: ColorScheme; }) { const [tab, setTab] = useState(PREVIEW_TABS[0]); @@ -2201,11 +2123,18 @@ function ThemePreview({ } function ThemeBuilderPage() { - const documentConditions = useDocumentConditions(); + // Scheme and contrast tier are viewing conditions, not theme settings — the + // same theme renders in all four of them. The switches start on whatever the + // page is already showing and keep tracking it until someone presses one, so + // a light preview is never stranded inside a dark page. + const documentScheme = useScheme(); + const documentHighContrast = useHighContrast(); // `null` while the switch is still following the document. Deriving the shown // value from "override ?? document" is what lets a two-option control follow - // without an `Auto` option standing for the third state. - const [schemeOverride, setSchemeOverride] = useState( + // without an `Auto` option standing for the third state — and `auto` could not + // be previewed anyway: it is a *preference*, and a flat token value renders one + // concrete variant. + const [schemeOverride, setSchemeOverride] = useState( null, ); const [contrastOverride, setContrastOverride] = useState( @@ -2213,8 +2142,8 @@ function ThemeBuilderPage() { ); const version = usePaletteVersion(); - const scheme = schemeOverride ?? documentConditions.scheme; - const isHighContrast = contrastOverride ?? documentConditions.highContrast; + const scheme = schemeOverride ?? documentScheme; + const isHighContrast = contrastOverride ?? documentHighContrast; // The level lives in the palette config now, so it needs no mention here: it // is part of the theme being built, and both of these pick it up. @@ -2227,8 +2156,12 @@ function ThemeBuilderPage() { // calls land on the same memo inside `renderPaletteTokens` — it holds all four // variants of one config — so the second one costs nothing. const documentTokens = useMemo( - () => renderColorTokens(documentConditions), - [documentConditions, version], + () => + renderColorTokens({ + scheme: documentScheme, + highContrast: documentHighContrast, + }), + [documentScheme, documentHighContrast, version], ); return ( @@ -2252,7 +2185,7 @@ function ThemeBuilderPage() { aria-label="Color scheme" type="button" value={scheme} - onChange={(value) => setSchemeOverride(value as SchemeChoice)} + onChange={(value) => setSchemeOverride(value as ColorScheme)} > Light Dark diff --git a/src/stories/Usage.docs.mdx b/src/stories/Usage.docs.mdx index ee8904a01..a43d4c418 100644 --- a/src/stories/Usage.docs.mdx +++ b/src/stories/Usage.docs.mdx @@ -117,6 +117,21 @@ The `@dark` and `@hc` predefined states are wired up in `src/components/Root.tsx When the attribute is absent, the scheme falls back to `@media (prefers-color-scheme: dark)` and `@media (prefers-contrast: more)`. +#### Reading the scheme from JS + +For styling, use the state map — `{ '': light, '@dark': dark, '@hc': hc }`. Two cases it cannot serve: a surface the stylesheet never reaches (a Vega spec, a CodeMirror / Monaco theme, an iframe), and a control whose value *is* the ambient condition (a Light/Dark preview selector). For those, read the same two conditions from JS: + +```tsx +import { useHighContrast, useScheme } from '@cube-dev/ui-kit'; + +const scheme = useScheme(); // 'light' | 'dark' — the JS answer to `@dark` +const isHighContrast = useHighContrast(); // boolean — the JS answer to `@hc` +``` + +Both follow the attribute opt-in first and the media query second, exactly as the states do, and re-render when either changes. Outside React: `resolveScheme()`, `resolveHighContrast()`, and `subscribeScheme(listener)` (fires on either condition; re-read in the listener). + +Root-level only — there is no element-scoped or generic state reader, on purpose. Tasty's state vocabulary mixes element-local states (`hovered`, `pressed`, `disabled`) with ambient ones (`@media(…)`, `@root(…)`), and only the ambient half is answerable without an element. + ### Surface & Text Vocabulary The neutral palette uses a `surface` / `surface-text` model. Each surface depth has its own AAA text color, plus a soft (AA) variant for secondary copy: diff --git a/src/utils/react/index.ts b/src/utils/react/index.ts index 41c289b33..62a732041 100644 --- a/src/utils/react/index.ts +++ b/src/utils/react/index.ts @@ -16,6 +16,14 @@ export { useLayoutEffect } from './useLayoutEffect'; export { useCombinedRefs, mergeRefs } from './useCombinedRefs'; export { wrapNodeIfPlain } from './wrapNodeIfPlain'; export { useViewportSize } from './useViewportSize'; +export { + useScheme, + useHighContrast, + resolveScheme, + resolveHighContrast, + subscribeScheme, +} from './useScheme'; +export type { ColorScheme } from './useScheme'; export { useQaProps } from './useQaProps'; export { useEventBus, useEventListener, EventBusProvider } from './useEventBus'; export type { EventBusListener, EventBusContextValue } from './useEventBus'; diff --git a/src/utils/react/useScheme.test.tsx b/src/utils/react/useScheme.test.tsx new file mode 100644 index 000000000..e69875d74 --- /dev/null +++ b/src/utils/react/useScheme.test.tsx @@ -0,0 +1,173 @@ +import { act, waitFor } from '@testing-library/react'; + +import { renderHook } from '../../test'; + +import { + resolveHighContrast, + resolveScheme, + useHighContrast, + useScheme, +} from './useScheme'; + +const DARK = '(prefers-color-scheme: dark)'; +const MORE_CONTRAST = '(prefers-contrast: more)'; + +/** + * A `matchMedia` stub whose answers can be flipped mid-test, dispatching the + * `change` event the hook subscribes to. jsdom's own implementation always + * answers `false` and never changes, so the media half is untestable without it. + */ +function stubMatchMedia(initial: Record = {}) { + const matches = { ...initial }; + const listeners = new Map void>>(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => + ({ + media: query, + get matches() { + return Boolean(matches[query]); + }, + addEventListener: (_: string, listener: () => void) => { + const set = listeners.get(query) ?? new Set(); + + set.add(listener); + listeners.set(query, set); + }, + removeEventListener: (_: string, listener: () => void) => { + listeners.get(query)?.delete(listener); + }, + }) as unknown as MediaQueryList, + }); + + return function set(query: string, value: boolean) { + matches[query] = value; + listeners.get(query)?.forEach((listener) => listener()); + }; +} + +describe('useScheme / useHighContrast', () => { + const originalMatchMedia = window.matchMedia; + + afterEach(() => { + // The jsdom project shares one environment across a worker, so neither the + // stub nor the attributes may leak into the rest of the suite. + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: originalMatchMedia, + }); + document.documentElement.removeAttribute('data-schema'); + document.documentElement.removeAttribute('data-contrast'); + }); + + describe('resolveScheme', () => { + it('falls back to the media query when no attribute is set', () => { + const set = stubMatchMedia({ [DARK]: true }); + + expect(resolveScheme()).toBe('dark'); + + set(DARK, false); + + expect(resolveScheme()).toBe('light'); + }); + + it('lets the attribute opt-in win over the system preference', () => { + stubMatchMedia({ [DARK]: true }); + + document.documentElement.setAttribute('data-schema', 'light'); + + expect(resolveScheme()).toBe('light'); + + document.documentElement.setAttribute('data-schema', 'dark'); + + expect(resolveScheme()).toBe('dark'); + }); + + it('reads any other attribute value as light, exactly as `@dark` does', () => { + stubMatchMedia({ [DARK]: true }); + + // `@dark` gates the media fallback on the attribute being *absent* + // (`!@root(schema)`), so a present-but-unknown value stays light rather + // than falling through to the preference. + document.documentElement.setAttribute('data-schema', 'sepia'); + + expect(resolveScheme()).toBe('light'); + }); + }); + + describe('resolveHighContrast', () => { + it('follows the media query, then the attribute', () => { + const set = stubMatchMedia({ [MORE_CONTRAST]: true }); + + expect(resolveHighContrast()).toBe(true); + + set(MORE_CONTRAST, false); + + expect(resolveHighContrast()).toBe(false); + + document.documentElement.setAttribute('data-contrast', 'high'); + + expect(resolveHighContrast()).toBe(true); + + document.documentElement.setAttribute('data-contrast', 'normal'); + + expect(resolveHighContrast()).toBe(false); + }); + }); + + it('re-renders when the attribute flips', async () => { + stubMatchMedia(); + + const { result } = renderHook(() => useScheme()); + + expect(result.current).toBe('light'); + + await act(async () => { + document.documentElement.setAttribute('data-schema', 'dark'); + }); + + await waitFor(() => expect(result.current).toBe('dark')); + + await act(async () => { + document.documentElement.removeAttribute('data-schema'); + }); + + await waitFor(() => expect(result.current).toBe('light')); + }); + + it('re-renders when the system preference changes', async () => { + const set = stubMatchMedia({ [DARK]: false, [MORE_CONTRAST]: false }); + + const scheme = renderHook(() => useScheme()); + const contrast = renderHook(() => useHighContrast()); + + expect(scheme.result.current).toBe('light'); + expect(contrast.result.current).toBe(false); + + await act(async () => set(DARK, true)); + + expect(scheme.result.current).toBe('dark'); + expect(contrast.result.current).toBe(false); + + await act(async () => set(MORE_CONTRAST, true)); + + expect(contrast.result.current).toBe(true); + }); + + it('re-attaches its watchers after the last subscriber leaves', async () => { + stubMatchMedia(); + + // The observer and the query listeners are shared and torn down when the + // listener set empties, so a remount has to build them again. + renderHook(() => useScheme()).unmount(); + + const { result } = renderHook(() => useScheme()); + + await act(async () => { + document.documentElement.setAttribute('data-schema', 'dark'); + }); + + await waitFor(() => expect(result.current).toBe('dark')); + }); +}); diff --git a/src/utils/react/useScheme.ts b/src/utils/react/useScheme.ts new file mode 100644 index 000000000..8989a744b --- /dev/null +++ b/src/utils/react/useScheme.ts @@ -0,0 +1,235 @@ +import { useSyncExternalStore } from 'react'; + +/** + * The ambient viewing conditions — the color scheme and the contrast tier the + * document is showing right now. + * + * This module is the single owner of the definition: it builds the `@dark` / + * `@hc` predefined states that `src/components/Root.tsx` registers with tasty, + * *and* it answers the same question in JS. One definition, two readers, so the + * CSS and the JS answers cannot drift apart. + * + * **For styling, do not use this.** `{ '': light, '@dark': dark, '@hc': hc }` is + * the answer, and branching styles in JS gives up the conditionality that lets a + * scheme flip repaint without a re-render. Two cases the state map cannot serve: + * + * 1. **Surfaces the stylesheet does not reach** — a Vega spec, a CodeMirror or + * Monaco theme, a third-party iframe. They take values, not CSS, so `@dark` + * never applies and the branch has to happen in JS. + * 2. **Control state that is not styling** — seeding or labelling a control from + * the ambient condition, e.g. a Light/Dark or Normal/High-contrast preview + * selector that starts on whatever the page is already showing. + * + * Root-level only, and deliberately not a generic state reader: tasty's state + * vocabulary mixes element-local states (`hovered`, `pressed`, `disabled`) with + * ambient ones (`@media(…)`, `@root(…)`), and only the ambient half is + * answerable without an element. Evaluating the state language in JS would mean + * a second implementation of the state algebra to keep in agreement with the CSS + * one, for a question almost every caller asks about the root anyway. + */ + +// ============================================================================ +// The definition +// ============================================================================ + +/** + * The `data-*` attribute names the opt-in uses, and the media queries it falls + * back to. `@root(schema=dark)` compiles to `:root[data-schema="dark"]`, hence + * the `data-` prefix on the DOM side and the bare key on the tasty side. + */ +const SCHEME_KEY = 'schema'; +const CONTRAST_KEY = 'contrast'; +const SCHEME_ATTR = `data-${SCHEME_KEY}`; +const CONTRAST_ATTR = `data-${CONTRAST_KEY}`; +const DARK_QUERY = '(prefers-color-scheme: dark)'; +const HIGH_CONTRAST_QUERY = '(prefers-contrast: more)'; + +/** + * The `@dark` / `@hc` state aliases, in tasty's DSL — registered globally by + * `` via `setGlobalPredefinedStates()`. + * + * The attribute opt-in wins over the system preference, and the fallback is + * gated on the attribute being *absent* (`!@root(schema)`) rather than on it + * being some other value — so `` stays light inside a + * dark OS, which is the whole point of an opt-in. {@link resolveScheme} and + * {@link resolveHighContrast} read the same way. + */ +export const AMBIENT_PREDEFINED_STATES = { + '@dark': `@root(${SCHEME_KEY}=dark) | (!@root(${SCHEME_KEY}) & @media${DARK_QUERY})`, + '@hc': `@root(${CONTRAST_KEY}=high) | (!@root(${CONTRAST_KEY}) & @media${HIGH_CONTRAST_QUERY})`, +} as const; + +/** The color scheme the document resolves to. Matches `renderColorTokens({ scheme })`. */ +export type ColorScheme = 'light' | 'dark'; + +// ============================================================================ +// Reading +// ============================================================================ + +/** + * `matchMedia` is called per read rather than cached, so a test that stubs it + * takes effect immediately. Missing (older jsdom, non-DOM runtimes) reads as + * "no preference", which lands on the light / normal-contrast defaults. + */ +function mediaMatches(query: string): boolean { + return typeof window !== 'undefined' && + typeof window.matchMedia === 'function' + ? window.matchMedia(query).matches + : false; +} + +function rootAttribute(name: string): string | null { + return typeof document === 'undefined' + ? null + : document.documentElement.getAttribute(name); +} + +/** + * The document's current color scheme, read once — the JS answer to `@dark`. + * + * Outside React (a chart spec built in a module, an editor theme registered at + * import time). In React use {@link useScheme}, which also re-renders on change. + * Returns `'light'` with no DOM. + */ +export function resolveScheme(): ColorScheme { + const attribute = rootAttribute(SCHEME_ATTR); + + if (attribute !== null) { + return attribute === 'dark' ? 'dark' : 'light'; + } + + return mediaMatches(DARK_QUERY) ? 'dark' : 'light'; +} + +/** + * Whether the document is showing the high-contrast tier, read once — the JS + * answer to `@hc`. + * + * Note this is an *ambient* condition, not the theme's `contrastLevel`: the + * level is a `PaletteConfig` seed the app supplies (read it with + * `getPaletteConfig()`), while this is the tier the viewer asked for. + * Returns `false` with no DOM. + */ +export function resolveHighContrast(): boolean { + const attribute = rootAttribute(CONTRAST_ATTR); + + if (attribute !== null) { + return attribute === 'high'; + } + + return mediaMatches(HIGH_CONTRAST_QUERY); +} + +// ============================================================================ +// Watching +// ============================================================================ + +const listeners = new Set<() => void>(); + +let stopWatching: (() => void) | null = null; + +function notify() { + listeners.forEach((listener) => listener()); +} + +/** + * Both inputs have to be watched, because either can decide the answer: the + * attribute when the opt-in is set, the media query otherwise. One observer and + * one pair of query listeners are shared by every subscriber, and torn down when + * the last one leaves. + */ +function startWatching(): () => void { + if (typeof document === 'undefined') { + return () => {}; + } + + const observer = new MutationObserver(notify); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: [SCHEME_ATTR, CONTRAST_ATTR], + }); + + const queries = + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? [window.matchMedia(DARK_QUERY), window.matchMedia(HIGH_CONTRAST_QUERY)] + : []; + + queries.forEach((query) => query.addEventListener('change', notify)); + + return () => { + observer.disconnect(); + queries.forEach((query) => query.removeEventListener('change', notify)); + }; +} + +/** + * Subscribe to ambient condition changes — either the scheme or the contrast + * tier. Returns an unsubscribe function. + * + * The listener takes no argument: re-read with {@link resolveScheme} / + * {@link resolveHighContrast}, which is what the hooks below do. For non-React + * consumers that own a surface the stylesheet cannot reach — re-theming a Monaco + * instance, re-rendering a chart. + */ +export function subscribeScheme(listener: () => void): () => void { + listeners.add(listener); + + if (!stopWatching) { + stopWatching = startWatching(); + } + + return () => { + listeners.delete(listener); + + if (!listeners.size && stopWatching) { + stopWatching(); + stopWatching = null; + } + }; +} + +// ============================================================================ +// React bindings +// ============================================================================ + +/** + * Snapshots are primitives, so React bails out on an unchanged value and no + * memoization is needed — a contrast change re-runs a `useScheme()` reader's + * `getSnapshot` and stops there. + */ +const getServerScheme = (): ColorScheme => 'light'; +const getServerHighContrast = () => false; + +/** + * The document's color scheme, kept live — `'light'` or `'dark'`. + * + * ```tsx + * const scheme = useScheme(); + * + * buildSpec(scheme), [scheme])} />; + * ``` + * + * Follows both the `` opt-in and `prefers-color-scheme`, + * exactly as the `@dark` state does. Under SSR it renders `'light'` and + * re-renders with the real value after hydration. + */ +export function useScheme(): ColorScheme { + return useSyncExternalStore(subscribeScheme, resolveScheme, getServerScheme); +} + +/** + * Whether the document is showing the high-contrast tier, kept live — the hook + * form of {@link resolveHighContrast}, following `` and + * `prefers-contrast` exactly as the `@hc` state does. + * + * Under SSR it renders `false` and re-renders with the real value after + * hydration. + */ +export function useHighContrast(): boolean { + return useSyncExternalStore( + subscribeScheme, + resolveHighContrast, + getServerHighContrast, + ); +} From 36843cb1457b25d122fd8cc231c895eb759044a7 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Tue, 25 Aug 2026 18:08:40 +0200 Subject: [PATCH 2/4] refactor(useSchema): name the concept `schema`, not `scheme` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...scheme.md => lucky-hooks-follow-schema.md} | 2 +- src/components/Root.tsx | 12 ++--- src/stories/Theming.docs.mdx | 14 +++--- src/stories/Theming.stories.tsx | 36 +++++++------- src/stories/Usage.docs.mdx | 8 ++-- src/utils/react/index.ts | 10 ++-- ...{useScheme.test.tsx => useSchema.test.tsx} | 32 ++++++------- .../react/{useScheme.ts => useSchema.ts} | 48 +++++++++---------- 8 files changed, 81 insertions(+), 81 deletions(-) rename .changeset/{lucky-hooks-follow-scheme.md => lucky-hooks-follow-schema.md} (70%) rename src/utils/react/{useScheme.test.tsx => useSchema.test.tsx} (87%) rename src/utils/react/{useScheme.ts => useSchema.ts} (86%) diff --git a/.changeset/lucky-hooks-follow-scheme.md b/.changeset/lucky-hooks-follow-schema.md similarity index 70% rename from .changeset/lucky-hooks-follow-scheme.md rename to .changeset/lucky-hooks-follow-schema.md index 21031fdcb..fb18a610f 100644 --- a/.changeset/lucky-hooks-follow-scheme.md +++ b/.changeset/lucky-hooks-follow-schema.md @@ -2,4 +2,4 @@ '@cube-dev/ui-kit': minor --- -Added `useScheme()` and `useHighContrast()` — the JS answer to the `@dark` and `@hc` states, for the two places a state map cannot reach: surfaces that take values rather than CSS (a Vega spec, a CodeMirror/Monaco theme, an iframe) and controls whose value *is* the ambient condition. Both follow the `` / `` opt-in first and `prefers-color-scheme` / `prefers-contrast` second, exactly as the states do, and re-render on a change. `resolveScheme()`, `resolveHighContrast()` and `subscribeScheme()` cover the same ground outside React. Styling still belongs in a state map. +Added `useSchema()` and `useHighContrast()` — the JS answer to the `@dark` and `@hc` states, for the two places a state map cannot reach: surfaces that take values rather than CSS (a Vega spec, a CodeMirror/Monaco theme, an iframe) and controls whose value *is* the ambient condition. Both follow the `` / `` opt-in first and `prefers-color-scheme` / `prefers-contrast` second, exactly as the states do, and re-render on a change. `resolveSchema()`, `resolveHighContrast()` and `subscribeSchema()` cover the same ground outside React. Styling still belongs in a state map. diff --git a/src/components/Root.tsx b/src/components/Root.tsx index c36984827..b221cd8e3 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -16,7 +16,7 @@ import { NavigationAdapter } from '../providers/navigation.types'; import { TrackingProps, TrackingProvider } from '../providers/TrackingProvider'; import { PaletteConfig, setPaletteConfig } from '../tokens/palette-config'; import { EventBusProvider } from '../utils/react/useEventBus'; -import { AMBIENT_PREDEFINED_STATES } from '../utils/react/useScheme'; +import { AMBIENT_PREDEFINED_STATES } from '../utils/react/useSchema'; import { extractStyles } from '../utils/styles'; import { TASTY_VERSION, VERSION } from '../version'; @@ -27,14 +27,14 @@ import { PortalProvider } from './portal'; import type { i18n as I18nInstance } from 'i18next'; -// Color-scheme aliases for the Glaze-generated palette (see `src/tokens/palette.ts`). +// Color-schema aliases for the Glaze-generated palette (see `src/tokens/palette.ts`). // Attribute opt-in wins over system preference: -// → forces dark scheme -// → forces high-contrast scheme +// → forces the dark schema +// → forces the high-contrast schema // Otherwise falls back to the user's `prefers-color-scheme` / `prefers-contrast`. // -// The strings live in `useScheme.ts`, which also reads the same two conditions -// from JS (`useScheme()` / `useHighContrast()`) — one definition, so the CSS and +// The strings live in `useSchema.ts`, which also reads the same two conditions +// from JS (`useSchema()` / `useHighContrast()`) — one definition, so the CSS and // the JS answers cannot drift apart. setGlobalPredefinedStates(AMBIENT_PREDEFINED_STATES); diff --git a/src/stories/Theming.docs.mdx b/src/stories/Theming.docs.mdx index baf20f87d..0fd40c20d 100644 --- a/src/stories/Theming.docs.mdx +++ b/src/stories/Theming.docs.mdx @@ -701,27 +701,27 @@ subscribePaletteConfig(() => { `getPaletteConfig().themes.danger.saturation` is a number even if nobody ever set it. `DEFAULT_PALETTE_CONFIG` is the shipped baseline in the same shape. -## Reading the ambient scheme +## Reading the ambient schema -The palette config is what the app *asked for*; the scheme and contrast tier are +The palette config is what the app *asked for*; the schema and contrast tier are what the viewer is *seeing* — `` / `prefers-color-scheme`, and `` / `prefers-contrast`. They are not part of the config, so `getPaletteConfig()` says nothing about them: ```tsx -import { useHighContrast, useScheme } from '@cube-dev/ui-kit'; +import { useHighContrast, useSchema } from '@cube-dev/ui-kit'; -const scheme = useScheme(); // 'light' | 'dark' +const schema = useSchema(); // 'light' | 'dark' const isHighContrast = useHighContrast(); // boolean ``` Both resolve exactly as the `@dark` / `@hc` states do — attribute opt-in first, media query only when the attribute is absent — and re-render when either input -changes. Outside React: `resolveScheme()`, `resolveHighContrast()` and -`subscribeScheme(listener)`, which fires on a change to either condition. +changes. Outside React: `resolveSchema()`, `resolveHighContrast()` and +`subscribeSchema(listener)`, which fires on a change to either condition. Reach for these only where CSS cannot go. Styling belongs in a state map -(`{ '': …, '@dark': …, '@hc': … }`), which repaints on a scheme flip with no +(`{ '': …, '@dark': …, '@hc': … }`), which repaints on a schema flip with no re-render at all. What the states cannot reach is a surface that takes values rather than CSS — a Vega spec, a CodeMirror or Monaco theme, an iframe — or a control whose value *is* the condition, like the Light/Dark and diff --git a/src/stories/Theming.stories.tsx b/src/stories/Theming.stories.tsx index cbc1b0864..530529582 100644 --- a/src/stories/Theming.stories.tsx +++ b/src/stories/Theming.stories.tsx @@ -34,14 +34,14 @@ import { useHighContrast, usePaletteConfig, usePaletteVersion, - useScheme, + useSchema, } from '../index'; import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Styles, Tokens } from '@tenphi/tasty'; import type { ReactNode } from 'react'; import type { - ColorScheme, + ColorSchema, PaletteConfig, PaletteNumericSeed, PaletteSeed, @@ -1984,10 +1984,10 @@ const PREVIEW_NAV = [ function ThemePreview({ tokens, - scheme, + schema, }: { tokens: Tokens; - scheme: ColorScheme; + schema: ColorSchema; }) { const [tab, setTab] = useState(PREVIEW_TABS[0]); @@ -1996,8 +1996,8 @@ function ThemePreview({ {/* The mark is two drawings swapped by the `@dark` state, which follows the *document* — tokens override token values, not states, so the preview has - to pin the scheme explicitly. Its colour is a token and needs no help. */} - + to pin the schema explicitly. Its colour is a token and needs no help. */} + Quarterly Revenue Draft @@ -2123,18 +2123,18 @@ function ThemePreview({ } function ThemeBuilderPage() { - // Scheme and contrast tier are viewing conditions, not theme settings — the + // Schema and contrast tier are viewing conditions, not theme settings — the // same theme renders in all four of them. The switches start on whatever the // page is already showing and keep tracking it until someone presses one, so // a light preview is never stranded inside a dark page. - const documentScheme = useScheme(); + const documentSchema = useSchema(); const documentHighContrast = useHighContrast(); // `null` while the switch is still following the document. Deriving the shown // value from "override ?? document" is what lets a two-option control follow // without an `Auto` option standing for the third state — and `auto` could not // be previewed anyway: it is a *preference*, and a flat token value renders one // concrete variant. - const [schemeOverride, setSchemeOverride] = useState( + const [schemaOverride, setSchemaOverride] = useState( null, ); const [contrastOverride, setContrastOverride] = useState( @@ -2142,14 +2142,14 @@ function ThemeBuilderPage() { ); const version = usePaletteVersion(); - const scheme = schemeOverride ?? documentScheme; + const schema = schemaOverride ?? documentSchema; const isHighContrast = contrastOverride ?? documentHighContrast; // The level lives in the palette config now, so it needs no mention here: it // is part of the theme being built, and both of these pick it up. const tokens = useMemo( - () => renderColorTokens({ scheme, highContrast: isHighContrast }), - [scheme, isHighContrast, version], + () => renderColorTokens({ scheme: schema, highContrast: isHighContrast }), + [schema, isHighContrast, version], ); // Resolved separately for the controls, which the *document* paints. Both @@ -2158,10 +2158,10 @@ function ThemeBuilderPage() { const documentTokens = useMemo( () => renderColorTokens({ - scheme: documentScheme, + scheme: documentSchema, highContrast: documentHighContrast, }), - [documentScheme, documentHighContrast, version], + [documentSchema, documentHighContrast, version], ); return ( @@ -2182,10 +2182,10 @@ function ThemeBuilderPage() { setSchemeOverride(value as ColorScheme)} + value={schema} + onChange={(value) => setSchemaOverride(value as ColorSchema)} > Light Dark @@ -2213,7 +2213,7 @@ function ThemeBuilderPage() { /> )} - + diff --git a/src/stories/Usage.docs.mdx b/src/stories/Usage.docs.mdx index a43d4c418..ce4cca4f0 100644 --- a/src/stories/Usage.docs.mdx +++ b/src/stories/Usage.docs.mdx @@ -117,18 +117,18 @@ The `@dark` and `@hc` predefined states are wired up in `src/components/Root.tsx When the attribute is absent, the scheme falls back to `@media (prefers-color-scheme: dark)` and `@media (prefers-contrast: more)`. -#### Reading the scheme from JS +#### Reading the schema from JS For styling, use the state map — `{ '': light, '@dark': dark, '@hc': hc }`. Two cases it cannot serve: a surface the stylesheet never reaches (a Vega spec, a CodeMirror / Monaco theme, an iframe), and a control whose value *is* the ambient condition (a Light/Dark preview selector). For those, read the same two conditions from JS: ```tsx -import { useHighContrast, useScheme } from '@cube-dev/ui-kit'; +import { useHighContrast, useSchema } from '@cube-dev/ui-kit'; -const scheme = useScheme(); // 'light' | 'dark' — the JS answer to `@dark` +const schema = useSchema(); // 'light' | 'dark' — the JS answer to `@dark` const isHighContrast = useHighContrast(); // boolean — the JS answer to `@hc` ``` -Both follow the attribute opt-in first and the media query second, exactly as the states do, and re-render when either changes. Outside React: `resolveScheme()`, `resolveHighContrast()`, and `subscribeScheme(listener)` (fires on either condition; re-read in the listener). +Both follow the attribute opt-in first and the media query second, exactly as the states do, and re-render when either changes. Outside React: `resolveSchema()`, `resolveHighContrast()`, and `subscribeSchema(listener)` (fires on either condition; re-read in the listener). Root-level only — there is no element-scoped or generic state reader, on purpose. Tasty's state vocabulary mixes element-local states (`hovered`, `pressed`, `disabled`) with ambient ones (`@media(…)`, `@root(…)`), and only the ambient half is answerable without an element. diff --git a/src/utils/react/index.ts b/src/utils/react/index.ts index 62a732041..49b0dc6e9 100644 --- a/src/utils/react/index.ts +++ b/src/utils/react/index.ts @@ -17,13 +17,13 @@ export { useCombinedRefs, mergeRefs } from './useCombinedRefs'; export { wrapNodeIfPlain } from './wrapNodeIfPlain'; export { useViewportSize } from './useViewportSize'; export { - useScheme, + useSchema, useHighContrast, - resolveScheme, + resolveSchema, resolveHighContrast, - subscribeScheme, -} from './useScheme'; -export type { ColorScheme } from './useScheme'; + subscribeSchema, +} from './useSchema'; +export type { ColorSchema } from './useSchema'; export { useQaProps } from './useQaProps'; export { useEventBus, useEventListener, EventBusProvider } from './useEventBus'; export type { EventBusListener, EventBusContextValue } from './useEventBus'; diff --git a/src/utils/react/useScheme.test.tsx b/src/utils/react/useSchema.test.tsx similarity index 87% rename from src/utils/react/useScheme.test.tsx rename to src/utils/react/useSchema.test.tsx index e69875d74..fc48e7d68 100644 --- a/src/utils/react/useScheme.test.tsx +++ b/src/utils/react/useSchema.test.tsx @@ -4,10 +4,10 @@ import { renderHook } from '../../test'; import { resolveHighContrast, - resolveScheme, + resolveSchema, useHighContrast, - useScheme, -} from './useScheme'; + useSchema, +} from './useSchema'; const DARK = '(prefers-color-scheme: dark)'; const MORE_CONTRAST = '(prefers-contrast: more)'; @@ -47,7 +47,7 @@ function stubMatchMedia(initial: Record = {}) { }; } -describe('useScheme / useHighContrast', () => { +describe('useSchema / useHighContrast', () => { const originalMatchMedia = window.matchMedia; afterEach(() => { @@ -61,15 +61,15 @@ describe('useScheme / useHighContrast', () => { document.documentElement.removeAttribute('data-contrast'); }); - describe('resolveScheme', () => { + describe('resolveSchema', () => { it('falls back to the media query when no attribute is set', () => { const set = stubMatchMedia({ [DARK]: true }); - expect(resolveScheme()).toBe('dark'); + expect(resolveSchema()).toBe('dark'); set(DARK, false); - expect(resolveScheme()).toBe('light'); + expect(resolveSchema()).toBe('light'); }); it('lets the attribute opt-in win over the system preference', () => { @@ -77,11 +77,11 @@ describe('useScheme / useHighContrast', () => { document.documentElement.setAttribute('data-schema', 'light'); - expect(resolveScheme()).toBe('light'); + expect(resolveSchema()).toBe('light'); document.documentElement.setAttribute('data-schema', 'dark'); - expect(resolveScheme()).toBe('dark'); + expect(resolveSchema()).toBe('dark'); }); it('reads any other attribute value as light, exactly as `@dark` does', () => { @@ -92,7 +92,7 @@ describe('useScheme / useHighContrast', () => { // than falling through to the preference. document.documentElement.setAttribute('data-schema', 'sepia'); - expect(resolveScheme()).toBe('light'); + expect(resolveSchema()).toBe('light'); }); }); @@ -119,7 +119,7 @@ describe('useScheme / useHighContrast', () => { it('re-renders when the attribute flips', async () => { stubMatchMedia(); - const { result } = renderHook(() => useScheme()); + const { result } = renderHook(() => useSchema()); expect(result.current).toBe('light'); @@ -139,15 +139,15 @@ describe('useScheme / useHighContrast', () => { it('re-renders when the system preference changes', async () => { const set = stubMatchMedia({ [DARK]: false, [MORE_CONTRAST]: false }); - const scheme = renderHook(() => useScheme()); + const schema = renderHook(() => useSchema()); const contrast = renderHook(() => useHighContrast()); - expect(scheme.result.current).toBe('light'); + expect(schema.result.current).toBe('light'); expect(contrast.result.current).toBe(false); await act(async () => set(DARK, true)); - expect(scheme.result.current).toBe('dark'); + expect(schema.result.current).toBe('dark'); expect(contrast.result.current).toBe(false); await act(async () => set(MORE_CONTRAST, true)); @@ -160,9 +160,9 @@ describe('useScheme / useHighContrast', () => { // The observer and the query listeners are shared and torn down when the // listener set empties, so a remount has to build them again. - renderHook(() => useScheme()).unmount(); + renderHook(() => useSchema()).unmount(); - const { result } = renderHook(() => useScheme()); + const { result } = renderHook(() => useSchema()); await act(async () => { document.documentElement.setAttribute('data-schema', 'dark'); diff --git a/src/utils/react/useScheme.ts b/src/utils/react/useSchema.ts similarity index 86% rename from src/utils/react/useScheme.ts rename to src/utils/react/useSchema.ts index 8989a744b..94f556e6b 100644 --- a/src/utils/react/useScheme.ts +++ b/src/utils/react/useSchema.ts @@ -1,7 +1,7 @@ import { useSyncExternalStore } from 'react'; /** - * The ambient viewing conditions — the color scheme and the contrast tier the + * The ambient viewing conditions — the color schema and the contrast tier the * document is showing right now. * * This module is the single owner of the definition: it builds the `@dark` / @@ -11,7 +11,7 @@ import { useSyncExternalStore } from 'react'; * * **For styling, do not use this.** `{ '': light, '@dark': dark, '@hc': hc }` is * the answer, and branching styles in JS gives up the conditionality that lets a - * scheme flip repaint without a re-render. Two cases the state map cannot serve: + * schema flip repaint without a re-render. Two cases the state map cannot serve: * * 1. **Surfaces the stylesheet does not reach** — a Vega spec, a CodeMirror or * Monaco theme, a third-party iframe. They take values, not CSS, so `@dark` @@ -37,9 +37,9 @@ import { useSyncExternalStore } from 'react'; * back to. `@root(schema=dark)` compiles to `:root[data-schema="dark"]`, hence * the `data-` prefix on the DOM side and the bare key on the tasty side. */ -const SCHEME_KEY = 'schema'; +const SCHEMA_KEY = 'schema'; const CONTRAST_KEY = 'contrast'; -const SCHEME_ATTR = `data-${SCHEME_KEY}`; +const SCHEMA_ATTR = `data-${SCHEMA_KEY}`; const CONTRAST_ATTR = `data-${CONTRAST_KEY}`; const DARK_QUERY = '(prefers-color-scheme: dark)'; const HIGH_CONTRAST_QUERY = '(prefers-contrast: more)'; @@ -51,16 +51,16 @@ const HIGH_CONTRAST_QUERY = '(prefers-contrast: more)'; * The attribute opt-in wins over the system preference, and the fallback is * gated on the attribute being *absent* (`!@root(schema)`) rather than on it * being some other value — so `` stays light inside a - * dark OS, which is the whole point of an opt-in. {@link resolveScheme} and + * dark OS, which is the whole point of an opt-in. {@link resolveSchema} and * {@link resolveHighContrast} read the same way. */ export const AMBIENT_PREDEFINED_STATES = { - '@dark': `@root(${SCHEME_KEY}=dark) | (!@root(${SCHEME_KEY}) & @media${DARK_QUERY})`, + '@dark': `@root(${SCHEMA_KEY}=dark) | (!@root(${SCHEMA_KEY}) & @media${DARK_QUERY})`, '@hc': `@root(${CONTRAST_KEY}=high) | (!@root(${CONTRAST_KEY}) & @media${HIGH_CONTRAST_QUERY})`, } as const; -/** The color scheme the document resolves to. Matches `renderColorTokens({ scheme })`. */ -export type ColorScheme = 'light' | 'dark'; +/** The color schema the document resolves to — the two arms of the `@dark` state. */ +export type ColorSchema = 'light' | 'dark'; // ============================================================================ // Reading @@ -85,14 +85,14 @@ function rootAttribute(name: string): string | null { } /** - * The document's current color scheme, read once — the JS answer to `@dark`. + * The document's current color schema, read once — the JS answer to `@dark`. * * Outside React (a chart spec built in a module, an editor theme registered at - * import time). In React use {@link useScheme}, which also re-renders on change. + * import time). In React use {@link useSchema}, which also re-renders on change. * Returns `'light'` with no DOM. */ -export function resolveScheme(): ColorScheme { - const attribute = rootAttribute(SCHEME_ATTR); +export function resolveSchema(): ColorSchema { + const attribute = rootAttribute(SCHEMA_ATTR); if (attribute !== null) { return attribute === 'dark' ? 'dark' : 'light'; @@ -147,7 +147,7 @@ function startWatching(): () => void { observer.observe(document.documentElement, { attributes: true, - attributeFilter: [SCHEME_ATTR, CONTRAST_ATTR], + attributeFilter: [SCHEMA_ATTR, CONTRAST_ATTR], }); const queries = @@ -164,15 +164,15 @@ function startWatching(): () => void { } /** - * Subscribe to ambient condition changes — either the scheme or the contrast + * Subscribe to ambient condition changes — either the schema or the contrast * tier. Returns an unsubscribe function. * - * The listener takes no argument: re-read with {@link resolveScheme} / + * The listener takes no argument: re-read with {@link resolveSchema} / * {@link resolveHighContrast}, which is what the hooks below do. For non-React * consumers that own a surface the stylesheet cannot reach — re-theming a Monaco * instance, re-rendering a chart. */ -export function subscribeScheme(listener: () => void): () => void { +export function subscribeSchema(listener: () => void): () => void { listeners.add(listener); if (!stopWatching) { @@ -195,27 +195,27 @@ export function subscribeScheme(listener: () => void): () => void { /** * Snapshots are primitives, so React bails out on an unchanged value and no - * memoization is needed — a contrast change re-runs a `useScheme()` reader's + * memoization is needed — a contrast change re-runs a `useSchema()` reader's * `getSnapshot` and stops there. */ -const getServerScheme = (): ColorScheme => 'light'; +const getServerSchema = (): ColorSchema => 'light'; const getServerHighContrast = () => false; /** - * The document's color scheme, kept live — `'light'` or `'dark'`. + * The document's color schema, kept live — `'light'` or `'dark'`. * * ```tsx - * const scheme = useScheme(); + * const schema = useSchema(); * - * buildSpec(scheme), [scheme])} />; + * buildSpec(schema), [schema])} />; * ``` * * Follows both the `` opt-in and `prefers-color-scheme`, * exactly as the `@dark` state does. Under SSR it renders `'light'` and * re-renders with the real value after hydration. */ -export function useScheme(): ColorScheme { - return useSyncExternalStore(subscribeScheme, resolveScheme, getServerScheme); +export function useSchema(): ColorSchema { + return useSyncExternalStore(subscribeSchema, resolveSchema, getServerSchema); } /** @@ -228,7 +228,7 @@ export function useScheme(): ColorScheme { */ export function useHighContrast(): boolean { return useSyncExternalStore( - subscribeScheme, + subscribeSchema, resolveHighContrast, getServerHighContrast, ); From e5235f4c3106571dd5abaf03b5f08039de4b25e3 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Tue, 25 Aug 2026 18:21:19 +0200 Subject: [PATCH 3/4] refactor: rename the `scheme` term to `schema` everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: `renderColorTokens`/`renderPaletteTokens` take `schema` instead of `scheme`, ``/`` 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 --- .changeset/schema-not-scheme.md | 11 ++ .storybook/preview.jsx | 8 +- docs/rules/probe.md | 14 +- scripts/probe.mjs | 56 +++--- src/components/GlobalStyles.tsx | 8 +- src/components/actions/Banner/Banner.tsx | 2 +- src/components/actions/Menu/styled.tsx | 2 +- src/components/content/Item/Item.stories.tsx | 2 +- .../content/ItemCard/ItemCard.docs.mdx | 2 +- .../content/PrismCode/PrismCode.docs.mdx | 6 +- src/components/data/AGENTS.md | 2 +- .../data/DataTable/DataTable.browser.test.tsx | 4 +- .../data/DataTable/DataTable.docs.mdx | 2 +- .../data/DataTable/DataTable.stories.tsx | 2 +- src/components/data/TableBase/column-tint.ts | 2 +- src/components/data/TableBase/types.ts | 6 +- src/components/fields/Checkbox/Checkbox.tsx | 2 +- .../ColorSwatchGroup/ColorSwatchGroup.tsx | 2 +- src/components/fields/Picker/Picker.docs.mdx | 2 +- src/components/fields/Select/Select.tsx | 2 +- src/components/fields/color/color.ts | 2 +- src/components/layout/Board/Board.tsx | 2 +- src/components/layout/Board/WidgetHost.tsx | 4 +- .../organisms/StatsCard/StatsCard.docs.mdx | 2 +- .../other/CubeLogo/CubeLogo.docs.mdx | 18 +- .../other/CubeLogo/CubeLogo.stories.tsx | 8 +- src/components/other/CubeLogo/CubeLogo.tsx | 26 +-- .../other/CubeLogo/cube-logo.test.tsx | 12 +- .../other/NoDataIcon/NoDataIcon.stories.tsx | 10 +- .../overlays/Tooltip/Tooltip.docs.mdx | 4 +- src/components/overlays/Tooltip/Tooltip.tsx | 8 +- .../overlays/Tooltip/TooltipProvider.docs.mdx | 2 +- .../LoadingAnimation.stories.tsx | 10 +- src/data/item-themes.test.ts | 2 +- src/data/item-themes.ts | 28 +-- src/data/themes.ts | 2 +- src/stories/AdvancedStates.stories.tsx | 14 +- src/stories/Colors.docs.mdx | 4 +- src/stories/Colors.stories.tsx | 4 +- src/stories/Introduction.docs.mdx | 2 +- src/stories/Theming.docs.mdx | 28 +-- src/stories/Theming.stories.tsx | 14 +- src/stories/Usage.docs.mdx | 20 +- ...orSchemeBridge.ts => colorSchemaBridge.ts} | 22 +-- ...ithColorScheme.tsx => withColorSchema.tsx} | 56 +++--- src/test/probe/config-guard.ts | 2 +- src/test/probe/harness.browser.probe.tsx | 22 +-- src/test/probe/harness.probe.tsx | 4 +- src/test/probe/io.ts | 2 +- src/tokens/color-theme.test.ts | 24 +-- src/tokens/color-theme.ts | 4 +- src/tokens/colors.ts | 18 +- src/tokens/palette-config.ts | 2 +- src/tokens/palette.test.ts | 172 +++++++++--------- src/tokens/palette.ts | 60 +++--- src/tokens/shadows.ts | 2 +- 56 files changed, 382 insertions(+), 371 deletions(-) create mode 100644 .changeset/schema-not-scheme.md rename src/stories/decorators/{colorSchemeBridge.ts => colorSchemaBridge.ts} (65%) rename src/stories/decorators/{withColorScheme.tsx => withColorSchema.tsx} (69%) diff --git a/.changeset/schema-not-scheme.md b/.changeset/schema-not-scheme.md new file mode 100644 index 000000000..eb36f4e94 --- /dev/null +++ b/.changeset/schema-not-scheme.md @@ -0,0 +1,11 @@ +--- +'@cube-dev/ui-kit': minor +--- + +**Breaking:** renamed the `scheme` term to `schema` across the API, so one word names the concept the `data-schema` attribute and the `@root(schema=…)` state already use. + +- `renderColorTokens()` / `renderPaletteTokens()` / `RenderPaletteOptions`: the `scheme` option is now `schema` — `renderColorTokens({ schema: 'dark' })`. +- `` / ``: the `scheme` prop is now `schema`. +- The probe's `tokenOptions.scheme` is now `tokenOptions.schema`, and the `pnpm probe` CLI flag `--scheme` is now `--schema` (`--schema hc` still means light + high contrast). + +No aliases: update the call sites. diff --git a/.storybook/preview.jsx b/.storybook/preview.jsx index bad13f79f..5037b7a1c 100644 --- a/.storybook/preview.jsx +++ b/.storybook/preview.jsx @@ -8,7 +8,7 @@ import { create, themes } from 'storybook/theming'; import { Root } from '../src/components/Root'; import { getI18n, LOCALE_LABELS, SUPPORTED_LOCALES } from '../src/i18n'; -import { setToolbarScheme } from '../src/stories/decorators/colorSchemeBridge'; +import { setToolbarSchema } from '../src/stories/decorators/colorSchemaBridge'; // Summarizes DOM/React events before Storybook's action spies see them. Without // it, serializing a focus event over the preview channel costs ~600ms per focus @@ -53,7 +53,7 @@ configure({ testIdAttribute: 'data-qa', asyncUtilTimeout: 10000 }); // initial event after manager/preview channels connect. if (typeof document !== 'undefined') { addons.getChannel().on(DARK_MODE_EVENT_NAME, (isDark) => { - setToolbarScheme(isDark ? 'dark' : 'light'); + setToolbarSchema(isDark ? 'dark' : 'light'); }); } @@ -137,7 +137,7 @@ export const parameters = { // `storybook-dark-mode` configuration. No `current` so the addon resolves // OS `prefers-color-scheme` on first load. `stylePreview: false` keeps the // addon from also injecting dark/light classes on the preview body — the - // `data-schema` attribute set by `colorSchemeBridge` is the only signal + // `data-schema` attribute set by `colorSchemaBridge` is the only signal // we care about (see `src/components/Root.tsx` and `src/tokens/palette.ts`). darkMode: { dark: darkTheme, @@ -147,7 +147,7 @@ export const parameters = { // Storybook's `addon-backgrounds` injects `.sb-show-main { background: … !important }` // when an option is selected, which overrides the body's `#surface` fill from // `src/components/GlobalStyles.tsx`. Disable it globally so the body's - // scheme-aware Glaze background shows through (dark/light). Stories can still + // schema-aware Glaze background shows through (dark/light). Stories can still // override via `parameters.backgrounds = { disable: false, … }`. // NOTE: the addon's parameter is `disable` (not `disabled`) — the latter is // silently ignored, leaving the addon active and its toolbar still able to diff --git a/docs/rules/probe.md b/docs/rules/probe.md index a82d838b0..0643c254a 100644 --- a/docs/rules/probe.md +++ b/docs/rules/probe.md @@ -6,7 +6,7 @@ Answering "what CSS does this actually produce?" used to mean hand-writing a thr ```bash pnpm probe styles '{"fill":"#purple","padding":"2x","preset":"t3"}' -pnpm probe tokens --scheme dark --filter surface +pnpm probe tokens --schema dark --filter surface pnpm probe globals pnpm probe render <<'TSX' import { Button } from '@cube-dev/ui-kit'; @@ -25,8 +25,8 @@ TSX **`tokens`** — two shapes of the same palette, labelled rather than merged: -- `resolved` is `renderColorTokens()`: flat literal values for **one** variant, chosen with `--scheme light|dark` and `--hc`. The legacy aliases come back **by reference** (`'#dark': '#surface-text'`) rather than resolved — deliberately, so a region preview re-resolves them against its own tokens — and the probe labels them so you cannot read one as a color. -- `palette` is `getPaletteTokens()`: one tasty state map per token, keyed by scheme (`''` / `'@dark'` / `'@hc'` / `'@dark & @hc'`). This is the **four-variant view**, and it is the one a palette change has to be diffed across — see [`docs/glaze/`](../glaze/) on why light mode alone is misleading. +- `resolved` is `renderColorTokens()`: flat literal values for **one** variant, chosen with `--schema light|dark` and `--hc`. The legacy aliases come back **by reference** (`'#dark': '#surface-text'`) rather than resolved — deliberately, so a region preview re-resolves them against its own tokens — and the probe labels them so you cannot read one as a color. +- `palette` is `getPaletteTokens()`: one tasty state map per token, keyed by schema (`''` / `'@dark'` / `'@hc'` / `'@dark & @hc'`). This is the **four-variant view**, and it is the one a palette change has to be diffed across — see [`docs/glaze/`](../glaze/) on why light mode alone is misleading. ```bash pnpm probe tokens --json > /tmp/tokens-before.json @@ -35,7 +35,7 @@ pnpm probe tokens --json > /tmp/tokens-after.json diff <(jq -S .palette /tmp/tokens-before.json) <(jq -S .palette /tmp/tokens-after.json) ``` -**`render`** — module-level code, then a trailing JSX expression (or an explicit `export default`). The default export is rendered by React as ``, so a snippet may use hooks — `useState` to probe a controlled input or a disclosure is ordinary, not exotic. Each run gets its own `.probe//` directory, so probing in parallel is safe; directories older than an hour are swept on the next run. It reports the markup plus **only the CSS that snippet caused**: the harness renders `` empty, captures, mounts the snippet, captures again and subtracts. Overlays are reported under `PORTALS` — `` is the `PortalProvider` target, so a `Dialog` renders as its *sibling* and never appears in the inline markup. `--full-css` keeps the baseline; `--canonical` normalises tasty's class hashes and React's `useId` counters so two renders can be diffed byte-for-byte, **on both tiers** — a browser run is exactly where you would diff one scheme or viewport against another. +**`render`** — module-level code, then a trailing JSX expression (or an explicit `export default`). The default export is rendered by React as ``, so a snippet may use hooks — `useState` to probe a controlled input or a disclosure is ordinary, not exotic. Each run gets its own `.probe//` directory, so probing in parallel is safe; directories older than an hour are swept on the next run. It reports the markup plus **only the CSS that snippet caused**: the harness renders `` empty, captures, mounts the snippet, captures again and subtracts. Overlays are reported under `PORTALS` — `` is the `PortalProvider` target, so a `Dialog` renders as its *sibling* and never appears in the inline markup. `--full-css` keeps the baseline; `--canonical` normalises tasty's class hashes and React's `useId` counters so two renders can be diffed byte-for-byte, **on both tiers** — a browser run is exactly where you would diff one schema or viewport against another. **`globals`** — everything on the page with only `` mounted: the `:root` token block, the body styles, `@font-face`, the keyframes. Note that only a handful of those rules are attributed to a node, and **those** are all `render` subtracts; the token block reaches the page through `useGlobalStyles` / `injectRawCSS`, so it lives on a global sheet that no per-node dump can see and `render` never had to exclude it. (Cube Cloud's console-ui hands its palette to `` through a tasty `tokens` prop instead, so there the same block *is* node-attributed and the subtraction is what keeps ~119KB out of every answer. Same command, different reason for the same clean output.) @@ -47,15 +47,15 @@ jsdom is the default only because it is quicker: it reports the CSS tasty genera ```bash pnpm probe:browser render --computed '[data-qa="Card"]' backgroundColor padding -pnpm probe:browser render --scheme dark --hc --screenshot +pnpm probe:browser render --schema dark --hc --screenshot pnpm probe:browser render --rect '[data-qa="Card"]' ``` The same component, both tiers: `var(--surface-2-color)` / `calc(3 * var(--gap))` under `probe`, versus `rgb(248, 248, 249)` / `24px` under `probe:browser`. `--computed` and `--rect` take a CSS selector, so give the component a `qa` prop and select on `[data-qa="…"]`. -Scheme and contrast are independent axes, driven through the `` attributes the `@dark` / `@hc` states resolve against — so `--scheme dark --hc` reaches the fourth variant, which no single `--scheme` value can express. `--scheme hc` stays accepted as the spelling Cloud's probe uses and means light + high contrast. +Schema and contrast are independent axes, driven through the `` attributes the `@dark` / `@hc` states resolve against — so `--schema dark --hc` reaches the fourth variant, which no single `--schema` value can express. `--schema hc` stays accepted as the spelling Cloud's probe uses and means light + high contrast. -**Nothing is silently ignored.** `--computed`, `--rect` and `--screenshot` are rejected on the jsdom tier rather than no-oping: asking for computed values and getting none back reads as "no styles applied", the opposite of the truth. Likewise `probe:browser` refuses every mode but `render`; `--scheme` / `--hc` are refused on modes that have no scheme (`styles` and `globals` already report every scheme at once — their state maps and `@media` blocks *are* the per-scheme answer); an unknown `--scheme` is rejected by the CLI rather than reaching the token renderer, where it surfaces as a stack trace that reads like a harness bug instead of a typo; and a flag that needs a value says so instead of defaulting to off — including when the value it would have swallowed is the next flag (`--computed --scheme dark`). +**Nothing is silently ignored.** `--computed`, `--rect` and `--screenshot` are rejected on the jsdom tier rather than no-oping: asking for computed values and getting none back reads as "no styles applied", the opposite of the truth. Likewise `probe:browser` refuses every mode but `render`; `--schema` / `--hc` are refused on modes that have no schema (`styles` and `globals` already report every schema at once — their state maps and `@media` blocks *are* the per-schema answer); an unknown `--schema` is rejected by the CLI rather than reaching the token renderer, where it surfaces as a stack trace that reads like a harness bug instead of a typo; and a flag that needs a value says so instead of defaulting to off — including when the value it would have swallowed is the next flag (`--computed --schema dark`). A snippet that does not compile is reported the same way on both tiers: the parse error, with its file, line and code frame. That takes a detour on the browser tier, because Chromium hands the harness only `Failed to fetch dynamically imported module: ` — the real error is in the 500 body it keeps from script, so the harness re-requests the module to read it. When the module itself compiles and the break is in something it *imports*, only Vite's log names the file, so the probe prints that log under the message instead of dropping it. diff --git a/scripts/probe.mjs b/scripts/probe.mjs index 8bb29be94..df048efc6 100644 --- a/scripts/probe.mjs +++ b/scripts/probe.mjs @@ -40,15 +40,15 @@ pnpm probe [options] Modes styles '' CSS for a tasty styles object, under the real ui-kit config (units, recipes, presets, color tokens) - tokens Color tokens: resolved literal values for one scheme, + tokens Color tokens: resolved literal values for one schema, plus the four-variant state maps declares render HTML + the CSS this snippet caused. Snippet on stdin. globals Everything on the page with only mounted: the :root token block, body styles, @font-face, keyframes Options - --scheme tokens: light | dark (default: light) - --hc tokens: resolve the high-contrast variant of --scheme + --schema tokens: light | dark (default: light) + --hc tokens: resolve the high-contrast variant of --schema --filter tokens: only tokens whose name contains this --full-css render: do not subtract the baseline --canonical render: normalise tasty hashes and React IDs (for @@ -59,10 +59,10 @@ Options for 'pnpm probe:browser' only (these need a real browser) --computed [prop...] resolved values — jsdom reports var(...) as text --rect geometry — jsdom sizes everything at 0 --screenshot write a PNG beside the result - --scheme render: drive into light | dark | hc - --hc render: high contrast, composable with --scheme + --schema render: drive into light | dark | hc + --hc render: high contrast, composable with --schema so 'dark --hc' reaches the fourth variant - ('--scheme hc' alone means light + hc) + ('--schema hc' alone means light + hc) Notes The snippet is NOT typechecked — oxc strips types without checking them. @@ -87,8 +87,8 @@ function parseArgs(argv) { * A missing value used to land as `undefined`, which every downstream check * reads as "flag absent" — so `probe:browser render --computed` produced a * plain render and looked like an answer to a computed-values question that was - * never asked. Worse, `--computed --scheme dark` took `--scheme` itself as the - * selector, silently dropping the scheme too. Both are the failure this tool + * never asked. Worse, `--computed --schema dark` took `--schema` itself as the + * selector, silently dropping the schema too. Both are the failure this tool * refuses to commit elsewhere, so they are errors rather than defaults. */ const takeValue = (flag, index) => { @@ -122,8 +122,8 @@ function parseArgs(argv) { options.json = true; } else if (arg === '--hc') { options.highContrast = true; - } else if (arg === '--scheme') { - options.scheme = takeValue(arg, (i += 1)); + } else if (arg === '--schema') { + options.schema = takeValue(arg, (i += 1)); } else if (arg === '--filter') { options.filter = takeValue(arg, (i += 1)); } else if (arg === '--screenshot') { @@ -257,44 +257,44 @@ function main() { } } - // Which modes can actually act on a scheme. Silently ignoring `--scheme dark` - // on a mode that has no scheme is the same failure as silently ignoring + // Which modes can actually act on a schema. Silently ignoring `--schema dark` + // on a mode that has no schema is the same failure as silently ignoring // `--computed`: the answer comes back looking like the light-mode result was // the dark-mode result. - const schemeAware = browser ? ['render'] : ['tokens']; + const schemaAware = browser ? ['render'] : ['tokens']; if ( - (options.scheme || options.highContrast) && - !schemeAware.includes(options.mode) + (options.schema || options.highContrast) && + !schemaAware.includes(options.mode) ) { - const flags = [options.scheme && '--scheme', options.highContrast && '--hc'] + const flags = [options.schema && '--schema', options.highContrast && '--hc'] .filter(Boolean) .join(', '); console.error( `probe ${options.mode}: ${flags} has no effect here.\n` + (options.mode === 'styles' || options.mode === 'globals' - ? `'${options.mode}' reports what tasty generated for every scheme at once — the state ` + - `maps and @media blocks in its output ARE the per-scheme answer.\n` - : `The jsdom tier cannot resolve a scheme's colors at all. Use 'pnpm probe tokens ` + - `--scheme ' for literal values, or 'pnpm probe:browser render --scheme ' ` + + ? `'${options.mode}' reports what tasty generated for every schema at once — the state ` + + `maps and @media blocks in its output ARE the per-schema answer.\n` + : `The jsdom tier cannot resolve a schema's colors at all. Use 'pnpm probe tokens ` + + `--schema ' for literal values, or 'pnpm probe:browser render --schema ' ` + `for what a browser computes.\n`), ); process.exit(1); } - // Validated here rather than left to the palette. An unknown scheme reaches + // Validated here rather than left to the palette. An unknown schema reaches // `renderColorTokens`, misses the variant lookup and throws inside the token // renderer — surfacing as a vitest stack trace about `Object.keys(undefined)`, // which reads as a harness bug rather than as a typo in the flag. - const schemes = browser ? ['light', 'dark', 'hc'] : ['light', 'dark']; + const schemas = browser ? ['light', 'dark', 'hc'] : ['light', 'dark']; - if (options.scheme && !schemes.includes(options.scheme)) { + if (options.schema && !schemas.includes(options.schema)) { console.error( - `probe: unknown --scheme "${options.scheme}". Expected ${schemes.join(' | ')}.` + + `probe: unknown --schema "${options.schema}". Expected ${schemas.join(' | ')}.` + (browser ? `\n('hc' is light + high contrast, the spelling Cube Cloud's probe uses. ` + - `For dark + high contrast, pass '--scheme dark --hc'.)` + `For dark + high contrast, pass '--schema dark --hc'.)` : `\nAdd --hc for the high-contrast variant of either.`), ); process.exit(1); @@ -342,7 +342,7 @@ function main() { if (options.mode === 'tokens') { input.tokenOptions = { - scheme: options.scheme ?? 'light', + schema: options.schema ?? 'light', highContrast: Boolean(options.highContrast), }; } @@ -375,7 +375,7 @@ function main() { // file's contents in through `define` at config-load time, so anything added // after the write never reaches the harness. if (browser) { - input.scheme = options.scheme; + input.schema = options.schema; input.highContrast = Boolean(options.highContrast); input.computed = options.computed; input.computedProps = options.computedProps; @@ -545,7 +545,7 @@ function print(result, options, harnessLog = '') { ([name]) => !options.filter || name.includes(options.filter), ); - console.log(`# resolved — ${result.scheme} (${entries.length} tokens)`); + console.log(`# resolved — ${result.schema} (${entries.length} tokens)`); for (const [name, value] of entries) { // Legacy aliases come back by reference, not resolved — flagged so a // '#surface-text' value is not read as a literal color. diff --git a/src/components/GlobalStyles.tsx b/src/components/GlobalStyles.tsx index 2c56620e2..7b51a9307 100644 --- a/src/components/GlobalStyles.tsx +++ b/src/components/GlobalStyles.tsx @@ -20,7 +20,7 @@ interface GlobalStylesProps { /** * Body styles applied via useGlobalStyles. * - * `fill` and `color` use scheme-aware Glaze tokens so the whole page flips + * `fill` and `color` use schema-aware Glaze tokens so the whole page flips * automatically when `` (or `prefers-color-scheme: dark`) * is active — see `src/tokens/palette.ts` and `src/components/Root.tsx`. */ @@ -93,10 +93,10 @@ const STATIC_CSS = ` /* * Prism syntax highlighting. * - * All token colors come from the scheme-aware Glaze \`code-*\` palette + * All token colors come from the schema-aware Glaze \`code-*\` palette * (defined in src/tokens/palette.ts). Each \`code-*\` token has \`mode: 'auto'\` * with a numeric contrast floor of 4.5 against \`#surface\` so every token - * reads at WCAG AA in light AND dark schemes (AAA in high-contrast). + * reads at WCAG AA in light AND dark schemas (AAA in high-contrast). * Diff insertion / deletion re-use the adaptive \`success-*\` and \`danger-*\` * ramps for both the line bg and the token color. */ @@ -161,7 +161,7 @@ const STATIC_CSS = ` white-space: normal; } - /* Diff (re-uses success/danger ramps; both bg and text adapt to scheme) */ + /* Diff (re-uses success/danger ramps; both bg and text adapt to schema) */ .token.inserted-sign { background-color: var(--success-bg-color); color: var(--success-text-color); diff --git a/src/components/actions/Banner/Banner.tsx b/src/components/actions/Banner/Banner.tsx index 818fceb8f..4c672fb3c 100644 --- a/src/components/actions/Banner/Banner.tsx +++ b/src/components/actions/Banner/Banner.tsx @@ -63,7 +63,7 @@ const BannerElement = tasty(Item, { // shape rather than the borderless default. // // `current` is the right color source: a banner labels itself `#white` in both -// schemes (see `BannerLinkElement` and `*_PRIMARY_STYLES`), so the action mixes +// schemas (see `BannerLinkElement` and `*_PRIMARY_STYLES`), so the action mixes // its chip, border and label from that white — label cr 4.3-5.0 against every // banner theme, with the chip a subtle 1.06 off the banner. That is what an // action on a saturated surface should be: the banner carries the emphasis, the diff --git a/src/components/actions/Menu/styled.tsx b/src/components/actions/Menu/styled.tsx index 400ab013b..2a76e37a6 100644 --- a/src/components/actions/Menu/styled.tsx +++ b/src/components/actions/Menu/styled.tsx @@ -22,7 +22,7 @@ export const StyledMenuWrapper = tasty({ '': '', // Use the design-system `$shadow` token (resolves to // `0 .5x 2x #shadow-md`) so the popover shadow follows the - // scheme-aware Glaze `#shadow-md` color. The previous literal + // schema-aware Glaze `#shadow-md` color. The previous literal // `0px 5px 15px #dark.05` baked in `#dark` (now aliased to the // adaptive `#surface-text`) which inverts to a *light* shadow on // dark surfaces. diff --git a/src/components/content/Item/Item.stories.tsx b/src/components/content/Item/Item.stories.tsx index 310f3f659..6e0c5906b 100644 --- a/src/components/content/Item/Item.stories.tsx +++ b/src/components/content/Item/Item.stories.tsx @@ -1262,7 +1262,7 @@ DescriptionWithTypes.parameters = { docs: { description: { story: - "Demonstrates how descriptions work with all Item type variants. The description inherits the color scheme and adapts to each type's visual style, ensuring consistent readability across different type configurations.", + "Demonstrates how descriptions work with all Item type variants. The description inherits the color schema and adapts to each type's visual style, ensuring consistent readability across different type configurations.", }, }, }; diff --git a/src/components/content/ItemCard/ItemCard.docs.mdx b/src/components/content/ItemCard/ItemCard.docs.mdx index f43ab3cc1..2a795d9fe 100644 --- a/src/components/content/ItemCard/ItemCard.docs.mdx +++ b/src/components/content/ItemCard/ItemCard.docs.mdx @@ -64,7 +64,7 @@ Inherits all styling from `Item`. See [Item documentation](/docs/content-item--d ### Themes -Use the `theme` prop to apply a semantic color scheme: +Use the `theme` prop to apply a semantic color schema: - `default` — Standard card appearance - `success` — Green theme for positive outcomes diff --git a/src/components/content/PrismCode/PrismCode.docs.mdx b/src/components/content/PrismCode/PrismCode.docs.mdx index 56359f9f7..25bb4ed06 100644 --- a/src/components/content/PrismCode/PrismCode.docs.mdx +++ b/src/components/content/PrismCode/PrismCode.docs.mdx @@ -34,10 +34,10 @@ Supports [Base properties](/docs/getting-started-base-properties--docs). ## Theme -Syntax colors are driven by a dedicated, scheme-aware palette of `code-*` +Syntax colors are driven by a dedicated, schema-aware palette of `code-*` tokens. Each token uses a different hue so token classes stay visually separable, and every token meets at least **WCAG AA (4.5:1)** contrast -against `#surface` in light, dark, and high-contrast schemes (AAA in HC). +against `#surface` in light, dark, and high-contrast schemas (AAA in HC). | Token | Hue | Used for | | -------------------- | -------------- | -------------------------------------------------------------- | @@ -51,7 +51,7 @@ against `#surface` in light, dark, and high-contrast schemes (AAA in HC). Diff insertion (`+`) and deletion (`-`) intentionally re-use the existing `#success-*` / `#danger-*` ramps — both the line background tint and the -foreground color adapt to the active scheme. +foreground color adapt to the active schema. ## Examples diff --git a/src/components/data/AGENTS.md b/src/components/data/AGENTS.md index f83617b2d..d9cbfbb5e 100644 --- a/src/components/data/AGENTS.md +++ b/src/components/data/AGENTS.md @@ -21,7 +21,7 @@ The escape hatches that keep this line holdable: are deliberately NOT reserved, because there is no column pin/visibility state behind them to drive. - `column.color` takes a hue rather than a resolved colour, so the kit derives - the ramp and the contrast floor per scheme instead of trusting a caller's + the ramp and the contrast floor per schema instead of trusting a caller's hex pair. See `column-tint.ts` and `src/tokens/color-theme.ts`. - `renderCellMenu`, `column.render`, `column.cellStyles` and `renderRow` cover the rest. diff --git a/src/components/data/DataTable/DataTable.browser.test.tsx b/src/components/data/DataTable/DataTable.browser.test.tsx index 65f6722af..9d2f300ee 100644 --- a/src/components/data/DataTable/DataTable.browser.test.tsx +++ b/src/components/data/DataTable/DataTable.browser.test.tsx @@ -590,7 +590,7 @@ describe('DataTable column menu visibility', () => { * The three things Cube Cloud's ag-grid version gets wrong are the three things * asserted here: banding survives inside a tinted column, the fill stays opaque * so a pinned column still occludes, and the text clears WCAG AA in every - * scheme rather than only the one it was picked in. + * schema rather than only the one it was picked in. */ describe('DataTable column colors', () => { const TINTED: CubeDataTableColumn[] = [ @@ -696,7 +696,7 @@ describe('DataTable column colors', () => { )!; const [even, odd] = cellsOf('revenue'); - // Both bands and the header. Glaze re-solves the tone per scheme against a + // Both bands and the header. Glaze re-solves the tone per schema against a // real contrast floor, which is exactly what a hex pair picked once in // light mode cannot do. for (const element of [even, odd, header]) { diff --git a/src/components/data/DataTable/DataTable.docs.mdx b/src/components/data/DataTable/DataTable.docs.mdx index f50a30e98..f2719789d 100644 --- a/src/components/data/DataTable/DataTable.docs.mdx +++ b/src/components/data/DataTable/DataTable.docs.mdx @@ -504,7 +504,7 @@ palette theme name, any CSS color, a `{ hue, saturation }` seed, or a Every form but the last is *derived*: only the hue and saturation are kept, and the tone ramp plus an AA/AAA text floor are re-solved for light, dark and -high-contrast. A column cannot end up unreadable in a scheme nobody checked. +high-contrast. A column cannot end up unreadable in a schema nobody checked. ```tsx const columns = [ diff --git a/src/components/data/DataTable/DataTable.stories.tsx b/src/components/data/DataTable/DataTable.stories.tsx index 1027c4926..97db0bc01 100644 --- a/src/components/data/DataTable/DataTable.stories.tsx +++ b/src/components/data/DataTable/DataTable.stories.tsx @@ -657,7 +657,7 @@ export const PersistedColumnLayout: Story = { * * A palette theme name (`'success'`, `'note'`, …) is the cheap form. Any CSS * colour works too: only its hue and saturation are kept, and the tone ramp plus - * an AA/AAA text floor are re-solved per scheme by Glaze. Flip the toolbar to + * an AA/AAA text floor are re-solved per schema by Glaze. Flip the toolbar to * dark or high contrast and every column stays readable — which is the point, * and the thing hand-picked hex pairs get wrong. * diff --git a/src/components/data/TableBase/column-tint.ts b/src/components/data/TableBase/column-tint.ts index 2adf4c6da..893edd4f0 100644 --- a/src/components/data/TableBase/column-tint.ts +++ b/src/components/data/TableBase/column-tint.ts @@ -75,7 +75,7 @@ function isThemeName(value: string): value is CubeTableColumnTheme { * * A status theme seeded by a COLOR needs nothing special here: its resolved seed already * carries that color's hue and chroma, so a tinted column follows it for free. Only the - * two numbers are passed on — a runtime tint re-derives its own lightness per scheme, so + * two numbers are passed on — a runtime tint re-derives its own lightness per schema, so * the seed's `color` and `colorTone` have nothing to say to it and are not part of * `ColorThemeConfig`. */ diff --git a/src/components/data/TableBase/types.ts b/src/components/data/TableBase/types.ts index d66f24121..1495ffbdd 100644 --- a/src/components/data/TableBase/types.ts +++ b/src/components/data/TableBase/types.ts @@ -233,7 +233,7 @@ export type CubeTableColumnTheme = * How a column is tinted. * * Every form but the last is *derived*: only a hue and a saturation are kept, and - * the tone ramp plus an `AA`/`AAA` text floor are re-solved per colour scheme. So + * the tone ramp plus an `AA`/`AAA` text floor are re-solved per colour schema. So * a column stays readable in light, dark and high contrast without the caller * checking — which is the part hand-picked hex pairs get wrong. */ @@ -248,7 +248,7 @@ export type CubeTableColumnColor = * Full manual control, as tasty colour strings (`'#note-surface'`, `'#purple.10'`). * * Nothing is derived and nothing is contrast-checked — this is the escape - * hatch, and readability in every scheme becomes the caller's problem. + * hatch, and readability in every schema becomes the caller's problem. * `fillBand` defaults to `fill`, which turns banding off for the column. */ | { fill: string; fillBand?: string; text?: string }; @@ -317,7 +317,7 @@ export interface CubeTableColumn { /** * Tints the column — header, cells and pinned totals — with an adaptive fill - * and a text colour solved to stay readable on it in every scheme. + * and a text colour solved to stay readable on it in every schema. * * Row banding survives: the tint carries its own band one tone step away, so * the stripe still reads down the column instead of being painted over. diff --git a/src/components/fields/Checkbox/Checkbox.tsx b/src/components/fields/Checkbox/Checkbox.tsx index 1c98e7643..b58b039ac 100644 --- a/src/components/fields/Checkbox/Checkbox.tsx +++ b/src/components/fields/Checkbox/Checkbox.tsx @@ -89,7 +89,7 @@ const CheckboxElement = tasty({ // The check / minus icon is always rendered inside the box, so the // default color must be transparent when the checkbox is neither // checked nor indeterminate — otherwise the white stroke shows through - // the dark `#surface` fill in dark schemes (it used to be hidden + // the dark `#surface` fill in dark schemas (it used to be hidden // accidentally by the legacy white-on-white `fill: '#white'`). color: { '': '#clear', diff --git a/src/components/fields/ColorSwatchGroup/ColorSwatchGroup.tsx b/src/components/fields/ColorSwatchGroup/ColorSwatchGroup.tsx index df3666200..c276c03d3 100644 --- a/src/components/fields/ColorSwatchGroup/ColorSwatchGroup.tsx +++ b/src/components/fields/ColorSwatchGroup/ColorSwatchGroup.tsx @@ -32,7 +32,7 @@ export type CubeColorSwatchItem = string | { color: string; label?: string }; * Two rings drawn inside the swatch, as React Aria marks selection. Outer ring * first: earlier shadows paint over later ones, so this reads as 2bw of * `#surface-text` at the edge and 2bw of `#surface` within it. Two tones that - * flip with the scheme stay visible against any color the swatch holds. + * flip with the schema stay visible against any color the swatch holds. */ const SELECTED_RING = 'inset 0 0 0 2bw #surface-text, inset 0 0 0 4bw #surface'; diff --git a/src/components/fields/Picker/Picker.docs.mdx b/src/components/fields/Picker/Picker.docs.mdx index 241cd58c5..a8ba4a6f1 100644 --- a/src/components/fields/Picker/Picker.docs.mdx +++ b/src/components/fields/Picker/Picker.docs.mdx @@ -310,7 +310,7 @@ The trigger button supports various visual styles via the `type` prop: ### Trigger Button Themes -Control the color scheme with the `theme` prop: +Control the color schema with the `theme` prop: - **`default`** — Standard theme (default) - **`danger`** — Red/destructive color (also applied automatically when `isInvalid` is set) diff --git a/src/components/fields/Select/Select.tsx b/src/components/fields/Select/Select.tsx index ed66f7eaa..883c3d06d 100644 --- a/src/components/fields/Select/Select.tsx +++ b/src/components/fields/Select/Select.tsx @@ -472,7 +472,7 @@ function Select( // Forwarded so a `special` trigger tells its action which SURFACE it // sits on: `CURRENT_ITEM_STYLES` only steps up to the stronger alpha // ramp on `theme=special`, and against that dark purple base the - // light-scheme alphas are almost invisible. It travels through + // light-schema alphas are almost invisible. It travels through // context rather than as a prop because the prop is what opts an // action out of `current` back to `clear`, and this one needs to stay // `current` so its label keeps inheriting. diff --git a/src/components/fields/color/color.ts b/src/components/fields/color/color.ts index 811f319f1..e4fe25ccb 100644 --- a/src/components/fields/color/color.ts +++ b/src/components/fields/color/color.ts @@ -361,7 +361,7 @@ export function detectFormat(input: string): ColorFormat | null { * Black or white — whichever the WCAG contrast ratio favors on the given * color. Keeps the popover preview label readable at any lightness. Returns a * literal hex rather than a token, because the answer is measured against this - * exact fill and must not adapt with the color scheme. + * exact fill and must not adapt with the color schema. */ export function getContrastingColor(color: ColorValue): '#000000' | '#ffffff' { const luminance = relativeLuminanceFromLinearRgb( diff --git a/src/components/layout/Board/Board.tsx b/src/components/layout/Board/Board.tsx index 3b269ecbd..89dfeda83 100644 --- a/src/components/layout/Board/Board.tsx +++ b/src/components/layout/Board/Board.tsx @@ -165,7 +165,7 @@ const A11yLayer = tasty({ // rectangle inset by the board's resolved padding and sized to the grid content // so the padding reads as a symmetric frame on every edge. Its size, position // and gradients come from the board's position params via inline `style`. The -// fill uses the `#border` token (via its CSS var) so it adapts to the scheme. +// fill uses the `#border` token (via its CSS var) so it adapts to the schema. const GridOverlayElement = tasty({ qa: 'BoardGridOverlay', styles: { diff --git a/src/components/layout/Board/WidgetHost.tsx b/src/components/layout/Board/WidgetHost.tsx index 75e87f6d3..47d352a95 100644 --- a/src/components/layout/Board/WidgetHost.tsx +++ b/src/components/layout/Board/WidgetHost.tsx @@ -64,7 +64,7 @@ const WidgetElement = tasty({ // ring dimmed, so releasing the pointer only changes the strength of an edge // that is already there — no geometry shift, nothing to re-read. The border // is *not* dimmed with it: `#primary-border` is close to `#border` on a dark - // scheme, so fading it lands below the border a widget already has and the + // schema, so fading it lands below the border a widget already has and the // preview would read as weaker than doing nothing. border: { '': false, @@ -77,7 +77,7 @@ const WidgetElement = tasty({ 'hovered & !card & (draggable | resizing)': '0 0 0 1bw #border', 'pre-selected': '0 0 0 1bw #primary.40', selected: '0 0 0 1bw #primary', - // `$dialog-shadow` uses Glaze `#shadow-lg`, which adapts to dark / high-contrast schemes. + // `$dialog-shadow` uses Glaze `#shadow-lg`, which adapts to dark / high-contrast schemas. 'drag | resizing': '$dialog-shadow', }, outline: { diff --git a/src/components/organisms/StatsCard/StatsCard.docs.mdx b/src/components/organisms/StatsCard/StatsCard.docs.mdx index 20b00f745..821a9b422 100644 --- a/src/components/organisms/StatsCard/StatsCard.docs.mdx +++ b/src/components/organisms/StatsCard/StatsCard.docs.mdx @@ -26,7 +26,7 @@ Extends Card with: - `placeContent` — `space-between` - `gap` — `1x` - `border` — none -- `shadow` — `$shadow` (design-system elevation token, scheme-aware via `#shadow-md`) +- `shadow` — `$shadow` (design-system elevation token, schema-aware via `#shadow-md`) - `padding` — `2.5x` ### Base Properties diff --git a/src/components/other/CubeLogo/CubeLogo.docs.mdx b/src/components/other/CubeLogo/CubeLogo.docs.mdx index ba915ca10..62fcd5e5d 100644 --- a/src/components/other/CubeLogo/CubeLogo.docs.mdx +++ b/src/components/other/CubeLogo/CubeLogo.docs.mdx @@ -43,35 +43,35 @@ Every path is drawn with `currentColor`. Inheriting is the default and usually c ``` -There are **no baked-in brand hexes**, which is what lets the marks work on any surface and in any scheme. +There are **no baked-in brand hexes**, which is what lets the marks work on any surface and in any schema. -## Light and dark schemes +## Light and dark schemas The mark is two different drawings rather than one drawing recoloured — the dark variant is filled differently so it holds its weight against a dark surface. Both are rendered, and the `@dark` state swaps them in CSS. That means the swap costs no re-render, is correct during SSR and before hydration, and needs no prop in the normal case. It also means a snapshot of the DOM always contains both paths; assert on `[data-element="LightMark"]` / `[data-element="DarkMark"]` rather than on a single `path`. -### Pinning a scheme +### Pinning a schema -`@dark` is a *state*, and it is resolved against the **document**. Where the background is known but the document scheme does not describe it, pass `scheme` to pin one mark: +`@dark` is a *state*, and it is resolved against the **document**. Where the background is known but the document schema does not describe it, pass `schema` to pin one mark: ```jsx {/* A fixed-dark panel inside a light app */} - + {/* A themed region — `tokens` overrides token *values*, so it cannot reach a state */} - - + + ``` -Use it only when you know the surface. Omitting it is the right default: a pinned scheme stops following the user's own light/dark preference. +Use it only when you know the surface. Omitting it is the right default: a pinned schema stops following the user's own light/dark preference. ## Properties -- **`scheme`** `'light' | 'dark'` — Pin one mark instead of letting the `@dark` state pick. Omit it unless you know the surface; see [Pinning a scheme](#pinning-a-scheme) +- **`schema`** `'light' | 'dark'` — Pin one mark instead of letting the `@dark` state pick. Omit it unless you know the surface; see [Pinning a schema](#pinning-a-schema) - **`size`** — Maps onto `font-size`. Both axes on `CubeLogo`, height only on `CubeFullLogo` - **`color`** — Any color token. Both marks are drawn with `currentColor`, so this is inherited by default diff --git a/src/components/other/CubeLogo/CubeLogo.stories.tsx b/src/components/other/CubeLogo/CubeLogo.stories.tsx index 0f72e0bf6..57a0eb121 100644 --- a/src/components/other/CubeLogo/CubeLogo.stories.tsx +++ b/src/components/other/CubeLogo/CubeLogo.stories.tsx @@ -70,16 +70,16 @@ export const Colors: Story = { * `@dark` follows the *document*, so a panel whose background does **not** follow * it has to pin the mark itself. Both fills here are fixed (`#white` is a tasty * named color, `#surface-inverse` is `mode: 'fixed'`), so flipping the toolbar - * scheme switch must leave both panels exactly as they are. + * schema switch must leave both panels exactly as they are. */ -export const PinnedScheme: Story = { +export const PinnedSchema: Story = { render: () => ( - + - + ), diff --git a/src/components/other/CubeLogo/CubeLogo.tsx b/src/components/other/CubeLogo/CubeLogo.tsx index 7d014a0c4..d9410726a 100644 --- a/src/components/other/CubeLogo/CubeLogo.tsx +++ b/src/components/other/CubeLogo/CubeLogo.tsx @@ -9,7 +9,7 @@ import { CubeIconProps, Icon } from '../../../icons/Icon'; * The mark is two *different drawings*, not one drawing recoloured: the light * variant is an outlined cube, the dark variant is filled differently so it keeps * its weight against a dark surface. Both are rendered and swapped by the global - * `@dark` state rather than picked in JS, so the logo follows the scheme without a + * `@dark` state rather than picked in JS, so the logo follows the schema without a * re-render and works in SSR. * * Both marks are drawn with `currentColor`, so colour comes from the surrounding @@ -56,7 +56,7 @@ const MarkPaths = () => ( ); -const SCHEME_SWAP = { +const SCHEMA_SWAP = { LightMark: { display: { '': 'block', '@dark': 'none' }, }, @@ -68,13 +68,13 @@ const SCHEME_SWAP = { /** * Pin one mark instead of letting the `@dark` state choose. * - * Needed wherever the background is known but the *document* scheme does not + * Needed wherever the background is known but the *document* schema does not * describe it: a fixed-dark panel in a light app (`#surface-inverse`, the * `special` theme), an exported image, or a themed region — `tokens` overrides * token *values*, and `@dark` is a state, so a region preview cannot reach the * swap on its own. */ -const FORCED_SCHEME = { +const FORCED_SCHEMA = { light: { LightMark: { display: 'block' }, DarkMark: { display: 'none' }, @@ -88,14 +88,14 @@ const FORCED_SCHEME = { export interface CubeLogoProps extends CubeIconProps { /** * Force the light or dark mark. Omit it — the default — to follow the document - * scheme in CSS, which costs no re-render and is correct during SSR. + * schema in CSS, which costs no re-render and is correct during SSR. */ - scheme?: 'light' | 'dark'; + schema?: 'light' | 'dark'; } -/** Caller styles win; a forced scheme only overrides the swap itself. */ -function resolveLogoStyles({ scheme, styles }: CubeLogoProps) { - return scheme ? mergeStyles(FORCED_SCHEME[scheme], styles) : styles; +/** Caller styles win; a forced schema only overrides the swap itself. */ +function resolveLogoStyles({ schema, styles }: CubeLogoProps) { + return schema ? mergeStyles(FORCED_SCHEMA[schema], styles) : styles; } /** @@ -104,14 +104,14 @@ function resolveLogoStyles({ scheme, styles }: CubeLogoProps) { */ const CubeLogoElement = tasty(Icon, { qa: 'CubeLogo', - styles: SCHEME_SWAP, + styles: SCHEMA_SWAP, }); export const CubeLogo = forwardRef(function CubeLogo( props: CubeLogoProps, ref: ForwardedRef, ) { - const { scheme, styles, ...rest } = props; + const { schema, styles, ...rest } = props; return ( , ) { - const { scheme, styles, ...rest } = props; + const { schema, styles, ...rest } = props; return ( ', () => { - it('renders both scheme marks so the swap is CSS-only', () => { + it('renders both schema marks so the swap is CSS-only', () => { renderWithRoot(); const el = screen.getByTestId('CubeLogo'); @@ -20,8 +20,8 @@ describe('', () => { it.each([ ['light', 'block', 'none'], ['dark', 'none', 'block'], - ] as const)('pins the %s mark when asked', (scheme, light, dark) => { - renderWithRoot(); + ] as const)('pins the %s mark when asked', (schema, light, dark) => { + renderWithRoot(); const el = screen.getByTestId('CubeLogo'); const mark = (name: string) => getComputedStyle(el.querySelector(`[data-element="${name}"]`)!).display; @@ -30,10 +30,10 @@ describe('', () => { expect(mark('DarkMark')).toBe(dark); }); - it('keeps scheme off the DOM', () => { - renderWithRoot(); + it('keeps schema off the DOM', () => { + renderWithRoot(); - expect(screen.getByTestId('CubeLogo')).not.toHaveAttribute('scheme'); + expect(screen.getByTestId('CubeLogo')).not.toHaveAttribute('schema'); }); it('drives sizing from the size prop', () => { diff --git a/src/components/other/NoDataIcon/NoDataIcon.stories.tsx b/src/components/other/NoDataIcon/NoDataIcon.stories.tsx index 2ba900ae7..3edfa6242 100644 --- a/src/components/other/NoDataIcon/NoDataIcon.stories.tsx +++ b/src/components/other/NoDataIcon/NoDataIcon.stories.tsx @@ -1,9 +1,9 @@ import { Meta, StoryObj } from '@storybook/react-vite'; import { - withDarkScheme, + withDarkSchema, withHighContrast, -} from '../../../stories/decorators/withColorScheme'; +} from '../../../stories/decorators/withColorSchema'; import { Text } from '../../content/Text'; import { Flow } from '../../layout/Flow'; import { Space } from '../../layout/Space'; @@ -64,11 +64,11 @@ export const WithLoadingAnimation: Story = { /** * The three faces are pinned by a contrast floor against `#surface`, so they - * hold the same separation from the page in every scheme rather than flattening + * hold the same separation from the page in every schema rather than flattening * out in dark — this story and the next are what that claim is checked against. */ -export const DarkScheme: Story = { - decorators: [withDarkScheme], +export const DarkSchema: Story = { + decorators: [withDarkSchema], }; export const HighContrast: Story = { diff --git a/src/components/overlays/Tooltip/Tooltip.docs.mdx b/src/components/overlays/Tooltip/Tooltip.docs.mdx index 4a5747a20..189fb6a14 100644 --- a/src/components/overlays/Tooltip/Tooltip.docs.mdx +++ b/src/components/overlays/Tooltip/Tooltip.docs.mdx @@ -49,7 +49,7 @@ Display container for tooltip content. Has a directional arrow dependent on its - **`showIcon`** `boolean` — Controls whether an icon should be displayed in the tooltip - **`placement`** `PlacementAxis` (default: `'top'`) — Position of the tooltip relative to its trigger. Options: `top`, `bottom`, `left`, `right` - **`isMaterial`** `boolean` — Enables material-style tooltip with auto pointer events -- **`isLight`** `boolean` — Uses light color scheme for the tooltip +- **`isLight`** `boolean` — Uses light color schema for the tooltip - **`isOpen`** `boolean` — Controlled open state of the tooltip - **`defaultOpen`** `boolean` — Whether the tooltip is open by default (uncontrolled) - **`onOpenChange`** `(isOpen: boolean) => void` — Callback when the tooltip open state changes @@ -84,7 +84,7 @@ These properties allow direct style application without using the `styles` prop: The `mods` property on the tooltip accepts the following modifiers you can override: - **`material`** `boolean` — Enables material-style tooltip with pointer events -- **`light`** `boolean` — Uses light color scheme instead of dark +- **`light`** `boolean` — Uses light color schema instead of dark - **`open`** `boolean` — Indicates whether the tooltip is currently visible ## Usage Patterns diff --git a/src/components/overlays/Tooltip/Tooltip.tsx b/src/components/overlays/Tooltip/Tooltip.tsx index f8b27e773..856c3246a 100644 --- a/src/components/overlays/Tooltip/Tooltip.tsx +++ b/src/components/overlays/Tooltip/Tooltip.tsx @@ -28,18 +28,18 @@ export type { AriaTooltipProps }; const TooltipElement = tasty({ styles: { display: 'block', - // The DEFAULT (dark) tooltip is intentionally scheme-invariant: + // The DEFAULT (dark) tooltip is intentionally schema-invariant: // `#surface-inverse.85` + `#white` keep it the "always dark" tooltip in - // light, dark, and high-contrast schemes — matching the legacy `#dark.85` + // light, dark, and high-contrast schemas — matching the legacy `#dark.85` // + `#white` design. // // The `light` variant uses the adaptive surface tokens (`#surface` + - // `#surface-text-soft`) so it follows the page scheme. This restores the + // `#surface-text-soft`) so it follows the page schema. This restores the // softer legacy `#dark-02` look in light mode (cr≈9.2) and stays AA-safe // in dark mode (the previous fixed `#surface-inverse` was much darker // than the legacy `#dark-02`, and pairing the fixed `#white` fill with // the adaptive `#surface-text-soft` text — the LEGACY_ALIASES port of - // `#dark-02` — would collapse to cr≈1.8 in dark schemes; using `#surface` + // `#dark-02` — would collapse to cr≈1.8 in dark schemas; using `#surface` // for the fill keeps both ends of the pair adapting together). fill: { '': '#surface-inverse.85', diff --git a/src/components/overlays/Tooltip/TooltipProvider.docs.mdx b/src/components/overlays/Tooltip/TooltipProvider.docs.mdx index 7a1d9ebf8..0f71335c3 100644 --- a/src/components/overlays/Tooltip/TooltipProvider.docs.mdx +++ b/src/components/overlays/Tooltip/TooltipProvider.docs.mdx @@ -23,7 +23,7 @@ A convenience wrapper that combines `TooltipTrigger` and `Tooltip` into a single - **`delay`** `number` (default: `250`) — The delay time in ms for the tooltip to show up - **`closeDelay`** `number` (default: `500`) — The delay time in ms for the tooltip to close - **`isMaterial`** `boolean` — Enables material-style tooltip with auto pointer events -- **`isLight`** `boolean` — Uses light color scheme for the tooltip +- **`isLight`** `boolean` — Uses light color schema for the tooltip - **`isOpen`** `boolean` — Controlled open state of the tooltip - **`defaultOpen`** `boolean` — Whether the tooltip is open by default (uncontrolled) - **`onOpenChange`** `(isOpen: boolean) => void` — Callback when the tooltip open state changes diff --git a/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx b/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx index 0a1bb5738..d11957ad8 100644 --- a/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx +++ b/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx @@ -1,9 +1,9 @@ import { Meta, StoryFn } from '@storybook/react-vite'; import { - withDarkScheme, + withDarkSchema, withHighContrast, -} from '../../../stories/decorators/withColorScheme'; +} from '../../../stories/decorators/withColorSchema'; import { baseProps } from '../../../stories/lists/baseProps'; import { @@ -32,9 +32,9 @@ Large.args = { size: 'large', }; -export const DarkScheme = Template.bind({}); -DarkScheme.args = {}; -DarkScheme.decorators = [withDarkScheme]; +export const DarkSchema = Template.bind({}); +DarkSchema.args = {}; +DarkSchema.decorators = [withDarkSchema]; export const HighContrast = Template.bind({}); HighContrast.args = {}; diff --git a/src/data/item-themes.test.ts b/src/data/item-themes.test.ts index 35851e9c7..5de706028 100644 --- a/src/data/item-themes.test.ts +++ b/src/data/item-themes.test.ts @@ -48,7 +48,7 @@ describe('ITEM_VARIANTS', () => { * authored number goes up. See `CURRENT_OUTLINE_STYLES`. * * And `current.clear` steps its enabled states through the custom properties - * of `CURRENT_ITEM_RAMP` — one per scheme and surface — while its disabled + * of `CURRENT_ITEM_RAMP` — one per schema and surface — while its disabled * chip is a plain alpha, so the two are not comparable as strings at all. */ const CURRENT = [ diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index 9d49bdbaf..0d5b527be 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -81,8 +81,8 @@ export const DEFAULT_PRIMARY_STYLES: Styles = { // The brand ramp `accent-surface` → `-2` → `-3` gives a monotonically // increasing contrast against `#surface` (cr ≈ 4.5 → 4.8 → 5.2 in light, // similar in dark), so hover and pressed read visibly *darker* than the - // default state in both schemes. Disabled uses the brand-tinted, - // scheme-symmetric chip (`accent-disabled-surface` cr ≈ 1.4 vs surface) + // default state in both schemas. Disabled uses the brand-tinted, + // schema-symmetric chip (`accent-disabled-surface` cr ≈ 1.4 vs surface) // so the muted state stays identifiable as a brand color. fill: { '': '#surface #primary-accent-surface', @@ -786,10 +786,10 @@ export const NOTE_ITEM_STYLES: Styles = { // Every color here resolves to a fixed-mode value (built-in `#white`, the // standalone `#special-*` theme in `src/tokens/palette.ts`, or `transparent`). // `mode: 'fixed'` makes the resolved OKHSL identical in light, dark, and -// high-contrast, so the special theme renders the same regardless of scheme. +// high-contrast, so the special theme renders the same regardless of schema. // The only intentionally adaptive colors are `VALIDATION_STYLES.border` // (`#danger-accent-text` / `#success-accent-text`) — validation state is allowed to follow -// the active scheme. +// the active schema. export const SPECIAL_PRIMARY_STYLES: Styles = { // Focus ring uses `#special-accent-text` — a fixed-mode dark-purple that // stays identical across light/dark/HC, matching the special theme's @@ -838,7 +838,7 @@ export const SPECIAL_OUTLINE_STYLES: Styles = { // alpha distinct sidesteps the collision. // // Focus ring uses the fixed-mode `#special-accent-text` so the indicator - // stays scheme-invariant alongside the rest of the special theme — see + // stays schema-invariant alongside the rest of the special theme — see // `SPECIAL_PRIMARY_STYLES.outline` for the full rationale. outline: { '': '0 #special-accent-text.0', @@ -916,7 +916,7 @@ export const SPECIAL_CLEAR_STYLES: Styles = { // the white-alpha variants are solved for. // // Focus ring uses the fixed-mode `#special-accent-text` so the indicator - // stays scheme-invariant alongside the rest of the special theme — see + // stays schema-invariant alongside the rest of the special theme — see // `SPECIAL_PRIMARY_STYLES.outline` for the full rationale. outline: { '': '0 #special-accent-text.0', @@ -1020,8 +1020,8 @@ export const SPECIAL_ITEM_STYLES: Styles = { // written inline in `fill`. Two reasons: // // 1. Unlike the brand tokens, `#current` alphas do NOT adapt to the color -// scheme, so one ramp cannot serve both. Each step therefore carries the base -// entry for the light scheme and an `@dark` counterpart. +// schema, so one ramp cannot serve both. Each step therefore carries the base +// entry for the light schema and an `@dark` counterpart. // 2. Writing both ramps straight into one `fill` map would put twelve alpha // values in a single state-map, and Tasty's `mergeEntriesByValue` pass // coalesces any two equal value strings into one OR-entry at the group's max @@ -1030,7 +1030,7 @@ export const SPECIAL_ITEM_STYLES: Styles = { // the constraint that `SPECIAL_OUTLINE_STYLES` documents the hard way. // // THE DARK STEPS ARE DERIVED, NOT AUTHORED. The same alpha is not the same step -// in both schemes, and the direction is the opposite of what it looks like: near +// in both schemas, and the direction is the opposite of what it looks like: near // the dark end of the scale a small sRGB move is a large perceptual one, so a // light tint on a dark surface reads STRONGER than the same tint of a dark label // on a light page. Each `@dark` value is therefore solved so its OKHST *tone* @@ -1050,11 +1050,11 @@ export const SPECIAL_ITEM_STYLES: Styles = { // because tasty computes the mix percentage as `parseFloat(alpha) * 100`, and // `.132` lands on `13.200000000000001%` in the emitted CSS. // -// Measured against `#surface` / `#surface-text` in each scheme, which is the only +// Measured against `#surface` / `#surface-text` in each schema, which is the only // tractable calibration: `current` paints from an arbitrary inherited color over // an arbitrary container, so a single ramp cannot be exact for all of them. The // neutral page pair is the common case, and matching it is what keeps the two -// schemes recognisably the same ramp. Re-derive with Glaze's `oklabToOkhsl` + +// schemas recognisably the same ramp. Re-derive with Glaze's `oklabToOkhsl` + // `okhslToOkhst` if the neutral tokens move. // // Note that the label keeps its own margin throughout: the weakest dark step @@ -1289,7 +1289,7 @@ export const CURRENT_OUTLINE_2_STYLES: Styles = { // The states are the second fill layer. There is no lighter or darker sibling // of an arbitrary inherited color to step to — the brand ramps walk // `accent-surface` → `-2` → `-3` — so hover and pressed lay a translucent -// `#black` over the same base instead, which darkens in both schemes and so +// `#black` over the same base instead, which darkens in both schemas and so // keeps the same monotonic direction the brand primaries have. // // The label CANNOT go through `color`. `#current` compiles to the literal @@ -1305,9 +1305,9 @@ export const CURRENT_PRIMARY_STYLES: Styles = { ...CURRENT_FOCUS_RING, // Every other `primary` rims its fill with a lighter sibling // (`accent-surface-border` over `accent-surface`, cr 1.48 against it in both - // schemes). An arbitrary inherited color has no such sibling, so the rim comes + // schemas). An arbitrary inherited color has no such sibling, so the rim comes // from the same token the label does — the one color guaranteed to sit on the - // opposite side of the fill in either scheme, and the one a container can + // opposite side of the fill in either schema, and the one a container can // redirect, so the rim cannot come apart from the label it edges. `.25` // measures cr 1.82 in light and 1.55 in dark against the fill: the brand rim's // presence, a shade more so in light, where `current` has no other edge cue. diff --git a/src/data/themes.ts b/src/data/themes.ts index e83247804..db27bf1bd 100644 --- a/src/data/themes.ts +++ b/src/data/themes.ts @@ -13,7 +13,7 @@ // top. We use `#-accent-surface` (fixed mode) which is // anchored to the fixed-white `#-accent-surface-text` // with `contrast: [4.5, 7]`, so white-text-on-fill is -// guaranteed WCAG AA (4.5) / AAA (7) in every scheme — the +// guaranteed WCAG AA (4.5) / AAA (7) in every schema — the // same brand-pill design `special` uses. (Pre-glaze this slot // resolved to `#-accent-text`, which is anchored to // surface with mode 'auto' for "readable text on surface" — diff --git a/src/stories/AdvancedStates.stories.tsx b/src/stories/AdvancedStates.stories.tsx index d7c802807..a077b16eb 100644 --- a/src/stories/AdvancedStates.stories.tsx +++ b/src/stories/AdvancedStates.stories.tsx @@ -223,10 +223,10 @@ Local definitions take precedence over global ones. }; // ============================================================================= -// Color Scheme (Light/Dark Mode) +// Color Schema (Light/Dark Mode) // ============================================================================= -const ColorSchemeBox = tasty({ +const ColorSchemaBox = tasty({ styles: { padding: '3x', radius: '2r', @@ -245,19 +245,19 @@ const ColorSchemeBox = tasty({ }, }); -export const ColorScheme: StoryObj = { +export const ColorSchema: StoryObj = { render: () => ( - Adapts to system color scheme (prefers-color-scheme) + Adapts to system color schema (prefers-color-scheme) - + Light mode: white background, dark text
Dark mode: dark background, white text
-
+ Change your system appearance settings to see the effect @@ -270,7 +270,7 @@ export const ColorScheme: StoryObj = { Use \`@media(prefers-color-scheme: dark)\` to style based on system color preference: \`\`\`tsx -const ColorSchemeBox = tasty({ +const ColorSchemaBox = tasty({ styles: { fill: { '': '#white', diff --git a/src/stories/Colors.docs.mdx b/src/stories/Colors.docs.mdx index 9501228f9..9ddb67a4b 100644 --- a/src/stories/Colors.docs.mdx +++ b/src/stories/Colors.docs.mdx @@ -8,7 +8,7 @@ import * as ColorsStories from './Colors.stories'; Cube UI Kit color tokens are generated by Glaze from a shared OKHST palette. Every adaptive token includes light, dark, and high-contrast variants. Change -the active scheme in the Storybook toolbar to review each example. +the active schema in the Storybook toolbar to review each example. The colors shown here come from the default seeds. Those seeds — brand hue, saturation, per-status hues — are tunable at runtime; see @@ -93,7 +93,7 @@ token for each syntax role instead of assigning arbitrary palette colors. Borders use the `1bw` design-system width. Shadow geometry comes from `src/tokens/shadows.ts`; the `#shadow-sm`, `#shadow-md`, and `#shadow-lg` -colors adapt with the active scheme. +colors adapt with the active schema. diff --git a/src/stories/Colors.stories.tsx b/src/stories/Colors.stories.tsx index c3a578780..22f9b2c94 100644 --- a/src/stories/Colors.stories.tsx +++ b/src/stories/Colors.stories.tsx @@ -650,7 +650,7 @@ export const CodeSyntax: Story = { render: () => ( @@ -684,7 +684,7 @@ export const BordersAndShadows: Story = { render: () => (
Borders diff --git a/src/stories/Introduction.docs.mdx b/src/stories/Introduction.docs.mdx index 4e7432d73..d46518074 100644 --- a/src/stories/Introduction.docs.mdx +++ b/src/stories/Introduction.docs.mdx @@ -34,7 +34,7 @@ Cube UI Kit is built on two independent open-source projects: high-contrast token sets. Use the [Theme Builder](/story/getting-started-theming--theme-builder) to tune a -palette and preview the result across schemes and contrast levels. +palette and preview the result across schemas and contrast levels. ## Get started diff --git a/src/stories/Theming.docs.mdx b/src/stories/Theming.docs.mdx index 0fd40c20d..bde6b23d1 100644 --- a/src/stories/Theming.docs.mdx +++ b/src/stories/Theming.docs.mdx @@ -9,7 +9,7 @@ import * as ThemingStories from './Theming.stories'; The whole palette is generated by [Glaze](https://github.com/tenphi/glaze) from a handful of seeds, one per **zone**: the accent, the base, and each of the four status themes. Those seeds are tunable at runtime — change one and every token re-resolves, in light, -dark, and high-contrast schemes at once. +dark, and high-contrast schemas at once. Every zone takes the same seed, and it comes in two forms: a **color**, which is usually what you actually have, or the **numbers** behind one. Never both — a zone is seeded one @@ -197,7 +197,7 @@ out the same colour. The page floor is now sized for a shape and the label is gu separately, by a cap on the seed's tone. They are APCA rather than WCAG on purpose. A single WCAG ratio means two different -things depending on the scheme: measured across twelve hues, a fill sitting exactly at +things depending on the schema: measured across twelve hues, a fill sitting exactly at 3:1 comes out at Lc 56 in light but only Lc 23 in dark. That is why light brands kept getting crushed while dark ones sailed through the same rule. **A consequence to state plainly: the emitted fill can sit below WCAG 3:1.** `#0EA5E9` renders at 2.77:1 against @@ -288,7 +288,7 @@ tint's, from `getColorTheme()` — is authored as an offset from the page's rath than as an absolute tone, and that offset is exactly the two tones `tinted` shifts by. Anchored absolutely they would land on the page's own new tone and a `note` banner would stop reading as a banner at all; anchored to the page they keep the -separation the offset was chosen for, in both schemes. They also pick up a little +separation the offset was chosen for, in both schemas. They also pick up a little more chroma there, being further from the extreme — so `tinted` makes a status surface easier to see, not harder. @@ -605,7 +605,7 @@ fine on the page surface can fall apart two panels deep. `setPaletteConfig()` re-themes the whole document, which is no use for a theme picker — you want several themes visible at once, or a dark preview inside a light page. `renderColorTokens()` does that: it resolves the palette for **one** config -and **one** scheme and returns flat literal values, ready to apply to a subtree +and **one** schema and returns flat literal values, ready to apply to a subtree through a tasty `tokens` prop. ```tsx @@ -614,7 +614,7 @@ import { renderColorTokens, tasty } from '@cube-dev/ui-kit'; const Region = tasty({ styles: { fill: '#surface', color: '#surface-text' } }); const preview = useMemo( - () => renderColorTokens({ accent: { hue: 210 }, scheme: 'dark' }), + () => renderColorTokens({ accent: { hue: 210 }, schema: 'dark' }), [], ); @@ -622,15 +622,15 @@ const preview = useMemo( ``` The reason this needs its own API: the document palette emits **state maps** -(`{ '': …, '@dark': …, '@hc': … }`), so a page can only ever show one scheme at a -time — that is what `@dark` means. Collapsing the palette to a chosen scheme +(`{ '': …, '@dark': …, '@hc': … }`), so a page can only ever show one schema at a +time — that is what `@dark` means. Collapsing the palette to a chosen schema removes the conditionality, so several themes can coexist. | Option | Meaning | | --------------------------- | --------------------------------------------------------------------------------------------- | -| every `PaletteConfig` field | merged over the **current** config, so `{ scheme: 'dark' }` previews the active theme in dark | -| `scheme` | `'light'` (default) or `'dark'` | -| `highContrast` | resolve that scheme's high-contrast variant. Default `false` | +| every `PaletteConfig` field | merged over the **current** config, so `{ schema: 'dark' }` previews the active theme in dark | +| `schema` | `'light'` (default) or `'dark'` | +| `highContrast` | resolve that schema's high-contrast variant. Default `false` | Nothing is applied globally — the live palette and the stored config are untouched, so previews are safe to render anywhere. @@ -645,7 +645,7 @@ Notes: - Real components work inside a region. Every color in the kit compiles to a CSS custom property, so overriding those properties on one element re-colors its - whole subtree with no scheme attribute and no second ``. + whole subtree with no schema attribute and no second ``. - The legacy aliases come along **by reference** (`'#dark': '#surface-text'`), and so do the shadow tokens and scrollbar colors, whose values embed a palette color. Tasty re-declares them on the region so each `var()` resolves against the @@ -656,7 +656,7 @@ Notes: - `contrastLevel` works per region, so the whole 0–100 ramp can be shown at once — something a document cannot do, since it is only ever at one level. Level `0` reproduces the normal tier and `100` the high-contrast tier exactly, in both - schemes. `highContrast: true` still returns the genuine high-contrast + schemas. `highContrast: true` still returns the genuine high-contrast resolution at any level below `100`, because the level moves the baseline and the tier escalates from where `'auto'` would put it; at `100` the two coincide and it returns the same colors as the normal variant. @@ -729,7 +729,7 @@ Normal/High-contrast selectors over the [theme builder](#theme-builder). Note the distinction from `contrastLevel`: the level is a config seed the app supplies and can read back, while high contrast is a viewing condition the -document is in. They also pair up — `renderColorTokens({ scheme, highContrast })` +document is in. They also pair up — `renderColorTokens({ schema, highContrast })` takes both, so a region can be previewed in the conditions the page is actually showing. @@ -783,7 +783,7 @@ where a saturated color lands, so `#EF4444` resolves to `#c47069`. Both are visi requested/resolved chips. **Re-seeding costs about 10 ms.** Rebuilding the eight themes and re-solving -~156 tokens across four scheme variants is not free, though it is inside a frame. +~156 tokens across four schema variants is not free, though it is inside a frame. A manual `contrastLevel` costs the same — it runs the same four passes — except at level `100`, which skips the two high-contrast ones because the normal pass already produced those values. diff --git a/src/stories/Theming.stories.tsx b/src/stories/Theming.stories.tsx index 530529582..9f1e3944b 100644 --- a/src/stories/Theming.stories.tsx +++ b/src/stories/Theming.stories.tsx @@ -185,7 +185,7 @@ function useResetOnUnmount() { useEffect(() => resetPaletteConfig, []); } -/** Light-scheme value of a token, straight out of the resolved palette. */ +/** Light-schema value of a token, straight out of the resolved palette. */ function resolvedValue(name: string): string { const token = getPaletteTokens()[name] as Record | undefined; @@ -300,7 +300,7 @@ function ColorResolution({ resolved }: { resolved?: Tokens }) { // The preview's own tokens when there are any, so the chip answers "what did I get in // the variant I am looking at". `resolvedValue` only ever reports the document's - // light scheme, which is the wrong answer inside a dark preview. + // light schema, which is the wrong answer inside a dark preview. const valueOf = (name: string) => (resolved?.[name] as string | undefined) ?? resolvedValue(name); @@ -1179,7 +1179,7 @@ function CodePanel() { with numbers, and a muted palette would wash the whole block out. Tune it on its own with themes.code.saturation. Every token still keeps an AA/AAA floor against the real surface, so it stays - readable in every scheme. + readable in every schema. @@ -1997,7 +1997,7 @@ function ThemePreview({ {/* The mark is two drawings swapped by the `@dark` state, which follows the *document* — tokens override token values, not states, so the preview has to pin the schema explicitly. Its colour is a token and needs no help. */} - + Quarterly Revenue Draft @@ -2148,7 +2148,7 @@ function ThemeBuilderPage() { // The level lives in the palette config now, so it needs no mention here: it // is part of the theme being built, and both of these pick it up. const tokens = useMemo( - () => renderColorTokens({ scheme: schema, highContrast: isHighContrast }), + () => renderColorTokens({ schema: schema, highContrast: isHighContrast }), [schema, isHighContrast, version], ); @@ -2158,7 +2158,7 @@ function ThemeBuilderPage() { const documentTokens = useMemo( () => renderColorTokens({ - scheme: documentSchema, + schema: documentSchema, highContrast: documentHighContrast, }), [documentSchema, documentHighContrast, version], @@ -2246,7 +2246,7 @@ export const Playground: Story = { {DEFAULT_PALETTE_CONFIG.hue}° / saturation{' '} {DEFAULT_PALETTE_CONFIG.saturation}; the hue slider steps by 1°, so use Reset to get back to the exact shipped value. Flip the toolbar - dark-mode switch at any point — both schemes are generated from the + dark-mode switch at any point — both schemas are generated from the same seed. } diff --git a/src/stories/Usage.docs.mdx b/src/stories/Usage.docs.mdx index ce4cca4f0..34a891dd9 100644 --- a/src/stories/Usage.docs.mdx +++ b/src/stories/Usage.docs.mdx @@ -73,7 +73,7 @@ Defined in `src/tokens/sizes.ts`. Used for component heights and icon sizes. ## Shadow Tokens -Defined in `src/tokens/shadows.ts`. Shadow colors (`#shadow-sm`, `#shadow-md`, `#shadow-lg`) are generated by Glaze and adapt automatically to dark / high-contrast schemes. +Defined in `src/tokens/shadows.ts`. Shadow colors (`#shadow-sm`, `#shadow-md`, `#shadow-lg`) are generated by Glaze and adapt automatically to dark / high-contrast schemas. | Token | Value | |-------|-------| @@ -96,26 +96,26 @@ Defined in `src/tokens/layout.ts`. All color tokens use `#name` syntax in styles. Opacity variants use `#name.NN` (e.g. `#surface-text.06` for 6% opacity). -The palette is generated by [`@tenphi/glaze`](https://github.com/tenphi/glaze) (see `src/tokens/palette.ts`). Every color token is emitted as a state map with `light`, `@dark`, and `@hc` (high-contrast) variants, so the entire UI Kit adapts to dark / high-contrast schemes automatically. +The palette is generated by [`@tenphi/glaze`](https://github.com/tenphi/glaze) (see `src/tokens/palette.ts`). Every color token is emitted as a state map with `light`, `@dark`, and `@hc` (high-contrast) variants, so the entire UI Kit adapts to dark / high-contrast schemas automatically. The seeds it is generated from — brand hue, saturation, per-status hues, `pastel`, `contrastLevel` — are tunable at runtime. See [Theming](/docs/getting-started-theming--docs). -### Color Schemes +### Color Schemas The `@dark` and `@hc` predefined states are wired up in `src/components/Root.tsx` to support both an attribute opt-in and the user's system preference: ```html - + - + ``` -When the attribute is absent, the scheme falls back to `@media (prefers-color-scheme: dark)` and `@media (prefers-contrast: more)`. +When the attribute is absent, the schema falls back to `@media (prefers-color-scheme: dark)` and `@media (prefers-contrast: more)`. #### Reading the schema from JS @@ -180,7 +180,7 @@ the token to its own fill, which contrasts with its own text by construction: ```jsx - + ``` @@ -190,7 +190,7 @@ label), because it is a declared token rather than a bare custom property. ### Accent System -Each themed color (default, primary, success, danger, warning, note) provides an `accent-*` family anchored to a fixed white "accent surface text". These tokens stay recognizable across schemes (used for branded buttons, CTAs, etc.): +Each themed color (default, primary, success, danger, warning, note) provides an `accent-*` family anchored to a fixed white "accent surface text". These tokens stay recognizable across schemas (used for branded buttons, CTAs, etc.): | Token (default theme; prefixed in others) | Role | |-------|------| @@ -231,10 +231,10 @@ resetPaletteConfig(); Also available: `getPaletteConfig()`, `subscribePaletteConfig()`, the `usePaletteConfig()` hook, a `` prop, `DEFAULT_PALETTE_CONFIG`, and `invalidatePaletteTokens()` for when you drive `glaze.configure(...)` yourself after mount. The palette is process-global — one palette per process, not per tree or per request. -To theme a *region* instead of the document — a theme preview, or a dark panel inside a light page — use `renderColorTokens()`. It resolves one config and one scheme to flat values you apply via a `tokens` prop, without touching the live palette: +To theme a *region* instead of the document — a theme preview, or a dark panel inside a light page — use `renderColorTokens()`. It resolves one config and one schema to flat values you apply via a `tokens` prop, without touching the live palette: ```tsx - + ``` diff --git a/src/stories/decorators/colorSchemeBridge.ts b/src/stories/decorators/colorSchemaBridge.ts similarity index 65% rename from src/stories/decorators/colorSchemeBridge.ts rename to src/stories/decorators/colorSchemaBridge.ts index e00cd57fd..c0b920107 100644 --- a/src/stories/decorators/colorSchemeBridge.ts +++ b/src/stories/decorators/colorSchemaBridge.ts @@ -5,24 +5,24 @@ * Two writers route through here: * - The `storybook-dark-mode` addon's toolbar toggle (via the * `DARK_MODE` channel event in `.storybook/preview.jsx`). - * - The per-story `withColorScheme` decorator in this folder. + * - The per-story `withColorSchema` decorator in this folder. * - * `overrideScheme` always wins over `toolbarScheme`, so a story explicitly + * `overrideSchema` always wins over `toolbarSchema`, so a story explicitly * forced into dark/light cannot be clobbered by the addon's async init. * * NOTE: lives under `src/stories/decorators/` (not `.storybook/`) so the * decorator can import it without crossing the TypeScript include boundary. */ -export type Scheme = 'light' | 'dark'; +export type Schema = 'light' | 'dark'; -let toolbarScheme: Scheme | null = null; -let overrideScheme: Scheme | null = null; +let toolbarSchema: Schema | null = null; +let overrideSchema: Schema | null = null; const apply = (): void => { if (typeof document === 'undefined') return; - const next = overrideScheme ?? toolbarScheme; + const next = overrideSchema ?? toolbarSchema; if (next == null) { document.documentElement.removeAttribute('data-schema'); @@ -32,16 +32,16 @@ const apply = (): void => { }; /** Set by the `storybook-dark-mode` channel listener in `preview.jsx`. */ -export const setToolbarScheme = (scheme: Scheme | null): void => { - toolbarScheme = scheme; +export const setToolbarSchema = (schema: Schema | null): void => { + toolbarSchema = schema; apply(); }; /** - * Set by the per-story `withColorScheme` decorator. + * Set by the per-story `withColorSchema` decorator. * Pass `null` to release the override and fall back to the toolbar value. */ -export const setSchemeOverride = (scheme: Scheme | null): void => { - overrideScheme = scheme; +export const setSchemaOverride = (schema: Schema | null): void => { + overrideSchema = schema; apply(); }; diff --git a/src/stories/decorators/withColorScheme.tsx b/src/stories/decorators/withColorSchema.tsx similarity index 69% rename from src/stories/decorators/withColorScheme.tsx rename to src/stories/decorators/withColorSchema.tsx index f32a6651f..1c5eab6f1 100644 --- a/src/stories/decorators/withColorScheme.tsx +++ b/src/stories/decorators/withColorSchema.tsx @@ -1,6 +1,6 @@ import { useLayoutEffect, useRef } from 'react'; -import { setSchemeOverride } from './colorSchemeBridge'; +import { setSchemaOverride } from './colorSchemaBridge'; import type { ReactElement } from 'react'; @@ -17,10 +17,10 @@ type StoryDecorator = ( /** * Drives the `data-schema` attribute on ``. - * - `'dark'` / `'light'` — force the corresponding scheme + * - `'dark'` / `'light'` — force the corresponding schema * - `'auto'` — clear the attribute and fall back to `prefers-color-scheme` */ -export type ColorScheme = 'light' | 'dark' | 'auto'; +export type ColorSchema = 'light' | 'dark' | 'auto'; /** * Drives the `data-contrast` attribute on ``. @@ -29,15 +29,15 @@ export type ColorScheme = 'light' | 'dark' | 'auto'; */ export type ContrastMode = 'normal' | 'high' | 'auto'; -export interface WithColorSchemeOptions { - /** Color scheme applied via ``. */ - scheme?: ColorScheme; +export interface WithColorSchemaOptions { + /** Color schema applied via ``. */ + schema?: ColorSchema; /** Contrast mode applied via ``. */ contrast?: ContrastMode; } /** - * Storybook decorator that switches a story into a different color scheme by + * Storybook decorator that switches a story into a different color schema by * driving the `data-schema` / `data-contrast` attributes on `` (the same * attributes the global `@dark` / `@hc` predefined states resolve against — * see `src/components/Root.tsx`). @@ -46,25 +46,25 @@ export interface WithColorSchemeOptions { * so flipping `data-schema` is enough to repaint the whole story canvas — no * extra wrappers needed. * - * `data-schema` writes go through `colorSchemeBridge` so the per-story + * `data-schema` writes go through `colorSchemaBridge` so the per-story * override always wins over the `storybook-dark-mode` toolbar (which writes * the same attribute via the channel listener in `.storybook/preview.jsx`). * `data-contrast` is unmanaged by the addon and stays a direct DOM write. * - * Implemented synchronously in `useLayoutEffect`, so the scheme switches + * Implemented synchronously in `useLayoutEffect`, so the schema switches * before paint (no flash). The bridge restores the toolbar value on unmount, * and `data-contrast` restores its prior literal value. * * @example * export const DarkVariant = MyTemplate.bind({}); - * DarkVariant.decorators = [withColorScheme({ scheme: 'dark' })]; + * DarkVariant.decorators = [withColorSchema({ schema: 'dark' })]; */ -export const withColorScheme = ( - options: WithColorSchemeOptions = {}, +export const withColorSchema = ( + options: WithColorSchemaOptions = {}, ): StoryDecorator => { - const { scheme, contrast } = options; + const { schema, contrast } = options; - const ColorSchemeDecorator: StoryDecorator = (Story) => { + const ColorSchemaDecorator: StoryDecorator = (Story) => { const previousContrastRef = useRef(null); useLayoutEffect(() => { @@ -72,10 +72,10 @@ export const withColorScheme = ( previousContrastRef.current = html.getAttribute('data-contrast'); - if (scheme === 'auto') { - setSchemeOverride(null); - } else if (scheme) { - setSchemeOverride(scheme); + if (schema === 'auto') { + setSchemaOverride(null); + } else if (schema) { + setSchemaOverride(schema); } if (contrast === 'auto') { @@ -88,8 +88,8 @@ export const withColorScheme = ( } return () => { - if (scheme) { - setSchemeOverride(null); + if (schema) { + setSchemaOverride(null); } if (contrast) { @@ -107,18 +107,18 @@ export const withColorScheme = ( return ; }; - (ColorSchemeDecorator as { displayName?: string }).displayName = - `WithColorScheme(${scheme ?? 'auto'},${contrast ?? 'auto'})`; + (ColorSchemaDecorator as { displayName?: string }).displayName = + `WithColorSchema(${schema ?? 'auto'},${contrast ?? 'auto'})`; - return ColorSchemeDecorator; + return ColorSchemaDecorator; }; -/** Convenience preset: switches the story into the dark scheme. */ -export const withDarkScheme: StoryDecorator = withColorScheme({ - scheme: 'dark', +/** Convenience preset: switches the story into the dark schema. */ +export const withDarkSchema: StoryDecorator = withColorSchema({ + schema: 'dark', }); -/** Convenience preset: switches the story into the high-contrast scheme. */ -export const withHighContrast: StoryDecorator = withColorScheme({ +/** Convenience preset: switches the story into the high-contrast schema. */ +export const withHighContrast: StoryDecorator = withColorSchema({ contrast: 'high', }); diff --git a/src/test/probe/config-guard.ts b/src/test/probe/config-guard.ts index fe58d1c60..514e27572 100644 --- a/src/test/probe/config-guard.ts +++ b/src/test/probe/config-guard.ts @@ -36,7 +36,7 @@ export function assertConfigApplied(): void { if (!states || !('@dark' in states)) { throw new Error( 'Probe harness: the `@dark` / `@hc` predefined states are missing, so ' + - "``'s module body did not run. Scheme-keyed styles would resolve " + + "``'s module body did not run. Schema-keyed styles would resolve " + 'wrong. Check what the harness imports before `components/Root`.', ); } diff --git a/src/test/probe/harness.browser.probe.tsx b/src/test/probe/harness.browser.probe.tsx index e4bb5698e..890793ca1 100644 --- a/src/test/probe/harness.browser.probe.tsx +++ b/src/test/probe/harness.browser.probe.tsx @@ -36,32 +36,32 @@ const input = JSON.parse(__PROBE_INPUT__) as ProbeInput & { computedProps?: string[]; rect?: string; screenshot?: boolean; - scheme?: 'light' | 'dark' | 'hc'; + schema?: 'light' | 'dark' | 'hc'; highContrast?: boolean; }; /** - * Drive the scheme the way a host app does — through the attributes on `` + * Drive the schema the way a host app does — through the attributes on `` * that the `@dark` / `@hc` predefined states resolve against (see `Root.tsx`). * Setting a token by hand would prove nothing about how the real cascade * behaves. * * The two axes are independent, so they are separate parameters: `@hc` is a - * contrast attribute that composes with either schema. `--scheme hc` stays + * contrast attribute that composes with either schema. `--schema hc` stays * accepted as the spelling Cube Cloud's probe uses, where it means light + high * contrast — but it cannot express dark + high contrast, which is a real palette * variant, so `--hc` is the flag that reaches all four. */ -function applyScheme(scheme: string | undefined, highContrast: boolean): void { +function applySchema(schema: string | undefined, highContrast: boolean): void { const root = document.documentElement; - if (scheme === 'dark') { + if (schema === 'dark') { root.setAttribute('data-schema', 'dark'); } - if (scheme === 'light' || scheme === 'hc') { + if (schema === 'light' || schema === 'hc') { root.setAttribute('data-schema', 'light'); } - if (scheme === 'hc' || highContrast) { + if (schema === 'hc' || highContrast) { root.setAttribute('data-contrast', 'high'); } } @@ -184,7 +184,7 @@ it('probe:browser', async () => { // pixel geometry rather than as visibly missing output. assertConfigApplied(); - applyScheme(input.scheme, Boolean(input.highContrast)); + applySchema(input.schema, Boolean(input.highContrast)); const view = render({null}); const baseline = captureCss(view.baseElement); @@ -258,7 +258,7 @@ it('probe:browser', async () => { const scope = view.baseElement.querySelector('[data-probe-scope]'); // `--canonical` applies on both tiers. It is what makes two runs comparable - // byte-for-byte, and a browser run is exactly where you would diff one scheme + // byte-for-byte, and a browser run is exactly where you would diff one schema // or viewport against another — accepting the flag and returning raw hashes // and `useId` counters would defeat the comparison silently. const normalise = (text: string) => @@ -337,8 +337,8 @@ it('probe:browser', async () => { mode: input.mode, tier: 'browser', ok: true, - scheme: `${input.scheme ?? 'default'}${ - input.highContrast && input.scheme !== 'hc' ? ' + high contrast' : '' + schema: `${input.schema ?? 'default'}${ + input.highContrast && input.schema !== 'hc' ? ' + high contrast' : '' }`, html, portalHtml, diff --git a/src/test/probe/harness.probe.tsx b/src/test/probe/harness.probe.tsx index dc99ef1b2..7275e1475 100644 --- a/src/test/probe/harness.probe.tsx +++ b/src/test/probe/harness.probe.tsx @@ -64,7 +64,7 @@ it('probe', async () => { if (input.mode === 'tokens') { writeResult(input, { mode: 'tokens', - scheme: `${input.tokenOptions?.scheme ?? 'light'}${ + schema: `${input.tokenOptions?.schema ?? 'light'}${ input.tokenOptions?.highContrast ? ' + high contrast' : '' }`, // Flat literal values for one variant. The legacy aliases come back BY @@ -72,7 +72,7 @@ it('probe', async () => { // preview re-resolves them against its own tokens — so they are reported // separately rather than being mistaken for resolved colors. resolved: renderColorTokens(input.tokenOptions), - // The same palette as tasty state maps keyed by scheme + // The same palette as tasty state maps keyed by schema // (`'' | '@dark' | '@hc' | '@dark & @hc'`), which is the four-variant view // a palette change has to be diffed across. A different shape from the // above, so it is labelled rather than merged. diff --git a/src/test/probe/io.ts b/src/test/probe/io.ts index c06b52e55..18d6c7f4d 100644 --- a/src/test/probe/io.ts +++ b/src/test/probe/io.ts @@ -43,7 +43,7 @@ export interface ProbeInput { */ snippetUrl?: string; styles?: Styles; - tokenOptions?: { scheme?: 'light' | 'dark'; highContrast?: boolean }; + tokenOptions?: { schema?: 'light' | 'dark'; highContrast?: boolean }; fullCss?: boolean; canonical?: boolean; } diff --git a/src/tokens/color-theme.test.ts b/src/tokens/color-theme.test.ts index 1eebc0167..7df39bbd3 100644 --- a/src/tokens/color-theme.test.ts +++ b/src/tokens/color-theme.test.ts @@ -13,7 +13,7 @@ import { setPaletteConfig, } from './palette-config'; -const SCHEMES = ['', '@dark', '@hc', '@dark & @hc'] as const; +const SCHEMAS = ['', '@dark', '@hc', '@dark & @hc'] as const; function wcag(background: string, foreground: string): number { const luminance = (value: string) => { @@ -61,7 +61,7 @@ describe('getColorTheme', () => { ); }); - it('covers all four scheme variants', () => { + it('covers all four schema variants', () => { const theme = getColorTheme({ hue: 200 }); for (const token of Object.values(theme.colors)) { @@ -71,12 +71,12 @@ describe('getColorTheme', () => { // `@media(prefers-color-scheme: dark)` keys and no high-contrast tier at // all — a silent failure, since the light values still render. expect(Object.keys(theme.tokens[token] as object).sort()).toEqual( - [...SCHEMES].sort(), + [...SCHEMAS].sort(), ); } }); - it('solves the text contrast in every scheme', () => { + it('solves the text contrast in every schema', () => { const theme = getColorTheme({ hue: 200 }); const band = theme.tokens[theme.colors['surface-2']] as Record< string, @@ -87,11 +87,11 @@ describe('getColorTheme', () => { string >; - for (const scheme of SCHEMES) { + for (const schema of SCHEMAS) { // The Cloud defect this exists to fix: there, contrast was solved once // against a white surface at pick time and never re-checked, so a dark - // scheme could invert both sides into an unreadable pair. - expect(wcag(band[scheme], text[scheme])).toBeGreaterThanOrEqual(4.5); + // schema could invert both sides into an unreadable pair. + expect(wcag(band[schema], text[schema])).toBeGreaterThanOrEqual(4.5); } }); @@ -104,10 +104,10 @@ describe('getColorTheme', () => { >; // The floor is solved against `surface-2`, which is the tighter of the two - // bands in both schemes — so `surface` clears it for free. That is the whole + // bands in both schemas — so `surface` clears it for free. That is the whole // reason the text is anchored there rather than to `surface`. - for (const scheme of SCHEMES) { - expect(wcag(base[scheme], text[scheme])).toBeGreaterThanOrEqual(4.5); + for (const schema of SCHEMAS) { + expect(wcag(base[schema], text[schema])).toBeGreaterThanOrEqual(4.5); } }); @@ -122,8 +122,8 @@ describe('getColorTheme', () => { // Banding that resolves to the same colour is not banding. This is also what // keeps the two out of tasty's value-coalescing trap when they end up in one // state map. - for (const scheme of SCHEMES) { - expect(band[scheme]).not.toBe(base[scheme]); + for (const schema of SCHEMAS) { + expect(band[schema]).not.toBe(base[schema]); } }); diff --git a/src/tokens/color-theme.ts b/src/tokens/color-theme.ts index 2b7734ded..fbcd14cf8 100644 --- a/src/tokens/color-theme.ts +++ b/src/tokens/color-theme.ts @@ -28,7 +28,7 @@ export interface ColorThemeConfig { * `okhsl()`, `okhst()`, `oklch()`. * * From a colour only the HUE and SATURATION are taken; the lightness is - * discarded and re-derived per scheme. That is what makes the result adaptive + * discarded and re-derived per schema. That is what makes the result adaptive * rather than a value that happens to work in one theme. */ hue: number | string; @@ -132,7 +132,7 @@ function stableStringify(value: unknown): string { * ``` * * The tone {@link colorSeed} also reads is dropped here on purpose: a tint theme - * re-derives its lightness per scheme, which is what makes it adaptive. The palette's + * re-derives its lightness per schema, which is what makes it adaptive. The palette's * The palette's accent zone keeps the tone, because a brand fill has to *be* the * colour. * diff --git a/src/tokens/colors.ts b/src/tokens/colors.ts index 016b10df7..e884c8f0e 100644 --- a/src/tokens/colors.ts +++ b/src/tokens/colors.ts @@ -36,21 +36,21 @@ const LEGACY_ALIASES: Styles = { '#dark-05': '#border', // Fixed-mode counterpart to `#dark`. Resolves to the same L≈12 surface - // but uses Glaze `mode: 'fixed'` so it does NOT invert in dark schemes. + // but uses Glaze `mode: 'fixed'` so it does NOT invert in dark schemas. // Use this whenever the design intentionally pins a dark color regardless - // of scheme. Points at `#special-surface` (`mode: 'fixed'`, L=12), + // of schema. Points at `#special-surface` (`mode: 'fixed'`, L=12), // emitted by the standalone `specialTheme` in `palette.ts` — the canonical // source of fixed-mode color tokens for `special`-variant components. '#fixed-dark': '#special-surface', // Fixed-mode counterpart to `#primary-text`. `#primary-text` is anchored // to `surface` with `mode: 'auto'`, so it flips to a *light* purple in - // dark schemes (correct on body content, which also inverts). When the + // dark schemas (correct on body content, which also inverts). When the // local fill is a fixed color instead (an always-white pill, etc.), the // adaptive text loses contrast (light purple on white) in dark mode. // Points at `#special-accent-text` (`mode: 'fixed'`, cr 6–8.5 vs fixed // white) — a dark purple readable on a white surface that stays put - // across schemes. + // across schemas. '#fixed-primary-text': '#special-accent-text', // ---- Misc neutral ---- @@ -60,7 +60,7 @@ const LEGACY_ALIASES: Styles = { '#dark-bg': '#surface-2', '#clear': 'transparent', - // Pink: independent hue, scheme-static (no Glaze adaptation). Kept as a raw + // Pink: independent hue, schema-static (no Glaze adaptation). Kept as a raw // literal rather than folded into a theme — nothing in the palette emits this // hue as a standalone token, and it is a documented public alias (see // `Usage.docs.mdx`, `tasty.config.ts`, and the `pink` key in @@ -70,7 +70,7 @@ const LEGACY_ALIASES: Styles = { // ---- Disabled state aliases ---- // `#disabled-surface` and `#disabled-surface-text` are emitted directly by - // the Glaze palette (`palette.ts`) as scheme-symmetric, contrast-driven + // the Glaze palette (`palette.ts`) as schema-symmetric, contrast-driven // tokens — no alias needed here. `#disabled` stays as a brand-tinted // backwards-compat anchor for the per-theme `#-disabled` aliases below. @@ -214,12 +214,12 @@ const COLOR_DEPENDENT_TOKENS: Styles = { }; /** - * Render every UI Kit color for one config and one scheme, as flat literal + * Render every UI Kit color for one config and one schema, as flat literal * values ready to apply to a **region** via a tasty `tokens` prop. * * ```tsx * const preview = useMemo( - * () => renderColorTokens({ hue: 210, scheme: 'dark' }), + * () => renderColorTokens({ hue: 210, schema: 'dark' }), * [], * ); * @@ -229,7 +229,7 @@ const COLOR_DEPENDENT_TOKENS: Styles = { * ``` * * Config fields merge over the *current* palette config, so - * `renderColorTokens({ scheme: 'dark' })` previews the active theme in dark + * `renderColorTokens({ schema: 'dark' })` previews the active theme in dark * without restating it. Nothing is applied globally — the live palette is * untouched. * diff --git a/src/tokens/palette-config.ts b/src/tokens/palette-config.ts index 627fea528..517a71c2c 100644 --- a/src/tokens/palette-config.ts +++ b/src/tokens/palette-config.ts @@ -177,7 +177,7 @@ export interface PaletteConfig { * and no further. * * Those floors are APCA, not WCAG, and the difference is deliberate: one WCAG ratio - * means two very different things by scheme (3:1 measures Lc 56 in light but only + * means two very different things by schema (3:1 measures Lc 56 in light but only * Lc 23 in dark), which crushed light brands while letting dark ones through. A * consequence worth stating plainly — **the emitted fill can sit below WCAG 3:1**. * `#0EA5E9` renders at 2.77:1 against a white page and is correct at that value; the diff --git a/src/tokens/palette.test.ts b/src/tokens/palette.test.ts index 5c5f70695..6bd6928e9 100644 --- a/src/tokens/palette.test.ts +++ b/src/tokens/palette.test.ts @@ -85,7 +85,7 @@ function dumpTokens(tokens: Styles): Record { return out; } -/** Pick one scheme variant out of every token's state map. */ +/** Pick one schema variant out of every token's state map. */ function variant(tokens: Styles, state: string): Record { const out: Record = {}; @@ -142,7 +142,7 @@ function hexOf(value: string): string { * * Reading the tone rather than the color is what lets a test say "this landed where the * seed asked" for a token whose hue and chroma are settled but whose lightness went - * through a scheme window. + * through a schema window. */ function toneOf(value: string): number { return ( @@ -238,7 +238,7 @@ describe('palette tokens', () => { expect(dump).toMatchSnapshot(); }); - it('emits every token in all four scheme variants by default', () => { + it('emits every token in all four schema variants by default', () => { expect(statesOf(getPaletteTokens())).toEqual([ '', '@dark', @@ -249,14 +249,14 @@ describe('palette tokens', () => { /** * The cube-face ramp's whole point is that a `LoadingAnimation` reads with the - * same weight in every scheme. It is stated as a WCAG floor against `surface` - * rather than as a tone delta precisely because the dark scheme resolves a + * same weight in every schema. It is stated as a WCAG floor against `surface` + * rather than as a tone delta precisely because the dark schema resolves a * delta inside the `darkTone` window and flattened the ramp to ~75% of its * light span. The snapshot above pins the emitted colors; this pins the * property that made them those colors, so a regression reads as "dark went * flat again" rather than as three changed oklch strings. */ - it('holds the cube faces at one contrast ratio in every scheme', () => { + it('holds the cube faces at one contrast ratio in every schema', () => { const tokens = getPaletteTokens(); const FLOORS = [1.2, 1.65, 2.4]; const HC_FLOORS = [1.35, 2.1, 3.2]; @@ -745,7 +745,7 @@ describe('setPaletteConfig', () => { const tuned = dumpTokens(getPaletteTokens()); - // Bit-identical, in all four scheme variants: nothing but the code + // Bit-identical, in all four schema variants: nothing but the code // saturation reaches these. expect(CODE_TOKENS.map((name) => tuned[name])).toEqual(before); expect(getCodeTheme().getConfig().pastel).toBe(false); @@ -1048,27 +1048,27 @@ describe('renderPaletteTokens', () => { return out; }; - expect({ ...renderPaletteTokens({ scheme: 'light' }) }).toEqual( + expect({ ...renderPaletteTokens({ schema: 'light' }) }).toEqual( variantOf(''), ); - expect({ ...renderPaletteTokens({ scheme: 'dark' }) }).toEqual( + expect({ ...renderPaletteTokens({ schema: 'dark' }) }).toEqual( variantOf('@dark'), ); expect({ - ...renderPaletteTokens({ scheme: 'light', highContrast: true }), + ...renderPaletteTokens({ schema: 'light', highContrast: true }), }).toEqual(variantOf('@hc')); expect({ - ...renderPaletteTokens({ scheme: 'dark', highContrast: true }), + ...renderPaletteTokens({ schema: 'dark', highContrast: true }), }).toEqual(variantOf('@dark & @hc')); }); it('renders a config the app is not using, without applying it', () => { const before = dumpTokens(getPaletteTokens()); - const baseline = renderPaletteTokens({ scheme: 'light' }); + const baseline = renderPaletteTokens({ schema: 'light' }); const preview = renderPaletteTokens({ accent: { hue: 30 }, - scheme: 'light', + schema: 'light', }); expect(preview['#accent-surface']).not.toBe(baseline['#accent-surface']); @@ -1080,13 +1080,13 @@ describe('renderPaletteTokens', () => { it('merges over the current config rather than the shipped defaults', () => { setPaletteConfig({ accent: { saturation: 20 } }); - expect(renderPaletteTokens({ scheme: 'light' })['#accent-surface']).toBe( + expect(renderPaletteTokens({ schema: 'light' })['#accent-surface']).toBe( getPaletteTokens()['#accent-surface']?.[''], ); }); it('is independent of the ambient contrast mode', () => { - const hc = renderPaletteTokens({ scheme: 'light', highContrast: true }); + const hc = renderPaletteTokens({ schema: 'light', highContrast: true }); // A manual global level suppresses high-contrast output in Glaze exports; // a preview must not inherit that. @@ -1095,7 +1095,7 @@ describe('renderPaletteTokens', () => { expect( renderPaletteTokens({ contrastLevel: 'auto', - scheme: 'light', + schema: 'light', highContrast: true, }), ).toEqual(hc); @@ -1107,7 +1107,7 @@ describe('renderPaletteTokens', () => { expect(glaze.getConfig().contrastLevel).toBe(60); - renderPaletteTokens({ scheme: 'dark' }); + renderPaletteTokens({ schema: 'dark' }); expect(glaze.getConfig().contrastLevel).toBe(60); }); @@ -1116,15 +1116,15 @@ describe('renderPaletteTokens', () => { // A region asking for high contrast at a mid level gets the genuine // high-contrast resolution, not a copy of its own normal variant — the level // moves the baseline, the tier escalates from wherever `'auto'` would put it. - const normal = renderPaletteTokens({ contrastLevel: 40, scheme: 'light' }); + const normal = renderPaletteTokens({ contrastLevel: 40, schema: 'light' }); const hc = renderPaletteTokens({ contrastLevel: 40, - scheme: 'light', + schema: 'light', highContrast: true, }); const autoHc = renderPaletteTokens({ contrastLevel: 'auto', - scheme: 'light', + schema: 'light', highContrast: true, }); @@ -1133,10 +1133,10 @@ describe('renderPaletteTokens', () => { }); it('emits one tier at level 100, where the two coincide', () => { - const normal = renderPaletteTokens({ contrastLevel: 100, scheme: 'light' }); + const normal = renderPaletteTokens({ contrastLevel: 100, schema: 'light' }); const hc = renderPaletteTokens({ contrastLevel: 100, - scheme: 'light', + schema: 'light', highContrast: true, }); @@ -1147,16 +1147,16 @@ describe('renderPaletteTokens', () => { // Glaze guarantees level 0 === normal and level 100 === high contrast, bit // for bit. If the level did not reach the themes in the region path, these // would silently all be the same. - for (const scheme of ['light', 'dark'] as const) { - expect(renderPaletteTokens({ contrastLevel: 0, scheme: scheme })).toEqual( - renderPaletteTokens({ contrastLevel: 'auto', scheme: scheme }), + for (const schema of ['light', 'dark'] as const) { + expect(renderPaletteTokens({ contrastLevel: 0, schema: schema })).toEqual( + renderPaletteTokens({ contrastLevel: 'auto', schema: schema }), ); expect( - renderPaletteTokens({ contrastLevel: 100, scheme: scheme }), + renderPaletteTokens({ contrastLevel: 100, schema: schema }), ).toEqual( renderPaletteTokens({ contrastLevel: 'auto', - scheme: scheme, + schema: schema, highContrast: true, }), ); @@ -1165,7 +1165,7 @@ describe('renderPaletteTokens', () => { it('interpolates between the tiers at intermediate levels', () => { const at = (contrastLevel: number | 'auto') => - renderPaletteTokens({ contrastLevel: contrastLevel, scheme: 'light' }); + renderPaletteTokens({ contrastLevel: contrastLevel, schema: 'light' }); const low = at(0); const mid = at(50); @@ -1188,10 +1188,10 @@ describe('renderPaletteTokens', () => { it('applies the level to every theme in the palette, not just the default', () => { const auto = renderPaletteTokens({ contrastLevel: 'auto', - scheme: 'light', + schema: 'light', highContrast: true, }); - const full = renderPaletteTokens({ contrastLevel: 100, scheme: 'light' }); + const full = renderPaletteTokens({ contrastLevel: 100, schema: 'light' }); // `special` is a standalone theme and the status themes are `extend()` // children — the level has to reach all of them. @@ -1213,7 +1213,7 @@ describe('renderColorTokens', () => { }); it('adds the legacy aliases by reference, not resolved', () => { - const rendered = renderColorTokens({ scheme: 'dark' }); + const rendered = renderColorTokens({ schema: 'dark' }); // Resolved palette value… expect(rendered['#surface-text']).toMatch(/^oklch\(/); @@ -1233,7 +1233,7 @@ describe('renderColorTokens', () => { }); it('re-declares the tokens whose values embed a palette color', () => { - const rendered = renderColorTokens({ scheme: 'dark' }); + const rendered = renderColorTokens({ schema: 'dark' }); // Declared on , so CSS would have frozen the outer theme's color into // them; they have to ride along by reference to re-resolve in the region. @@ -1291,12 +1291,12 @@ describe('interop with a host driving glaze directly', () => { // only input — `buildPalette` also reads Glaze's global config. Without the // version in that key, a region preview keeps serving the old palette while // the document around it has already moved. - const before = renderColorTokens({ scheme: 'dark' }); + const before = renderColorTokens({ schema: 'dark' }); glaze.configure({ darkDesaturation: 0.5 }); invalidatePaletteTokens(); - expect(renderColorTokens({ scheme: 'dark' })).not.toEqual(before); + expect(renderColorTokens({ schema: 'dark' })).not.toEqual(before); }); }); @@ -1382,7 +1382,7 @@ describe('accent color seeds', () => { }); /** - * The whole contract, as one invariant: for every brand and every scheme, the fill + * The whole contract, as one invariant: for every brand and every schema, the fill * is EITHER exactly the color asked for, OR sitting on the 3:1 floor — and it is * floored only when the color could not clear the floor by itself. * @@ -1396,7 +1396,7 @@ describe('accent color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: 'light', + schema: 'light', }); const fill = String(tokens['#accent-surface']); const surface = String(tokens['#surface']); @@ -1430,7 +1430,7 @@ describe('accent color seeds', () => { } }); - it('lets dark adapt rather than pinning the color across schemes', () => { + it('lets dark adapt rather than pinning the color across schemas', () => { // Exactness is scoped to light / normal contrast on purpose. Dark is a // different page, and a fill pinned to one lightness across both would be a // worse `mode: 'fixed'` rather than a faithful brand — so the dark variant @@ -1441,7 +1441,7 @@ describe('accent color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: 'dark', + schema: 'dark', }); const fill = String(tokens['#accent-surface']); @@ -1455,7 +1455,7 @@ describe('accent color seeds', () => { const dark = renderPaletteTokens({ ...EXACT, accent: '#FFD400', - scheme: 'dark', + schema: 'dark', }); expect(hexOf(String(dark['#accent-surface']))).not.toBe('#ffd400'); }); @@ -1469,7 +1469,7 @@ describe('accent color seeds', () => { const hc = renderPaletteTokens({ ...EXACT, accent: '#0EA5E9', - scheme: 'light', + schema: 'light', highContrast: true, }); @@ -1509,7 +1509,7 @@ describe('accent color seeds', () => { for (const highContrast of [false, true]) { const shipped = renderPaletteTokens({ ...EXACT, - scheme: 'dark', + schema: 'dark', highContrast, }); const reference = apcaOf( @@ -1520,7 +1520,7 @@ describe('accent color seeds', () => { const seeded = renderPaletteTokens({ ...EXACT, accent: 'okhst(280 70% 10%)', - scheme: 'dark', + schema: 'dark', highContrast, }); const separation = apcaOf( @@ -1552,19 +1552,19 @@ describe('accent color seeds', () => { // range survived in dark against light's 45. const tones = [30, 45, 60, 75]; - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { const emitted = tones.map((tone) => toneOf( String( renderPaletteTokens({ ...EXACT, accent: `okhst(280 70% ${tone}%)`, - scheme, + schema, })['#accent-surface'], ), ), ); - const label = `${scheme}: ${emitted.map((t) => t.toFixed(1)).join(', ')}`; + const label = `${schema}: ${emitted.map((t) => t.toFixed(1)).join(', ')}`; // Never inverts — a lighter seed cannot emit a darker fill. for (let i = 1; i < emitted.length; i++) { @@ -1587,16 +1587,16 @@ describe('accent color seeds', () => { // saturated hue behaves (`#FFD400` in dark high contrast lands on pure black at // 2.23 against the rest state's 7.07), and it is why the hover target is 9. for (const accentColor of BRANDS) { - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { for (const highContrast of [false, true]) { const tokens = renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: scheme, + schema: schema, highContrast: highContrast, }); const base = String(tokens['#accent-selected-fill']); - const label = `${accentColor} ${scheme}${highContrast ? ' hc' : ''}`; + const label = `${accentColor} ${schema}${highContrast ? ' hc' : ''}`; expect( contrastOf(String(tokens['#accent-text']), base), @@ -1633,13 +1633,13 @@ describe('accent color seeds', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); for (const accentColor of BRANDS.filter((brand) => brand !== '#FFD400')) { - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { invalidatePaletteTokens(); - renderPaletteTokens({ ...EXACT, accent: accentColor, scheme: scheme }); + renderPaletteTokens({ ...EXACT, accent: accentColor, schema: schema }); renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: scheme, + schema: schema, highContrast: true, }); } @@ -1667,7 +1667,7 @@ describe('accent color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: 'light', + schema: 'light', }); const drift = Math.abs( toneOf(String(tokens['#accent-text-soft'])) - @@ -1683,14 +1683,14 @@ describe('accent color seeds', () => { // collapse them into one color and delete the rest→hover intensify that // `#accent-text` exists for. for (const accentColor of BRANDS) { - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { const tokens = renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: scheme, + schema: schema, }); - expect(tokens['#accent-text'], `${accentColor} ${scheme}`).not.toBe( + expect(tokens['#accent-text'], `${accentColor} ${schema}`).not.toBe( tokens['#accent-text-soft'], ); } @@ -1711,9 +1711,9 @@ describe('accent color seeds', () => { const seeded = renderPaletteTokens({ ...EXACT, accent: '#FFD400', - scheme: 'light', + schema: 'light', }); - const baseline = renderPaletteTokens({ ...EXACT, scheme: 'light' }); + const baseline = renderPaletteTokens({ ...EXACT, schema: 'light' }); for (const name of [ '#danger-accent-surface', @@ -1766,7 +1766,7 @@ describe('accent color seeds', () => { const seed = colorSeed('#0EA5E9')!; const tokens = renderPaletteTokens({ accent: '#0EA5E9', - scheme: 'light', + schema: 'light', }); const ramp = [ '#accent-surface', @@ -1786,15 +1786,15 @@ describe('accent color seeds', () => { for (let hue = 0; hue < 360; hue += 45) { for (const tone of [20, 60, 88, 100]) { for (const saturation of [0, 60, 100]) { - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { for (const highContrast of [false, true]) { const seed = `okhst(${hue} ${saturation}% ${tone}%)`; const tokens = renderPaletteTokens({ accent: seed, - scheme: scheme, + schema: schema, highContrast: highContrast, }); - const label = `${seed} ${scheme}${highContrast ? ' hc' : ''}`; + const label = `${seed} ${schema}${highContrast ? ' hc' : ''}`; // The white label — the guarantee `accentToneCeiling` exists for, and the // one that has to hold at text strength. @@ -1834,9 +1834,9 @@ describe('accent color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, accent: '#FFD400', - scheme: 'light', + schema: 'light', }); - const baseline = renderPaletteTokens({ ...EXACT, scheme: 'light' }); + const baseline = renderPaletteTokens({ ...EXACT, schema: 'light' }); expect(hueOf(String(tokens['#special-accent-surface']))).toBeCloseTo( colorSeed('#FFD400')!.hue, @@ -1852,7 +1852,7 @@ describe('accent color seeds', () => { // `#FFD400` sits on the sRGB gamut boundary, so its saturation is exactly 100 — // which makes it the clearest witness for the clip. const seed = colorSeed('#FFD400')!; - const baseline = renderPaletteTokens({ scheme: 'light' }); + const baseline = renderPaletteTokens({ schema: 'light' }); setPaletteConfig({ base: '#FFD400' }); @@ -1869,7 +1869,7 @@ describe('accent color seeds', () => { // The base zone moved and the accent zone did not. `baseline` is captured before // the write on purpose: `renderPaletteTokens` LAYERS over the live config, so a // baseline taken afterwards would already carry the base color. - const seeded = renderPaletteTokens({ scheme: 'light' }); + const seeded = renderPaletteTokens({ schema: 'light' }); expect(seeded['#border']).not.toBe(baseline['#border']); expect(seeded['#accent-surface']).toBe(baseline['#accent-surface']); @@ -1903,7 +1903,7 @@ describe('accent color seeds', () => { // The one guarantee this must not break: a brand — or a chrome — expressed as a // color cannot re-chromatize the status themes. They inherit the palette seed, // and no color writes to it. - const baseline = renderPaletteTokens({ scheme: 'light' }); + const baseline = renderPaletteTokens({ schema: 'light' }); const statuses = Object.keys(baseline).filter((name) => /^#(success|danger|warning|note)-/.test(name), ); @@ -1917,7 +1917,7 @@ describe('accent color seeds', () => { ] as PaletteConfig[]) { setPaletteConfig(config); - const seeded = renderPaletteTokens({ scheme: 'light' }); + const seeded = renderPaletteTokens({ schema: 'light' }); for (const name of statuses) expect(seeded[name], `${JSON.stringify(config)} ${name}`).toBe( @@ -2045,12 +2045,12 @@ describe('accent color seeds', () => { // This is the divergence the Theme Builder shows as requested-vs-resolved chips. const softened = renderPaletteTokens({ accent: '#EF4444', - scheme: 'light', + schema: 'light', }); const exact = renderPaletteTokens({ ...EXACT, accent: '#EF4444', - scheme: 'light', + schema: 'light', }); expectSameColor( @@ -2137,11 +2137,11 @@ describe('accent color seeds', () => { const preview = renderPaletteTokens({ ...EXACT, accent: '#FFD400', - scheme: 'light', + schema: 'light', }); expect(preview['#accent-surface']).not.toBe( - renderPaletteTokens({ scheme: 'light' })['#accent-surface'], + renderPaletteTokens({ schema: 'light' })['#accent-surface'], ); expect(getPaletteConfig()).toEqual(DEFAULT_PALETTE_CONFIG); }); @@ -2195,7 +2195,7 @@ describe('status color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, themes: { danger: color }, - scheme: 'light', + schema: 'light', }); const fill = String(tokens['#danger-accent-surface']); const surface = String(tokens['#surface']); @@ -2213,7 +2213,7 @@ describe('status color seeds', () => { it('reaches the theme’s text and icon, hover a step past rest', () => { setPaletteConfig({ ...EXACT, themes: { danger: '#b91c1c' } }); - const tokens = renderPaletteTokens({ scheme: 'light' }); + const tokens = renderPaletteTokens({ schema: 'light' }); const seed = colorSeed('#b91c1c')!; // The rest link IS the brand — the visible payoff of a color seed — and the hover @@ -2249,12 +2249,12 @@ describe('status color seeds', () => { const seeded = renderPaletteTokens({ ...EXACT, themes: { danger: color }, - scheme: 'light', + schema: 'light', }); const numeric = renderPaletteTokens({ ...EXACT, themes: { danger: { hue: seed.hue, saturation: seed.saturation } }, - scheme: 'light', + schema: 'light', }); for (const name of Object.keys(FACTORS)) { @@ -2270,9 +2270,9 @@ describe('status color seeds', () => { const muted = renderPaletteTokens({ ...EXACT, themes: { danger: '#8d6e63' }, - scheme: 'light', + schema: 'light', }); - const unseeded = renderPaletteTokens({ ...EXACT, scheme: 'light' }); + const unseeded = renderPaletteTokens({ ...EXACT, schema: 'light' }); expect(chromaOf(String(muted['#danger-border'])) * 3).toBeLessThan( chromaOf(String(unseeded['#danger-border'])), @@ -2287,7 +2287,7 @@ describe('status color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, themes: { note: '#0EA5E9' }, - scheme: 'light', + schema: 'light', }); const fill = String(tokens['#note-accent-surface']); @@ -2304,11 +2304,11 @@ describe('status color seeds', () => { // the case that proves it: uncapped, the button would be a white label on white. for (const name of ['danger', 'success', 'warning', 'note'] as const) { for (const highContrast of [false, true]) { - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { const tokens = renderPaletteTokens({ ...EXACT, themes: { [name]: 'okhst(20 80% 96%)' }, - scheme, + schema, highContrast, }); const fill = String(tokens[`#${name}-accent-surface`]); @@ -2318,7 +2318,7 @@ describe('status color seeds', () => { // serializer's rather than the solver's. expect( apcaOf('#ffffff', fill), - `${name} ${scheme}${highContrast ? ' hc' : ''}`, + `${name} ${schema}${highContrast ? ' hc' : ''}`, ).toBeGreaterThanOrEqual(44.9); } } @@ -2326,11 +2326,11 @@ describe('status color seeds', () => { }); it('scopes a color to its own theme, in both directions', () => { - const baseline = renderPaletteTokens({ ...EXACT, scheme: 'light' }); + const baseline = renderPaletteTokens({ ...EXACT, schema: 'light' }); const seeded = renderPaletteTokens({ ...EXACT, themes: { danger: '#b91c1c' }, - scheme: 'light', + schema: 'light', }); // Nothing outside `danger` may move — not the other three statuses, not the brand @@ -2351,7 +2351,7 @@ describe('status color seeds', () => { ...EXACT, accent: '#FFD400', themes: { danger: '#b91c1c' }, - scheme: 'light', + schema: 'light', }); for (const name of [ @@ -2418,11 +2418,11 @@ describe('status color seeds', () => { const preview = renderPaletteTokens({ ...EXACT, themes: { danger: '#b91c1c' }, - scheme: 'light', + schema: 'light', }); expect(preview['#danger-accent-surface']).not.toBe( - renderPaletteTokens({ scheme: 'light' })['#danger-accent-surface'], + renderPaletteTokens({ schema: 'light' })['#danger-accent-surface'], ); expect(getPaletteConfig()).toEqual(DEFAULT_PALETTE_CONFIG); }); diff --git a/src/tokens/palette.ts b/src/tokens/palette.ts index 9d74ec2f1..2b695ac6a 100644 --- a/src/tokens/palette.ts +++ b/src/tokens/palette.ts @@ -28,11 +28,11 @@ import type { ResolvedThemeSeed, } from './palette-config'; -/** Which resolved scheme variant {@link renderPaletteTokens} should return. */ +/** Which resolved schema variant {@link renderPaletteTokens} should return. */ export interface RenderPaletteOptions extends PaletteConfig { - /** Color scheme to resolve. Default: `'light'`. */ - scheme?: 'light' | 'dark'; - /** Resolve the high-contrast variant of that scheme. Default: `false`. */ + /** Color schema to resolve. Default: `'light'`. */ + schema?: 'light' | 'dark'; + /** Resolve the high-contrast variant of that schema. Default: `false`. */ highContrast?: boolean; } @@ -174,7 +174,7 @@ function baseSaturationScale(config: ResolvedPaletteConfig): number { * so Glaze solves `apcaContrast(surface, fill)`. * * **This number is page separation and nothing else**, and getting that wrong is what - * made the dark scheme unusable. It reads differently by scheme — in light `surface` IS + * made the dark schema unusable. It reads differently by schema — in light `surface` IS * white, so the same measurement happens to be the white-label pair, which invited the * value to be set for the LABEL at Lc 45 (`large` text) with 60 in high contrast. In * dark the page is near-black and the same number is a demand that a filled shape reach @@ -214,7 +214,7 @@ function baseSaturationScale(config: ResolvedPaletteConfig): number { * dark (12 hues, spread under 2 Lc — hue is not a factor, polarity is). That is 2.4x * stricter in light than in dark, which is why light brands kept getting crushed while * dark ones sailed through under the same rule. An Lc is one number that means one - * thing in both schemes. + * thing in both schemas. * * The pair is written out with BOTH entries equal, and that is the whole reason it is a * pair: stating one level would let APCA's automatic +15 Lc enhancement fire in high @@ -247,7 +247,7 @@ const ACCENT_FILL_CONTRAST: ContrastSpec = { apca: [25, 25] }; * `[4.5, 9]` pair, whose `9` existed only because a WCAG ratio that high is unreachable * for a saturated hue against a chromatic base: `#FFD400` in dark high contrast used to * pin to pure black and come out a hover link *less* readable than its rest state. An - * Lc target is reachable in both schemes because it is polarity-aware, so the + * Lc target is reachable in both schemas because it is polarity-aware, so the * pathological case has no equivalent here. */ const ACCENT_TEXT_CONTRAST: ContrastSpec = { apca: [60, 85] }; @@ -271,7 +271,7 @@ const ACCENT_RAMP = { * Both are pinned to the caller's tone otherwise, and a pair at the same tone behind * the same floor resolves to one color — which would silently delete the rest→hover * intensify that `accent-text` exists for. Tone is contrast-uniform, so one step is - * one step in either scheme. + * one step in either schema. */ const ACCENT_TEXT_HOVER_STEP = 6; @@ -309,7 +309,7 @@ const ACCENT_LABEL_LC = 45; * The ceiling is computed on the bare seed, but the emitted `accent-surface` then goes * through the page floor, which can only LIGHTEN — and a lighter fill is a weaker white * label, so the solve eats into the margin the ceiling just established. Measured worst - * case across 3072 hue/chroma/tone/scheme/tier combinations is 1.8 Lc, in dark high + * case across 3072 hue/chroma/tone/schema/tier combinations is 1.8 Lc, in dark high * contrast where the page floor pushes hardest; 3 covers it with room. */ const ACCENT_LABEL_MARGIN = 3; @@ -349,7 +349,7 @@ function labelLcOf(variant: Parameters[0]): number { * * The search runs against Glaze's own fixed-mode resolution — the same mapping * `accent-surface` goes through — rather than reimplementing the dark tone window, and - * checks all four variants so the cap is a property of the seed and not of one scheme. + * checks all four variants so the cap is a property of the seed and not of one schema. * * Only ever lowers, so a brand already dark enough comes back untouched. */ @@ -711,7 +711,7 @@ function tintedSurfaceOverride(config: ResolvedPaletteConfig): ColorMap { * pinned totals, and `surface-2-text`. * * The text is anchored to `surface-2`, not `surface`. `surface-2` has the lower - * contrast headroom in BOTH schemes — a darker background under dark text in + * contrast headroom in BOTH schemas — a darker background under dark text in * light, a lighter one under light text in dark — so solving the floor there * clears it on both bands. The neutral ramp's own `surface-2-text` is shaped the * same way for the same reason. @@ -733,7 +733,7 @@ export function tintRecipe(config: ResolvedPaletteConfig): ColorMap { base: 'surface-2', tone: `${TEXT_TONE - TINTED_SURFACE_TONE_OFFSET - SURFACE_2_TEXT_OFFSET}`, saturation: 0.25, - // The whole point: Glaze binary-searches the tone per scheme until the floor + // The whole point: Glaze binary-searches the tone per schema until the floor // is met, so a caller cannot persist an unreadable pair. contrast: ['AA', 'AAA'], }, @@ -790,7 +790,7 @@ function accentFillColors(accent: AccentSeed): ColorMap { // ---- Accent system (theme-aware, inherited by colored themes) ---- // Everything here is anchored to a fixed white "accent-surface-text" via // `mode: 'fixed'` + relative tone deltas, so accent colors stay visually - // consistent across light/dark/high-contrast schemes (the brand color does + // consistent across light/dark/high-contrast schemas (the brand color does // not flip). The solid fills are white-text-on-brand backgrounds, so they // keep an `['AA','AAA']` contrast floor even though the chosen tone deltas // already exceed it. This leaves room for a future low-contrast scale. @@ -816,7 +816,7 @@ function accentFillColors(accent: AccentSeed): ColorMap { // Hover variant of `accent-surface` — a *fixed*-mode darker shade used as // the hover fill for solid PRIMARY-type buttons. Anchored to the same // accent-surface-text so it stays in the same hue family. The relative tone - // lands a few steps darker than the pressed state in both schemes. + // lands a few steps darker than the pressed state in both schemas. 'accent-surface-hover': { base: 'accent-surface-text', tone: '-58', @@ -974,7 +974,7 @@ function buildPalette( * Carry `contrastLevel` on the theme instances instead of relying on the * global Glaze config. Used when rendering a palette the app is not running, * so a preview cannot disturb the live one. The resolved values are the same - * either way; only which scheme variants get *emitted* differs, and callers + * either way; only which schema variants get *emitted* differs, and callers * that isolate pick their variant explicitly. */ isolateContrastLevel?: boolean; @@ -1160,7 +1160,7 @@ function buildPalette( }, // Disabled fill chip + text — both adaptive (mode 'auto') and positioned // with relative tone deltas against `surface` so the disabled state has the - // same perceived intensity in light, dark, and high-contrast schemes. No + // same perceived intensity in light, dark, and high-contrast schemas. No // numeric contrast prop is needed: tone is already on a WCAG-uniform scale. // // Tone deltas reproduce the legacy palette's disabled appearance exactly: @@ -1186,8 +1186,8 @@ function buildPalette( }, // Fixed-mode "always dark" surface for elements that intentionally stay - // inverted regardless of scheme (tooltips, code blocks, popovers with their - // own dark theme, etc.). `mode: 'fixed'` bypasses the dark-scheme inversion + // inverted regardless of schema (tooltips, code blocks, popovers with their + // own dark theme, etc.). `mode: 'fixed'` bypasses the dark-schema inversion // so the color reads as a dark surface in light, dark, and high-contrast. // Pair with `#white` (built-in) for foreground text. 'surface-inverse': { @@ -1203,7 +1203,7 @@ function buildPalette( ...accentColors(accent), // Brand-tinted disabled chip + label for PRIMARY-style buttons (solid brand - // fill). The chip is scheme-symmetric (`mode: 'fixed'`) so the muted state + // fill). The chip is schema-symmetric (`mode: 'fixed'`) so the muted state // reads the same weight in light/dark/HC; saturation is bumped so it stays // identifiable as a muted brand color. // @@ -1213,9 +1213,9 @@ function buildPalette( // relative `tone: '+15'` with `autoFlip: false`, because Glaze < 1.2.0 // re-mapped the extreme through the dark tone window, compressing the // base-to-extreme span and dropping the dark label's contrast. Glaze 1.2.0 - // (tenphi/glaze#82) instead replays the light scheme's base→extreme tone + // (tenphi/glaze#82) instead replays the light schema's base→extreme tone // shift against the base's resolved dark tone — same-signed under - // `mode: 'fixed'` — so `'max'` holds its intended separation in every scheme + // `mode: 'fixed'` — so `'max'` holds its intended separation in every schema // and the approximation is no longer needed. Inherited per theme. // // The special theme keeps its own relative `+18` pair: its `surface` is a @@ -1274,13 +1274,13 @@ function buildPalette( // follows a re-seeded brand hue without announcing it. // // **Contrast, not tone, is the spec.** A relative tone delta is uniform on - // the OKHST scale but the dark scheme resolves it inside the `darkTone` + // the OKHST scale but the dark schema resolves it inside the `darkTone` // window, which compressed the ramp to ~75% of its light span — measurably // flatter, which is exactly how it looked. Glaze has no per-color // `darkTone`, so the fix is to state the intent as a WCAG floor against - // `surface` and let each scheme solve for it: the authored `tone: '-2'` is + // `surface` and let each schema solve for it: the authored `tone: '-2'` is // deliberately short of every floor, so all three faces are pinned by the - // ratio in every scheme rather than by a delta that means different things + // ratio in every schema rather than by a delta that means different things // in each. Measured on the emitted tokens, light comes out 1.201 / 1.653 / // 2.409 and dark 1.212 / 1.666 / 2.424 — within 1% of each other, against // 1.063 / 1.320 / 1.915 vs 1.053 / 1.264 / 1.735 before. @@ -1421,7 +1421,7 @@ function buildPalette( // -------------------------------------------------------------------------- // // Standalone theme for `special`-variant components (hero CTAs, banners, etc.) - // that intentionally sit on a dark surface regardless of the active scheme. + // that intentionally sit on a dark surface regardless of the active schema. // // Every token here is `mode: 'fixed'` so the resolved value is identical in // light, dark, and high-contrast. The shape is purpose-built (not a full @@ -1447,7 +1447,7 @@ function buildPalette( // - `accent-disabled-surface` / `accent-disabled-surface-text` — // brand-tinted disabled chip + label, positioned with relative tone // deltas against the fixed dark `surface` so the disabled state is - // scheme-symmetric. + // schema-symmetric. const specialTheme = glaze(hue, saturation, instanceConfig); @@ -1641,7 +1641,7 @@ let renderKey: string | null = null; let renderVariants: Record> = {}; /** - * Resolve one scheme variant of a palette to flat, literal color values. + * Resolve one schema variant of a palette to flat, literal color values. * * Unlike {@link getPaletteTokens}, which emits state maps (`@dark` / `@hc`) for * the whole document, this collapses the palette to the single variant you ask @@ -1655,7 +1655,7 @@ let renderVariants: Record> = {}; export function renderPaletteTokens( options: RenderPaletteOptions = {}, ): Tokens { - const { scheme = 'light', highContrast = false, ...config } = options; + const { schema = 'light', highContrast = false, ...config } = options; const resolved = resolvePaletteConfig(config); const key = `${getPaletteVersion()}:${JSON.stringify(resolved)}`; @@ -1699,13 +1699,13 @@ export function renderPaletteTokens( } } - const variant = VARIANT_KEY[`${scheme}:${highContrast}`]; + const variant = VARIANT_KEY[`${schema}:${highContrast}`]; // The fallback is for `contrastLevel: 100` only. There the normal variants // already *are* the high-contrast ones, so Glaze emits a single light/dark set // rather than duplicating it — and `highContrast` correctly resolves to the same // colors. At every other level the contrast variants are present and genuinely // escalated, so the fallback is not taken. - const flat = renderVariants[variant] ?? renderVariants[scheme]; + const flat = renderVariants[variant] ?? renderVariants[schema]; const out: Tokens = {}; for (const name of Object.keys(flat)) out[`#${name}`] = flat[name]; diff --git a/src/tokens/shadows.ts b/src/tokens/shadows.ts index 742bb55e6..0b082a19e 100644 --- a/src/tokens/shadows.ts +++ b/src/tokens/shadows.ts @@ -9,7 +9,7 @@ import type { Styles } from '@tenphi/tasty'; * * The shadow colors (`#shadow-sm` / `#shadow-md` / `#shadow-lg`) are * generated by Glaze and adapt automatically to dark / high-contrast - * schemes — see `src/tokens/palette.ts`. + * schemas — see `src/tokens/palette.ts`. * * Keys use $ prefix for CSS custom properties. */ From c66147d31de58a2c87abacaa16415b243024ccc0 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Tue, 25 Aug 2026 18:21:42 +0200 Subject: [PATCH 4/4] docs(README): say schema, not scheme Co-Authored-By: Claude Opus 5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index edf178708..ea07127af 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ function App() { ``` To ship your own brand color, tune the palette seeds — every token, in every -scheme, re-resolves from them: +schema, re-resolves from them: ```tsx