From 76ed6aec6bc4f9792c8507780fce3256a0d02fb7 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Fri, 24 Jul 2026 10:38:54 +1000 Subject: [PATCH] Flip to the 128-leaf theme contract and defineTheme-only authoring (breaking) (#230) * Flip to the 128-leaf theme contract and defineTheme-only authoring Reduce the emitted contract 152->128: feedback intents (info/success/ warning) collapse to a static subtle+border+text kit; drop surface.resting, surfaceDisabled, borderDisabled, and the unused motion tail; rename textDisabled->text.disabled (CSS-var-stable); add color.scrim. Emit color.scrim from the theme and replace the hardcoded combobox tray scrim with vars.color.scrim. Make defineTheme the sole public authoring API: drop buildTheme and the raw foundation types from the package entry (kept internal for the build pipeline), rewrite Tactile/Paper as defineTheme inputs, and route build-themes/themes through defineTheme. validateContrast drops removed-token checks and adds text.primary x neutral.surface.subtle*. Retained token values are byte-identical (zero visual drift; goldens unchanged). Breaking major. Part of #226 / #228. * Update docs for the 128-leaf contract and defineTheme authoring Rewrite the theming and overview docs plus STYLING.md to document defineTheme as the sole public authoring API and the reduced 128-leaf token contract (static feedback intents, text.disabled, scrim; removed surface.resting, the disabled surface/border tokens, and the unused motion tail). Repoint live docs examples/samples off removed tokens. Part of #226 / #228. --- apps/docs/content/docs/overview/color.mdx | 22 +-- apps/docs/content/docs/overview/styling.mdx | 4 +- apps/docs/content/docs/overview/theme.mdx | 2 +- apps/docs/content/docs/theming/authoring.mdx | 65 ++++---- apps/docs/content/docs/theming/index.mdx | 6 +- .../content/docs/theming/token-reference.mdx | 6 +- .../src/examples/box/responsive-layout.tsx | 2 +- .../examples/overview/concentric-radius.tsx | 2 +- apps/docs/src/examples/overview/depth.tsx | 2 +- .../examples/theming/identity-and-mode.tsx | 4 +- .../examples/theming/semantic-variables.tsx | 2 +- .../docs/src/samples/theming/author-theme.tsx | 8 +- docs/STYLING.md | 21 ++- .../@luke-ui/react/scripts/build-themes.ts | 14 +- .../@luke-ui/react/src/box/box.stories.tsx | 2 +- .../loading-spinner.visual.test.tsx | 2 +- .../react/src/recipes/combobox.css.ts | 8 +- .../@luke-ui/react/src/recipes/field.css.ts | 4 +- .../react/src/recipes/text-input.css.ts | 6 +- .../react/src/recipes/text.browser.test.ts | 8 +- .../react/src/styles/utilities.stories.tsx | 8 +- .../react/src/theme/build-theme.test.ts | 21 ++- .../@luke-ui/react/src/theme/build-theme.ts | 56 +++++-- packages/@luke-ui/react/src/theme/contract.ts | 40 ++--- .../react/src/theme/define-theme.test.ts | 150 +++++++++++------- .../@luke-ui/react/src/theme/define-theme.ts | 40 ++--- .../@luke-ui/react/src/theme/foundation.ts | 9 +- .../@luke-ui/react/src/theme/foundations.ts | 120 +++++++------- packages/@luke-ui/react/src/theme/index.tsx | 21 +-- .../react/src/theme/tokens.stories.tsx | 85 ++++------ packages/@luke-ui/react/src/themes/index.ts | 6 +- 31 files changed, 384 insertions(+), 362 deletions(-) diff --git a/apps/docs/content/docs/overview/color.mdx b/apps/docs/content/docs/overview/color.mdx index c6320302..ec874623 100644 --- a/apps/docs/content/docs/overview/color.mdx +++ b/apps/docs/content/docs/overview/color.mdx @@ -9,16 +9,16 @@ mode, so a component can remain consistent without knowing the underlying palett ## Surfaces and content Surface roles describe where an element sits in the interface. Use `canvas` for the page, `recessed` -for an inset area, `resting` for ordinary content, `floating` for elevated UI, and `overlay` for -modal backdrops. Text uses `primary` and `secondary` roles. Borders distinguish decorative, control, -and focus uses. +for an inset area, `floating` for elevated UI such as menus and cards, and `overlay` for dialogs and +other high-elevation surfaces. `scrim` dims the page behind an open overlay. Text uses `primary` and +`secondary` roles. Borders distinguish decorative, control, and focus uses. ```tsx import { vars } from '@luke-ui/react/theme';
+ - +The minimal theme authors one colour and a neutral character: + +```tsx +import { defineTheme } from '@luke-ui/react/theme'; + +const css = defineTheme({ + name: 'product', + color: { accent: '#3b82f6', neutralStyle: 'cool' }, +}); +``` ## Generate a stylesheet -Call `buildTheme` from an application build script and write the returned CSS to a stylesheet your +Call `defineTheme` from an application build script and write the returned CSS to a stylesheet your framework can load. Apply `themeRootClassName` and `themeClassName('product')` to the same root. The supplied name must -match the foundation name. Colour mode uses the same `data-color-mode` attribute as bundled themes. +match the input's `name`. Colour mode uses the same `data-color-mode` attribute as bundled themes. -Applications are responsible for loading any non-system font files selected by their foundation. +Applications are responsible for loading any non-system font files selected by their `typography`. ## Contrast validation -`buildTheme` calculates the generated colour values in OKLCH, maps them to the sRGB gamut, and -checks WCAG 2.2 AA contrast. Text pairs must reach 4.5:1. Non-text UI pairs must reach 3:1. +`defineTheme` calculates the generated colour values in OKLCH, maps them to the sRGB gamut, and +checks WCAG 2.2 AA contrast. Text pairs must reach 4.5:1. Non-text UI pairs must reach 3:1. A +single-value accent that has no accessible lightness in a mode throws before contrast validation +runs; author an explicit `{ light, dark }` accent instead. -When validation fails, `buildTheme` throws `ThemeContrastError`. Its `failures` array identifies +When validation fails, `defineTheme` throws `ThemeContrastError`. Its `failures` array identifies each colour mode and token pair, so the stylesheet is not written with an invalid combination. ## Next steps diff --git a/apps/docs/content/docs/theming/index.mdx b/apps/docs/content/docs/theming/index.mdx index 7f5f416d..c5b74289 100644 --- a/apps/docs/content/docs/theming/index.mdx +++ b/apps/docs/content/docs/theming/index.mdx @@ -63,8 +63,8 @@ Do not depend on a component's selectors, generated palette values, or theme imp ## Author a theme -When neither bundled identity fits a product, define a typed `ThemeFoundation` and compile it with -`buildTheme`. The generated stylesheet contains the full semantic contract for both colour modes and +When neither bundled identity fits a product, call `defineTheme` with a curated accent and neutral +character. The generated stylesheet contains the full semantic contract for both colour modes and validates contrast before it is written. @@ -72,7 +72,7 @@ validates contrast before it is written. Load the CSS, set the root classes, and preserve them through portals. - Compile a product-owned stylesheet from a typed foundation. + Compile a product-owned stylesheet from a curated `defineTheme` input. Browse the public semantic CSS variables for custom UI. diff --git a/apps/docs/content/docs/theming/token-reference.mdx b/apps/docs/content/docs/theming/token-reference.mdx index 8387df21..fd1d156a 100644 --- a/apps/docs/content/docs/theming/token-reference.mdx +++ b/apps/docs/content/docs/theming/token-reference.mdx @@ -19,9 +19,9 @@ other implementation details are not part of the public styling API. description="A custom surface that follows the active theme and colour mode." /> -Choose tokens by purpose. A card might use `surface.resting`, `radius.surface`, and `depth.resting`. -A warning message might use the warning intent's subtle surface and text. Avoid carrying resolved -colour values into application CSS. +Choose tokens by purpose. A card might use `surface.floating`, `radius.surface`, and +`depth.resting`. A warning message might use the warning intent's subtle surface and text. Avoid +carrying resolved colour values into application CSS. ## Tokens diff --git a/apps/docs/src/examples/box/responsive-layout.tsx b/apps/docs/src/examples/box/responsive-layout.tsx index 80e4eea4..39e2e380 100644 --- a/apps/docs/src/examples/box/responsive-layout.tsx +++ b/apps/docs/src/examples/box/responsive-layout.tsx @@ -12,7 +12,7 @@ export default function ResponsiveLayout() { justifyContent="space-between" padding={{ medium: '600', xsmall: '300' }} style={{ - backgroundColor: vars.color.surface.resting, + backgroundColor: vars.color.surface.floating, border: `1px solid ${vars.color.border.decorative}`, borderRadius: vars.radius.surface, boxShadow: vars.depth.resting, diff --git a/apps/docs/src/examples/overview/concentric-radius.tsx b/apps/docs/src/examples/overview/concentric-radius.tsx index d5aabcfe..713194c9 100644 --- a/apps/docs/src/examples/overview/concentric-radius.tsx +++ b/apps/docs/src/examples/overview/concentric-radius.tsx @@ -19,7 +19,7 @@ export default function ConcentricRadiusExample() { blockSize="6rem" inlineSize="100%" style={{ - backgroundColor: vars.color.surface.resting, + backgroundColor: vars.color.surface.floating, borderRadius: vars.radius.control, }} /> diff --git a/apps/docs/src/examples/overview/depth.tsx b/apps/docs/src/examples/overview/depth.tsx index 5e4afc50..4b67d492 100644 --- a/apps/docs/src/examples/overview/depth.tsx +++ b/apps/docs/src/examples/overview/depth.tsx @@ -17,7 +17,7 @@ export default function DepthExample() { @@ -65,7 +65,7 @@ export default function IdentityAndModeExample() {
} style={{ - backgroundColor: vars.color.surface.resting, + backgroundColor: vars.color.surface.floating, border: `1px solid ${vars.color.border.decorative}`, borderRadius: vars.radius.surface, boxShadow: vars.depth.resting, diff --git a/apps/docs/src/samples/theming/author-theme.tsx b/apps/docs/src/samples/theming/author-theme.tsx index 85532f50..cecbc643 100644 --- a/apps/docs/src/samples/theming/author-theme.tsx +++ b/apps/docs/src/samples/theming/author-theme.tsx @@ -1,7 +1,7 @@ -import { buildTheme } from '@luke-ui/react/theme'; -import type { ThemeFoundation } from '@luke-ui/react/theme'; +import { defineTheme } from '@luke-ui/react/theme'; +import type { ThemeInput } from '@luke-ui/react/theme'; import { writeFile } from 'node:fs/promises'; -export async function writeTheme(foundation: ThemeFoundation) { - await writeFile('src/product-theme.css', buildTheme(foundation)); +export async function writeTheme(input: ThemeInput) { + await writeFile('src/product-theme.css', defineTheme(input)); } diff --git a/docs/STYLING.md b/docs/STYLING.md index d1ef21f0..41a6623c 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -17,19 +17,26 @@ element. Neither step injects styles at runtime. `fontSizeSteps` typography step keys. - `theme/contract.css.ts`: the typed `vars` contract, built by walking the semantic token tree directly so it stays source-owned and free of styling-engine types. -- `theme/foundation.ts`: the typed theme-foundation input and curated defaults. +- `theme/define-theme.ts`: the public `defineTheme(input)` authoring util, its typed `ThemeInput`, + and the curated defaults it applies for omitted materials and scrim. +- `theme/foundation.ts`: the internal typed theme-foundation shape `defineTheme` normalises into and + the curated colour, radius, and typography defaults. - `theme/color.ts`: OKLCH colour math, sRGB gamut mapping, and WCAG contrast. -- `theme/build-theme.ts`: `buildTheme(foundation)`, `themeClassName`, and contrast validation. -- `theme/foundations.ts`: foundations for the bundled Tactile and Paper themes. +- `theme/build-theme.ts`: the internal `buildTheme(foundation)` value pipeline, `themeClassName`, + and contrast validation. +- `theme/foundations.ts`: `defineTheme(...)` inputs for the bundled Tactile and Paper themes. - `themes/`: bundled theme class-name constants exported from `@luke-ui/react/themes`. - `scripts/build-themes.ts`: writes the bundled theme stylesheets to `dist/themes/`. ## Themes -`buildTheme(foundation)` from `@luke-ui/react/theme` compiles a typed theme foundation into static -stylesheet text. It is pure and Node-compatible. It generates the full semantic contract in OKLCH -and throws a `ThemeContrastError` naming each failing mode and token pair when a generated pair -misses WCAG 2.2 AA contrast. +`defineTheme(input)` from `@luke-ui/react/theme` is the sole public theme-authoring surface. It +normalises a small, curated `ThemeInput` — a required `color.accent`, an optional neutral character, +and optional materials — into static stylesheet text. It is pure and Node-compatible. It generates +the full semantic contract in OKLCH and throws a `ThemeContrastError` naming each failing mode and +token pair when a generated pair misses WCAG 2.2 AA contrast. A single-value accent or neutral is +adapted per mode through a lightness search; it throws when no lightness in the vibrant band is +accessible. The raw `ThemeFoundation` object and `buildTheme` are internal only. The semantic contract includes `font.100` through `font.900` size steps. Each step groups its font size, line height, letter spacing, and per-font Capsize trims so components cannot combine unrelated diff --git a/packages/@luke-ui/react/scripts/build-themes.ts b/packages/@luke-ui/react/scripts/build-themes.ts index 86221e0b..eb407f13 100644 --- a/packages/@luke-ui/react/scripts/build-themes.ts +++ b/packages/@luke-ui/react/scripts/build-themes.ts @@ -3,20 +3,20 @@ import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { mkdir, writeFile } from 'node:fs/promises'; -import { buildTheme } from '../src/theme/build-theme.js'; -import { paperFoundation, tactileFoundation } from '../src/theme/foundations.js'; +import { defineTheme } from '../src/theme/define-theme.js'; +import { paperTheme, tactileTheme } from '../src/theme/foundations.js'; -const foundations = [tactileFoundation, paperFoundation]; +const themes = [tactileTheme, paperTheme]; async function main() { await Promise.all( - foundations.map(async (foundation) => { + themes.map(async (theme) => { const outputPath = fileURLToPath( - new URL(`../dist/themes/${foundation.name}.css`, import.meta.url), + new URL(`../dist/themes/${theme.name}.css`, import.meta.url), ); await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, buildTheme(foundation), 'utf8'); - process.stdout.write(`Generated dist/themes/${foundation.name}.css\n`); + await writeFile(outputPath, defineTheme(theme), 'utf8'); + process.stdout.write(`Generated dist/themes/${theme.name}.css\n`); }), ); } diff --git a/packages/@luke-ui/react/src/box/box.stories.tsx b/packages/@luke-ui/react/src/box/box.stories.tsx index 791ef987..8801b5f5 100644 --- a/packages/@luke-ui/react/src/box/box.stories.tsx +++ b/packages/@luke-ui/react/src/box/box.stories.tsx @@ -45,7 +45,7 @@ export const CustomDiv = meta.story({ id: 'account-summary', padding: '400', render: (domProps) => , - style: { backgroundColor: vars.color.surface.resting }, + style: { backgroundColor: vars.color.surface.recessed }, } satisfies Partial, play: async ({ canvas }) => { const div = canvas.getByText('Account summary content'); diff --git a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx index ca346258..bcdc6719 100644 --- a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx +++ b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx @@ -99,7 +99,7 @@ function ThemeMatrixScope({ data-color-mode={mode} style={{ alignItems: 'center', - backgroundColor: vars.color.surface.resting, + backgroundColor: vars.color.surface.recessed, border: `1px solid ${vars.color.border.decorative}`, color: vars.color.text.primary, display: 'flex', diff --git a/packages/@luke-ui/react/src/recipes/combobox.css.ts b/packages/@luke-ui/react/src/recipes/combobox.css.ts index daf55f09..ddfdb634 100644 --- a/packages/@luke-ui/react/src/recipes/combobox.css.ts +++ b/packages/@luke-ui/react/src/recipes/combobox.css.ts @@ -90,7 +90,7 @@ const comboboxActionStyles = { backgroundColor: vars.color.intent.accent.surface.subtlePressed, color: vars.color.text.primary, }, - [descendantDisabledSelector]: { color: vars.color.textDisabled }, + [descendantDisabledSelector]: { color: vars.color.text.disabled }, }, } satisfies StyleRule; @@ -190,7 +190,7 @@ const comboboxConfig = { selectors: { '&::placeholder': { color: vars.color.text.secondary, opacity: 1 }, '&:where([data-disabled="true"], :disabled)': { - color: vars.color.textDisabled, + color: vars.color.text.disabled, cursor: 'not-allowed', }, }, @@ -228,7 +228,7 @@ const comboboxConfig = { borderEndStartRadius: 0, borderStartEndRadius: vars.radius.overlay, borderStartStartRadius: vars.radius.overlay, - boxShadow: `${vars.depth.floating}, 0 0 0 100vmax rgb(0 0 0 / 20%)`, + boxShadow: `${vars.depth.floating}, 0 0 0 100vmax ${vars.color.scrim}`, inlineSize: 'auto !important' as 'auto', insetBlockEnd: `var(${comboboxTrayKeyboardInsetVar}, 0px) !important`, insetBlockStart: 'auto !important' as 'auto', @@ -385,7 +385,7 @@ const comboboxConfig = { selectors: { '&[data-disabled="true"]': { - color: vars.color.textDisabled, + color: vars.color.text.disabled, cursor: 'not-allowed', opacity: 0.55, }, diff --git a/packages/@luke-ui/react/src/recipes/field.css.ts b/packages/@luke-ui/react/src/recipes/field.css.ts index 132effbb..0bca951a 100644 --- a/packages/@luke-ui/react/src/recipes/field.css.ts +++ b/packages/@luke-ui/react/src/recipes/field.css.ts @@ -26,7 +26,7 @@ const fieldConfig = { selectors: { [`${dataDisabledSelector} &`]: { - color: vars.color.textDisabled, + color: vars.color.text.disabled, }, }, }, @@ -36,7 +36,7 @@ const fieldConfig = { selectors: { [`${dataDisabledSelector} &`]: { - color: vars.color.textDisabled, + color: vars.color.text.disabled, }, }, }, diff --git a/packages/@luke-ui/react/src/recipes/text-input.css.ts b/packages/@luke-ui/react/src/recipes/text-input.css.ts index fcc67f36..ff2cfbc1 100644 --- a/packages/@luke-ui/react/src/recipes/text-input.css.ts +++ b/packages/@luke-ui/react/src/recipes/text-input.css.ts @@ -123,7 +123,7 @@ const textInputConfig = { opacity: 1, }, '&:where([data-disabled="true"], :disabled)': { - color: vars.color.textDisabled, + color: vars.color.text.disabled, cursor: 'not-allowed', }, }, @@ -139,7 +139,7 @@ const textInputConfig = { selectors: { [descendantDisabledSelector]: { - color: vars.color.textDisabled, + color: vars.color.text.disabled, }, }, }, @@ -154,7 +154,7 @@ const textInputConfig = { selectors: { [descendantDisabledSelector]: { - color: vars.color.textDisabled, + color: vars.color.text.disabled, }, }, }, diff --git a/packages/@luke-ui/react/src/recipes/text.browser.test.ts b/packages/@luke-ui/react/src/recipes/text.browser.test.ts index b6b0a050..1eeff547 100644 --- a/packages/@luke-ui/react/src/recipes/text.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/text.browser.test.ts @@ -1,8 +1,8 @@ import '@luke-ui/react/themes/tactile.css'; import { afterEach, expect, test } from 'vite-plus/test'; import { fontSizeSteps } from '../theme/contract.js'; -import { tactileFoundation } from '../theme/foundations.js'; -import { buildTheme, themeClassName, themeRootClassName } from '../theme/index.js'; +import { tactileTheme } from '../theme/foundations.js'; +import { defineTheme, themeClassName, themeRootClassName } from '../theme/index.js'; import { tactileThemeClassName } from '../themes/index.js'; import { text } from './text.css.js'; @@ -188,8 +188,8 @@ function mountRoot(themeClass = tactileThemeClassName) { function installTheme(fontFamily: 'inter' | 'apple-system' | 'dm-sans') { const name = `capsize-${fontFamily}`; const style = document.head.appendChild(document.createElement('style')); - style.textContent = buildTheme({ - ...tactileFoundation, + style.textContent = defineTheme({ + ...tactileTheme, name, typography: { fontFamily }, }); diff --git a/packages/@luke-ui/react/src/styles/utilities.stories.tsx b/packages/@luke-ui/react/src/styles/utilities.stories.tsx index 418a031d..28d81268 100644 --- a/packages/@luke-ui/react/src/styles/utilities.stories.tsx +++ b/packages/@luke-ui/react/src/styles/utilities.stories.tsx @@ -38,7 +38,7 @@ export const Layout = meta.story({
@@ -46,7 +46,7 @@ export const Layout = meta.story({
@@ -75,7 +75,7 @@ export const Responsive = meta.story({
@@ -83,7 +83,7 @@ export const Responsive = meta.story({
diff --git a/packages/@luke-ui/react/src/theme/build-theme.test.ts b/packages/@luke-ui/react/src/theme/build-theme.test.ts index 076f87e4..c32c4806 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts @@ -3,6 +3,7 @@ import { paperThemeClassName, tactileThemeClassName } from '../themes/index.js'; import { buildTheme, ThemeContrastError, themeClassName } from './build-theme.js'; import { contrastRatio, parseColor } from './color.js'; import { flattenThemeContract } from './contract.js'; +import { normalizeTheme } from './define-theme.js'; import type { ThemeFoundation } from './foundation.js'; import { defaultFontWeights, @@ -11,7 +12,12 @@ import { deriveConcentricRadius, deriveNestedRadius, } from './foundation.js'; -import { paperFoundation, tactileFoundation } from './foundations.js'; +import { paperTheme, tactileTheme } from './foundations.js'; + +// The bundled themes are authored as `defineTheme` inputs; these engine tests exercise the raw +// `buildTheme` pipeline directly, so resolve each input into the foundation `buildTheme` consumes. +const tactileFoundation = normalizeTheme(tactileTheme); +const paperFoundation = normalizeTheme(paperTheme); const pairs = flattenThemeContract(); const isModePath = (path: string) => { @@ -122,7 +128,8 @@ describe('buildTheme output', () => { it('uses the stable kebab-case variable names', () => { expect(css).toContain('--luke-color-intent-danger-surface-solid-hover'); expect(css).toContain('--luke-color-loading-skeleton'); - expect(css).toContain('--luke-color-surface-disabled'); + expect(css).toContain('--luke-color-scrim'); + expect(css).toContain('--luke-color-text-disabled'); expect(css).toContain('--luke-color-intent-accent-text-hover'); expect(css).toContain('--luke-depth-raised'); expect(css).toContain('--luke-action-control-finish-resting'); @@ -453,7 +460,6 @@ describe('buildTheme contrast failures', () => { describe('bundled themes meet WCAG 2.2 AA', () => { const surfaceVarNames = [ '--luke-color-surface-canvas', - '--luke-color-surface-resting', '--luke-color-surface-recessed', '--luke-color-surface-floating', '--luke-color-surface-overlay', @@ -506,7 +512,7 @@ describe('bundled themes meet WCAG 2.2 AA', () => { it(`${foundation.name} generates the least-contrasting passing control borders`, () => { const blocks = splitBlocks(buildTheme(foundation)); for (const block of [blocks.baseLight, blocks.mediaDark]) { - const surfaces = ['canvas', 'resting', 'recessed'].map((surface) => { + const surfaces = ['canvas', 'recessed'].map((surface) => { return parseColor(extractValue(block, `--luke-color-surface-${surface}`)); }); const borderVarNames = [ @@ -522,7 +528,12 @@ describe('bundled themes meet WCAG 2.2 AA', () => { ...surfaces.map((surface) => contrastRatio(border, surface)), ); expect(minimumContrast).toBeGreaterThanOrEqual(3); - expect(minimumContrast).toBeLessThan(3.15); + // Borders are still solved against the generator's internal base ladder, which keeps an + // unemitted `resting` rung (between canvas and recessed) so retained values stay + // byte-identical. That rung is the tightest dark-mode background, so measured against + // only the emitted base surfaces the minimum sits a little higher (~3.05 light, ~3.5 dark) + // while still proving the border is not over-contrasted. + expect(minimumContrast).toBeLessThan(3.6); } } }); diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts index 44eb8fa6..c5a4a2b1 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.ts @@ -97,8 +97,17 @@ const LOADING_SKELETON_RATIOS = { dark: 1.75, light: 1.4 } as const; // below its target. const RATIO_HEADROOM = 0.05; -const INTENT_NAMES = ['neutral', 'accent', 'info', 'success', 'warning', 'danger'] as const; +// Action intents render the full interactive ramp (surface trio + solid trio + onSolid); feedback +// intents are static and emit only the soft kit (subtle surface + border + text). +const ACTION_INTENTS = ['neutral', 'accent', 'danger'] as const; +const FEEDBACK_INTENTS = ['info', 'success', 'warning'] as const; +// Intents whose full kit is generated by buildIntentKit (every intent but neutral, which has its +// own solid ladder). Feedback kits are computed in full — the subtle trio anchors the text solve — +// but only their subtle/border/text leaves are emitted. const FULL_KIT_INTENTS = ['accent', 'info', 'success', 'warning', 'danger'] as const; +// The only leaves a feedback intent emits. +const FEEDBACK_EMITTED_KEYS = new Set(['surface.subtle', 'border', 'text']); +const FEEDBACK_INTENT_SET: ReadonlySet = new Set(FEEDBACK_INTENTS); const SOURCE_COLOR_FIELDS = [ 'neutral', 'accent', @@ -122,11 +131,7 @@ const SPACE_VALUES = { } as const; const MOTION_VALUES = { - 'motion.duration.ambient': '800ms', 'motion.duration.fast': '120ms', - 'motion.duration.medium': '200ms', - 'motion.duration.slow': '300ms', - 'motion.easing.enter': 'cubic-bezier(0, 0, 0, 1)', 'motion.easing.exit': 'cubic-bezier(0.3, 0, 1, 1)', 'motion.easing.standard': 'cubic-bezier(0, 0, 0.4, 1)', } as const; @@ -223,6 +228,8 @@ function buildModeValues(mode: ColorMode, modeFoundation: ThemeModeFoundation): for (const [path, color] of Object.entries(colors)) { values[path] = formatOklch(color); } + // The scrim is emitted verbatim (it may carry an alpha channel), so it bypasses the OKLCH pipeline. + values['color.scrim'] = modeFoundation.color.scrim; for (const [name, value] of Object.entries(modeFoundation.depth)) { values[`depth.${name}`] = value; } @@ -312,19 +319,23 @@ function buildModeColors( }); colors['color.border.focus'] = source.focus; - // Disabled roles are exempt from contrast checks but must remain perceptibly disabled. + // Disabled text is exempt from contrast checks but must remain perceptibly muted. Disabled + // surfaces/borders are gone from the contract; interactive controls fade with opacity instead. const disabledChroma = Math.min(neutral.c, 0.01); - const disabledAt = (delta: number) => { - return gamutMapOklch({ c: disabledChroma, h: neutral.h, l: clampUnit(canvas.l + delta) }); - }; - colors['color.surfaceDisabled'] = disabledAt(isLight ? -0.06 : 0.05); - colors['color.textDisabled'] = disabledAt(isLight ? -0.32 : 0.33); - colors['color.borderDisabled'] = disabledAt(isLight ? -0.12 : 0.14); + colors['color.text.disabled'] = gamutMapOklch({ + c: disabledChroma, + h: neutral.h, + l: clampUnit(canvas.l + (isLight ? -0.32 : 0.33)), + }); for (const intent of FULL_KIT_INTENTS) { const intentSource = source[intent]; const kit = buildIntentKit(mode, canvas, baseSurfaces, intentSource, windows); + const isFeedback = FEEDBACK_INTENT_SET.has(intent); for (const [key, value] of Object.entries(kit)) { + // Feedback intents are static: emit only the soft kit even though the full kit is computed + // (the subtle trio is needed as a background while solving the feedback text colour). + if (isFeedback && !FEEDBACK_EMITTED_KEYS.has(key)) continue; colors[`color.intent.${intent}.${key}`] = value; } } @@ -540,20 +551,37 @@ function validateContrast( `color.intent.${intent}.surface.subtleHover`, `color.intent.${intent}.surface.subtlePressed`, ]; + // Feedback intents emit only `surface.subtle`, so their text is validated against just that plus + // the base surfaces (a subset of the action-intent backgrounds). + const intentTextBackgroundPaths = (intent: string) => + FEEDBACK_INTENT_SET.has(intent) + ? [ + 'color.surface.canvas', + 'color.surface.resting', + 'color.surface.recessed', + `color.intent.${intent}.surface.subtle`, + ] + : intentBackgroundPaths(intent); const basePaths = ['color.surface.canvas', 'color.surface.resting', 'color.surface.recessed']; for (const text of ['color.text.primary', 'color.text.secondary']) { for (const surface of surfacePaths) check(text, surface, TEXT_RATIO); } + // Carried from #137/#139: primary text must stay legible on the neutral subtle trio, which backs + // the neutral/gray badge and neutral-subtle controls. + for (const state of ['subtle', 'subtleHover', 'subtlePressed']) { + check('color.text.primary', `color.intent.neutral.surface.${state}`, TEXT_RATIO); + } for (const intent of FULL_KIT_INTENTS) { - for (const background of intentBackgroundPaths(intent)) { + for (const background of intentTextBackgroundPaths(intent)) { check(`color.intent.${intent}.text`, background, TEXT_RATIO); } } for (const background of intentBackgroundPaths('accent')) { check('color.intent.accent.textHover', background, TEXT_RATIO); } - for (const intent of INTENT_NAMES) { + // Only action intents render solid surfaces, so only they carry an onSolid contrast gate. + for (const intent of ACTION_INTENTS) { for (const state of ['solid', 'solidHover', 'solidPressed']) { check( `color.intent.${intent}.onSolid`, diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts index f1244973..40896a29 100644 --- a/packages/@luke-ui/react/src/theme/contract.ts +++ b/packages/@luke-ui/react/src/theme/contract.ts @@ -32,25 +32,26 @@ export const themeContractTree = { color: { surface: { canvas: null, - resting: null, recessed: null, floating: null, overlay: null, }, - surfaceDisabled: null, + /** Modal-backdrop dimming layer behind an overlay surface. */ + scrim: null, loadingSkeleton: null, text: { primary: null, secondary: null, + /** Dedicated muted text (form fields), not opacity. Emits `--luke-color-text-disabled`. */ + disabled: null, }, - textDisabled: null, border: { decorative: null, control: null, focus: null, }, - borderDisabled: null, intent: { + // Action intents: full 6-rung interactive ramp + onSolid. neutral: { surface: { subtle: null, @@ -76,7 +77,7 @@ export const themeContractTree = { textHover: null, onSolid: null, }, - info: { + danger: { surface: { subtle: null, subtleHover: null, @@ -89,44 +90,27 @@ export const themeContractTree = { text: null, onSolid: null, }, - success: { + // Feedback intents: static soft kit only (subtle surface + border + text). + info: { surface: { subtle: null, - subtleHover: null, - subtlePressed: null, - solid: null, - solidHover: null, - solidPressed: null, }, border: null, text: null, - onSolid: null, }, - warning: { + success: { surface: { subtle: null, - subtleHover: null, - subtlePressed: null, - solid: null, - solidHover: null, - solidPressed: null, }, border: null, text: null, - onSolid: null, }, - danger: { + warning: { surface: { subtle: null, - subtleHover: null, - subtlePressed: null, - solid: null, - solidHover: null, - solidPressed: null, }, border: null, text: null, - onSolid: null, }, }, }, @@ -217,13 +201,9 @@ export const themeContractTree = { motion: { duration: { fast: null, - medium: null, - slow: null, - ambient: null, }, easing: { standard: null, - enter: null, exit: null, }, }, diff --git a/packages/@luke-ui/react/src/theme/define-theme.test.ts b/packages/@luke-ui/react/src/theme/define-theme.test.ts index 343ffff6..c7f7aadc 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.test.ts @@ -1,10 +1,9 @@ +import { readFile } from 'node:fs/promises'; import { describe, expect, it } from 'vite-plus/test'; -import { buildTheme } from './build-theme.js'; import { contrastRatio, gamutMapOklch, parseColor } from './color.js'; +import { flattenThemeContract } from './contract.js'; import { defaultDepth, defineTheme } from './define-theme.js'; -import type { ThemeInput } from './define-theme.js'; -import { defaultSourceColors } from './foundation.js'; -import { paperFoundation, tactileFoundation } from './foundations.js'; +import { paperTheme, tactileTheme } from './foundations.js'; /** * Splits a generated stylesheet into its five rule blocks: identity, base light, media-query dark, @@ -35,7 +34,6 @@ function extractValue(block: string, varName: string): string { const ACCENT_SOLID = '--luke-color-intent-accent-surface-solid'; const SURFACE_VAR_NAMES = [ '--luke-color-surface-canvas', - '--luke-color-surface-resting', '--luke-color-surface-recessed', '--luke-color-surface-floating', '--luke-color-surface-overlay', @@ -146,62 +144,98 @@ describe('defineTheme partial per-mode merges', () => { }); }); -describe('defineTheme parity with the bundled foundations', () => { - const tactileInput: ThemeInput = { - actionControlFinish: { - dark: tactileFoundation.dark.actionControlFinish, - light: tactileFoundation.light.actionControlFinish, - }, - color: { - accent: { - dark: tactileFoundation.dark.color.accent, - light: tactileFoundation.light.color.accent, - }, - neutral: { - dark: tactileFoundation.dark.color.neutral, - light: tactileFoundation.light.color.neutral, - }, - }, - depth: { dark: tactileFoundation.dark.depth, light: tactileFoundation.light.depth }, - name: 'tactile', - }; - - const paperInput: ThemeInput = { - actionControlFinish: { - dark: paperFoundation.dark.actionControlFinish, - light: paperFoundation.light.actionControlFinish, - }, - color: { - accent: { - dark: paperFoundation.dark.color.accent, - light: paperFoundation.light.color.accent, - }, - // Paper authors feedback colours in light only; the omitted dark sides default. - danger: { light: paperFoundation.light.color.danger }, - info: { light: paperFoundation.light.color.info }, - neutral: { - dark: paperFoundation.dark.color.neutral, - light: paperFoundation.light.color.neutral, - }, - success: { light: paperFoundation.light.color.success }, - warning: { light: paperFoundation.light.color.warning }, - }, - depth: { dark: paperFoundation.dark.depth, light: paperFoundation.light.depth }, - name: 'paper', - radius: { control: 4 }, - }; - - it('reproduces Tactile byte-for-byte', () => { - expect(defineTheme(tactileInput)).toBe(buildTheme(tactileFoundation)); +/** The 25 leaves the 128-leaf flip removes, by stable `--luke-*` variable name. */ +const REMOVED_VAR_NAMES = [ + '--luke-color-surface-resting', + '--luke-color-surface-disabled', + '--luke-color-border-disabled', + ...['info', 'success', 'warning'].flatMap((intent) => + [ + 'surface-subtle-hover', + 'surface-subtle-pressed', + 'surface-solid', + 'surface-solid-hover', + 'surface-solid-pressed', + 'on-solid', + ].map((leaf) => `--luke-color-intent-${intent}-${leaf}`), + ), + '--luke-motion-duration-medium', + '--luke-motion-duration-slow', + '--luke-motion-duration-ambient', + '--luke-motion-easing-enter', +]; + +/** Extracts the set of unique `--luke-*` variable names declared in a stylesheet. */ +function emittedVarNames(css: string): Set { + return new Set([...css.matchAll(/(--luke-[a-z0-9-]+):/g)].map((match) => match[1] ?? '')); +} + +describe('the reduced 128-leaf contract', () => { + it('flattens to exactly 128 leaves with scrim added and the 25 removed leaves absent', () => { + const names = flattenThemeContract().map(([, varName]) => varName); + expect(names).toHaveLength(128); + expect(names).toContain('--luke-color-scrim'); + // text.disabled kept its stable CSS variable across the rename from color.textDisabled. + expect(names).toContain('--luke-color-text-disabled'); + for (const removed of REMOVED_VAR_NAMES) expect(names).not.toContain(removed); }); +}); + +describe('defineTheme emits the reduced contract for the bundled themes', () => { + const contractNames = flattenThemeContract().map(([, varName]) => varName); + + for (const [name, input] of [ + ['tactile', tactileTheme], + ['paper', paperTheme], + ] as const) { + const css = defineTheme(input); + const emitted = emittedVarNames(css); + + it(`${name} emits exactly the 128 contract variables, including scrim and disabled text`, () => { + expect(emitted.size).toBe(128); + expect([...emitted].sort()).toEqual([...contractNames].sort()); + expect(emitted.has('--luke-color-scrim')).toBe(true); + expect(emitted.has('--luke-color-text-disabled')).toBe(true); + }); - it('reproduces Paper byte-for-byte', () => { - expect(defineTheme(paperInput)).toBe(buildTheme(paperFoundation)); + it(`${name} drops every one of the 25 removed leaves`, () => { + for (const removed of REMOVED_VAR_NAMES) expect(emitted.has(removed)).toBe(false); + }); + + it(`${name} keeps feedback intents static — soft kit only, no solid or state variables`, () => { + for (const intent of ['info', 'success', 'warning']) { + expect(css).not.toContain(`--luke-color-intent-${intent}-surface-solid`); + expect(css).not.toContain(`--luke-color-intent-${intent}-surface-subtle-hover`); + expect(css).not.toContain(`--luke-color-intent-${intent}-surface-subtle-pressed`); + expect(css).not.toContain(`--luke-color-intent-${intent}-on-solid`); + expect(css).toContain(`--luke-color-intent-${intent}-surface-subtle:`); + expect(css).toContain(`--luke-color-intent-${intent}-border:`); + expect(css).toContain(`--luke-color-intent-${intent}-text:`); + } + }); + } + + it('keeps known retained Tactile values byte-identical to the pre-flip baseline', () => { + const css = defineTheme(tactileTheme); + for (const declaration of [ + '--luke-color-surface-canvas: oklch(0.985 0 0);', + '--luke-color-text-primary: oklch(0.225 0 0);', + '--luke-color-text-disabled: oklch(0.58 0.01 210);', + '--luke-color-intent-neutral-surface-subtle: oklch(0.94 0 0);', + '--luke-color-intent-danger-surface-solid: oklch(0.52 0.18 27);', + ]) { + expect(css).toContain(declaration); + } + // Scrim is new: black at the mode-aware default alpha. + expect(css).toContain('--luke-color-scrim: oklch(0 0 0 / 0.2);'); + expect(css).toContain('--luke-color-scrim: oklch(0 0 0 / 0.4);'); }); +}); - it('resolves the omitted dark feedback sides to the curated defaults', () => { - // Sanity-check the parity claim: Paper's dark feedback intents come from defaultSourceColors. - expect(paperFoundation.dark.color.info).toBeUndefined(); - expect(defaultSourceColors.dark.info).toMatch(/^oklch\(/); +describe('the combobox tray scrim adopts the scrim token', () => { + it('replaces the hardcoded rgb(0 0 0 / 20%) literal with vars.color.scrim', async () => { + const source = await readFile(new URL('../recipes/combobox.css.ts', import.meta.url), 'utf8'); + expect(source).not.toContain('rgb(0 0 0 / 20%)'); + expect(source).toContain('vars.color.scrim'); }); }); diff --git a/packages/@luke-ui/react/src/theme/define-theme.ts b/packages/@luke-ui/react/src/theme/define-theme.ts index a3ada9d8..fdbedbc1 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.ts @@ -1,8 +1,8 @@ /** - * The `defineTheme` authoring util: a small, curated-default authoring surface that normalises a - * {@link ThemeInput} into the existing per-mode {@link ThemeFoundation} and hands it to - * {@link buildTheme}. It is additive — `buildTheme` and its raw `ThemeFoundation` stay public — and - * owns the single-value accent/neutral adaptation the raw foundation never had. + * The `defineTheme` authoring util: the sole public theme-authoring surface. It normalises a small, + * curated-default {@link ThemeInput} into the internal per-mode {@link ThemeFoundation} and hands it + * to the internal {@link buildTheme} value pipeline. It owns the single-value accent/neutral + * adaptation and the resolution of curated defaults (materials, radius, scrim). */ import { buildTheme } from './build-theme.js'; @@ -200,22 +200,15 @@ const RADIUS_STEPS = { control: 2, detail: 1, overlay: 4, surface: 3 } as const; * {@link ThemeContrastError} when any resolved pair misses WCAG 2.2 AA. */ export function defineTheme(input: ThemeInput): string { - const { foundation } = normalizeTheme(input); - // The resolved `scrim` is computed by `normalizeTheme` but the emitted contract has no scrim - // leaf yet, so there is nowhere to emit it in this additive slice. Emission lands in #228; until - // then the resolved value is intentionally not consumed here. - return buildTheme(foundation); + return buildTheme(normalizeTheme(input)); } -/** The fully resolved theme: the foundation `buildTheme` consumes plus the not-yet-emitted scrim. */ -interface NormalizedTheme { - foundation: ThemeFoundation; - /** Resolved per-mode scrim. Computed but not emitted until the contract gains a scrim leaf (#228). */ - scrim: Record; -} - -/** Resolves a {@link ThemeInput} into the per-mode foundation and the resolved scrim. */ -function normalizeTheme(input: ThemeInput): NormalizedTheme { +/** + * Resolves a {@link ThemeInput} into the internal per-mode {@link ThemeFoundation} `buildTheme` + * consumes. Exported for internal callers and tests only; it is not part of the public package + * entry, where `defineTheme` is the sole authoring surface. + */ +export function normalizeTheme(input: ThemeInput): ThemeFoundation { const foundation: ThemeFoundation = { dark: buildModeFoundation(input, 'dark'), light: buildModeFoundation(input, 'light'), @@ -223,13 +216,7 @@ function normalizeTheme(input: ThemeInput): NormalizedTheme { radius: resolveRadius(input), }; if (input.typography !== undefined) foundation.typography = input.typography; - return { - foundation, - scrim: { - dark: resolveVerbatimRole(input.color.scrim, 'dark', defaultScrim.dark), - light: resolveVerbatimRole(input.color.scrim, 'light', defaultScrim.light), - }, - }; + return foundation; } /** Resolves one mode's source colours and materials. */ @@ -248,6 +235,9 @@ function resolveColors(input: ThemeInput, mode: ColorMode): ThemeSourceColors { const colors: ThemeSourceColors = { accent: resolveAdaptedRole(color.accent, mode, adaptAccent), neutral: resolveNeutral(color, mode), + // Emitted verbatim; a single string applies to both modes, an omitted side falls back to the + // curated mode-aware default. + scrim: resolveVerbatimRole(color.scrim, mode, defaultScrim[mode]), }; const feedback = { danger: color.danger, diff --git a/packages/@luke-ui/react/src/theme/foundation.ts b/packages/@luke-ui/react/src/theme/foundation.ts index d8608b2e..9a0a2e6e 100644 --- a/packages/@luke-ui/react/src/theme/foundation.ts +++ b/packages/@luke-ui/react/src/theme/foundation.ts @@ -88,7 +88,7 @@ export interface ThemeModeFoundation { } /** Authored action-control face lighting for one colour mode. */ -export interface ActionControlFinishFoundation { +interface ActionControlFinishFoundation { /** Face lighting for a pressed control. */ recessed: string; /** Face lighting for a resting control. */ @@ -119,10 +119,15 @@ export interface ThemeSourceColors { * Luke UI blue for the mode. */ focus?: string; + /** + * Modal-backdrop dimming colour, emitted verbatim (may carry an alpha channel). Required + * internally: `defineTheme` always resolves it, from the author's value or a mode-aware default. + */ + scrim: string; } /** Authored composite `box-shadow` values for one colour mode. */ -export interface ThemeDepthFoundation { +interface ThemeDepthFoundation { /** Inset treatment for a pressed control or sunken surface. */ recessed: string; /** Resting treatment for an interactive control or surface. */ diff --git a/packages/@luke-ui/react/src/theme/foundations.ts b/packages/@luke-ui/react/src/theme/foundations.ts index bb947751..711a81d1 100644 --- a/packages/@luke-ui/react/src/theme/foundations.ts +++ b/packages/@luke-ui/react/src/theme/foundations.ts @@ -1,12 +1,13 @@ -import type { ThemeFoundation } from './foundation.js'; +import type { ThemeInput } from './define-theme.js'; /** - * Foundation for Tactile, the default bundled theme: teal accent, neutral near-white light - * surfaces, lighter chromatic dark surfaces, and a compact tactile material. + * Tactile, the default bundled theme: a teal accent, a neutral near-white light canvas, lighter + * chromatic dark surfaces, and a compact tactile material. Both modes are authored explicitly, so + * `defineTheme` uses each side verbatim. */ -export const tactileFoundation: ThemeFoundation = { - dark: { - actionControlFinish: { +export const tactileTheme: ThemeInput = { + actionControlFinish: { + dark: { raised: [ 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.18) 0%, transparent 100%)', 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.1) 0%, transparent 70%)', @@ -20,11 +21,27 @@ export const tactileFoundation: ThemeFoundation = { 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.07) 0%, transparent 70%)', ].join(', '), }, - color: { - accent: 'oklch(0.75 0.1 200)', - neutral: 'oklch(0.25 0.015 210)', + light: { + raised: [ + 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.3) 0%, transparent 100%)', + 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.16) 0%, transparent 70%)', + ].join(', '), + recessed: [ + 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.12) 0%, transparent 100%)', + 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.06) 0%, transparent 70%)', + ].join(', '), + resting: [ + 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.24) 0%, transparent 100%)', + 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.12) 0%, transparent 70%)', + ].join(', '), }, - depth: { + }, + color: { + accent: { dark: 'oklch(0.75 0.1 200)', light: 'oklch(0.52 0.11 200)' }, + neutral: { dark: 'oklch(0.25 0.015 210)', light: 'oklch(0.985 0 0)' }, + }, + depth: { + dark: { floating: [ '0 4px 12px oklch(0.05 0.01 220 / 0.38)', '0 2px 4px oklch(0.05 0.01 220 / 0.22)', @@ -46,27 +63,7 @@ export const tactileFoundation: ThemeFoundation = { '0 3px 5px -1px oklch(0.05 0.01 220 / 0.26)', ].join(', '), }, - }, - light: { - actionControlFinish: { - raised: [ - 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.3) 0%, transparent 100%)', - 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.16) 0%, transparent 70%)', - ].join(', '), - recessed: [ - 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.12) 0%, transparent 100%)', - 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.06) 0%, transparent 70%)', - ].join(', '), - resting: [ - 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.24) 0%, transparent 100%)', - 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.12) 0%, transparent 70%)', - ].join(', '), - }, - color: { - accent: 'oklch(0.52 0.11 200)', - neutral: 'oklch(0.985 0 0)', - }, - depth: { + light: { floating: [ '0 4px 12px oklch(0.3 0.03 220 / 0.16)', '0 2px 4px oklch(0.3 0.03 220 / 0.1)', @@ -93,12 +90,14 @@ export const tactileFoundation: ThemeFoundation = { }; /** - * Foundation for Paper, the materially minimal bundled theme. Its light mode approximates the flat, - * hairline-bordered Luke UI look with the blue `#185281`-family accent; its dark mode is net-new. + * Paper, the materially minimal bundled theme. Its light mode approximates the flat, + * hairline-bordered Luke UI look with the blue `#185281`-family accent and explicitly authored + * feedback colours; its dark mode is net-new and lets the feedback intents fall back to the curated + * mode defaults (their dark sides are omitted). */ -export const paperFoundation: ThemeFoundation = { - dark: { - actionControlFinish: { +export const paperTheme: ThemeInput = { + actionControlFinish: { + dark: { raised: [ 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.1) 0%, transparent 100%)', 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.05) 0%, transparent 70%)', @@ -109,11 +108,29 @@ export const paperFoundation: ThemeFoundation = { 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.03) 0%, transparent 70%)', ].join(', '), }, - color: { - accent: 'oklch(0.7 0.11 250)', - neutral: 'oklch(0.22 0.01 250)', + light: { + raised: [ + 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.2) 0%, transparent 100%)', + 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.1) 0%, transparent 70%)', + ].join(', '), + recessed: 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.08) 0%, transparent 100%)', + resting: [ + 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.16) 0%, transparent 100%)', + 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.07) 0%, transparent 70%)', + ].join(', '), }, - depth: { + }, + color: { + accent: { dark: 'oklch(0.7 0.11 250)', light: '#185281' }, + // Feedback colours are authored for light only; the omitted dark sides default per mode. + danger: { light: '#c0262e' }, + info: { light: '#1d39c4' }, + neutral: { dark: 'oklch(0.22 0.01 250)', light: '#ffffff' }, + success: { light: '#306317' }, + warning: { light: '#d89614' }, + }, + depth: { + dark: { floating: '0 4px 14px oklch(0.12 0.01 250 / 0.25)', overlay: '0 12px 36px oklch(0.12 0.01 250 / 0.32)', raised: [ @@ -126,28 +143,7 @@ export const paperFoundation: ThemeFoundation = { '0 1px 2px oklch(0.12 0.01 250 / 0.06)', ].join(', '), }, - }, - light: { - actionControlFinish: { - raised: [ - 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.2) 0%, transparent 100%)', - 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.1) 0%, transparent 70%)', - ].join(', '), - recessed: 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.08) 0%, transparent 100%)', - resting: [ - 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.16) 0%, transparent 100%)', - 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.07) 0%, transparent 70%)', - ].join(', '), - }, - color: { - accent: '#185281', - danger: '#c0262e', - info: '#1d39c4', - neutral: '#ffffff', - success: '#306317', - warning: '#d89614', - }, - depth: { + light: { floating: '0 4px 14px oklch(0.2 0.01 250 / 0.12)', overlay: '0 12px 36px oklch(0.2 0.01 250 / 0.16)', raised: [ diff --git a/packages/@luke-ui/react/src/theme/index.tsx b/packages/@luke-ui/react/src/theme/index.tsx index 50d2a2e7..596d58d6 100644 --- a/packages/@luke-ui/react/src/theme/index.tsx +++ b/packages/@luke-ui/react/src/theme/index.tsx @@ -11,15 +11,11 @@ export const themeRootClassName = cx(lukeUiClassNames.themeRoot, lukeUiClassName export { vars } from './contract.css.js'; /** - * `buildTheme(foundation)` compiles a typed theme foundation into complete stylesheet text: pure - * and Node-compatible, containing the theme identity class plus both colour-mode blocks. It throws - * {@link ThemeContrastError} naming the mode and token pair when a generated pair misses WCAG 2.2 - * AA (4.5:1 for text, 3:1 for non-text UI). Colours are computed and emitted in OKLCH. - * - * `themeClassName(name)` returns the identity class for a theme name. `ThemeContrastError` carries - * every failing mode-and-pair in its `failures` array. + * `themeClassName(name)` returns the identity class for a theme name. `ThemeContrastError` is thrown + * by `defineTheme` when a resolved pair misses WCAG 2.2 AA (4.5:1 for text, 3:1 for non-text UI); it + * carries every failing mode-and-pair in its `failures` array. */ -export { buildTheme, ThemeContrastError, themeClassName } from './build-theme.js'; +export { ThemeContrastError, themeClassName } from './build-theme.js'; /** One WCAG contrast failure recorded on a {@link ThemeContrastError}. */ export type { ThemeContrastFailure } from './build-theme.js'; @@ -40,15 +36,6 @@ export type { ColorInput, ControlFinish, DepthLadder, ThemeInput } from './defin /** Curated defaults `defineTheme` applies for omitted materials and scrim. */ export { defaultControlFinish, defaultDepth, defaultScrim } from './define-theme.js'; -/** The typed theme-foundation contract accepted by `buildTheme`. */ -export type { - ActionControlFinishFoundation, - ThemeDepthFoundation, - ThemeFoundation, - ThemeModeFoundation, - ThemeSourceColors, -} from './foundation.js'; - /** Derives a concentric outer corner from an inner radius plus the intervening gap. */ export { deriveConcentricRadius } from './foundation.js'; diff --git a/packages/@luke-ui/react/src/theme/tokens.stories.tsx b/packages/@luke-ui/react/src/theme/tokens.stories.tsx index 392d0bbe..6feb5734 100644 --- a/packages/@luke-ui/react/src/theme/tokens.stories.tsx +++ b/packages/@luke-ui/react/src/theme/tokens.stories.tsx @@ -77,24 +77,22 @@ interface ValueSample { const surfaceSamples: Array = [ { label: 'Canvas', value: vars.color.surface.canvas }, { label: 'Recessed', value: vars.color.surface.recessed }, - { label: 'Resting', value: vars.color.surface.resting }, { label: 'Floating', value: vars.color.surface.floating }, { label: 'Overlay', value: vars.color.surface.overlay }, - { label: 'Disabled', value: vars.color.surfaceDisabled }, + { label: 'Scrim', value: vars.color.scrim }, { label: 'Loading skeleton', value: vars.color.loadingSkeleton }, ]; const textSamples: Array = [ { label: 'Primary', value: vars.color.text.primary }, { label: 'Secondary', value: vars.color.text.secondary }, - { label: 'Disabled', value: vars.color.textDisabled }, + { label: 'Disabled', value: vars.color.text.disabled }, ]; const borderSamples: Array = [ { label: 'Decorative', value: vars.color.border.decorative }, { label: 'Control', value: vars.color.border.control }, { label: 'Focus', value: vars.color.border.focus }, - { label: 'Disabled', value: vars.color.borderDisabled }, ]; const intentSamples: Array = [ @@ -137,35 +135,20 @@ const intentSamples: Array = [ { key: 'info', label: 'Info', - roles: intentRoles( - vars.color.intent.info.border, - vars.color.intent.info.text, - vars.color.intent.info.onSolid, - vars.color.intent.info.surface.solid, - ), - surfaces: intentSurfaceSamples(vars.color.intent.info.surface), + roles: feedbackRoles(vars.color.intent.info.border, vars.color.intent.info.text), + surfaces: [{ label: 'Subtle', value: vars.color.intent.info.surface.subtle }], }, { key: 'success', label: 'Success', - roles: intentRoles( - vars.color.intent.success.border, - vars.color.intent.success.text, - vars.color.intent.success.onSolid, - vars.color.intent.success.surface.solid, - ), - surfaces: intentSurfaceSamples(vars.color.intent.success.surface), + roles: feedbackRoles(vars.color.intent.success.border, vars.color.intent.success.text), + surfaces: [{ label: 'Subtle', value: vars.color.intent.success.surface.subtle }], }, { key: 'warning', label: 'Warning', - roles: intentRoles( - vars.color.intent.warning.border, - vars.color.intent.warning.text, - vars.color.intent.warning.onSolid, - vars.color.intent.warning.surface.solid, - ), - surfaces: intentSurfaceSamples(vars.color.intent.warning.surface), + roles: feedbackRoles(vars.color.intent.warning.border, vars.color.intent.warning.text), + surfaces: [{ label: 'Subtle', value: vars.color.intent.warning.surface.subtle }], }, { key: 'danger', @@ -257,28 +240,12 @@ const motionSamples: Array = [ label: 'Fast / standard', }, { - accessibleLabel: 'Medium enter motion sample', - duration: vars.motion.duration.medium, - durationKey: 'medium', - easing: vars.motion.easing.enter, - easingKey: 'enter', - label: 'Medium / enter', - }, - { - accessibleLabel: 'Slow exit motion sample', - duration: vars.motion.duration.slow, - durationKey: 'slow', + accessibleLabel: 'Fast exit motion sample', + duration: vars.motion.duration.fast, + durationKey: 'fast', easing: vars.motion.easing.exit, easingKey: 'exit', - label: 'Slow / exit', - }, - { - accessibleLabel: 'Ambient standard motion sample', - duration: vars.motion.duration.ambient, - durationKey: 'ambient', - easing: vars.motion.easing.standard, - easingKey: 'standard', - label: 'Ambient / standard', + label: 'Fast / exit', }, ]; @@ -423,8 +390,8 @@ export const Reference = meta.story({ const mediumControl = canvas.getByRole('img', { name: 'Control medium size sample' }); await expect(getComputedStyle(mediumControl).blockSize).toBe('40px'); - const mediumMotion = canvas.getByRole('img', { name: 'Medium enter motion sample' }); - await expect(getComputedStyle(mediumMotion).animationDuration).toBe('0.2s'); + const fastMotion = canvas.getByRole('img', { name: 'Fast standard motion sample' }); + await expect(getComputedStyle(fastMotion).animationDuration).toBe('0.12s'); const resolvedCanvas = canvasElement.querySelector('[data-token-path="color.surface.canvas"]'); await expect(resolvedCanvas).not.toBeNull(); @@ -461,10 +428,7 @@ function ThemeTokens() { { - const path = - sample.label === 'Disabled' - ? 'color.textDisabled' - : `color.text.${toKey(sample.label)}`; + const path = `color.text.${toKey(sample.label)}`; return tokenRow( path, , @@ -478,10 +442,7 @@ function ThemeTokens() { { - const path = - sample.label === 'Disabled' - ? 'color.borderDisabled' - : `color.border.${toKey(sample.label)}`; + const path = `color.border.${toKey(sample.label)}`; return tokenRow( path, , @@ -776,7 +737,7 @@ function BorderPreview({ label, value }: { label: string; value: string }) { role="img" style={{ ...swatchStyle, - backgroundColor: vars.color.surface.resting, + backgroundColor: vars.color.surface.recessed, borderColor: value, borderWidth: 3, }} @@ -818,7 +779,7 @@ function DepthPreview({ label, value }: { label: string; value: string }) { ); } @@ -952,13 +913,21 @@ function intentRoles( ]; } +/** Roles for a static feedback intent: soft border and text only (no solid surface or onSolid). */ +function feedbackRoles(border: string, text: string): Array { + return [ + { key: 'border', label: 'Border', preview: 'border', value: border }, + { key: 'text', label: 'Text', preview: 'text', value: text }, + ]; +} + function customPropertyName(value: string): string { const match = /^var\((--[^,)]+)/.exec(value); return match?.[1] ?? value; } function surfacePath(label: string): string { - if (label === 'Disabled') return 'color.surfaceDisabled'; + if (label === 'Scrim') return 'color.scrim'; if (label === 'Loading skeleton') return 'color.loadingSkeleton'; return `color.surface.${toKey(label)}`; } diff --git a/packages/@luke-ui/react/src/themes/index.ts b/packages/@luke-ui/react/src/themes/index.ts index 8a7daa02..e332c1d7 100644 --- a/packages/@luke-ui/react/src/themes/index.ts +++ b/packages/@luke-ui/react/src/themes/index.ts @@ -1,5 +1,5 @@ import { themeClassName } from '../theme/build-theme.js'; -import { paperFoundation, tactileFoundation } from '../theme/foundations.js'; +import { paperTheme, tactileTheme } from '../theme/foundations.js'; /** * Identity class for the Tactile theme, the Luke UI default. Apply it to `` or a @@ -7,11 +7,11 @@ import { paperFoundation, tactileFoundation } from '../theme/foundations.js'; * `data-color-mode` to follow the system preference, or set it to `light` or `dark` on a nested * scope. */ -export const tactileThemeClassName = themeClassName(tactileFoundation.name); +export const tactileThemeClassName = themeClassName(tactileTheme.name); /** * Identity class for the Paper theme. Apply it to `` or a subtree root together with the * `@luke-ui/react/themes/paper.css` stylesheet. Omit `data-color-mode` to follow the system * preference, or set it to `light` or `dark` on a nested scope. */ -export const paperThemeClassName = themeClassName(paperFoundation.name); +export const paperThemeClassName = themeClassName(paperTheme.name); -- 2.51.2