@@ -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);