diff --git a/apps/docs/content/docs/authoring-a-theme.mdx b/apps/docs/content/docs/authoring-a-theme.mdx
index cf7bd88f..5f038359 100644
--- a/apps/docs/content/docs/authoring-a-theme.mdx
+++ b/apps/docs/content/docs/authoring-a-theme.mdx
@@ -35,9 +35,35 @@ The minimal theme authors one colour and a neutral character:
-Start from a bundled theme instead of a blank input. Each bundled theme's entrypoint, for example
-`@luke-ui/react/themes/tactile`, exports `theme`, its own `ThemeInput`. Read it, copy it, or spread
-it into your own `defineTheme` call.
+## Extend a bundled theme
+
+Set `extends` to another theme's input. Your theme inherits every value it does not set. Each
+bundled theme entrypoint, such as `@luke-ui/react/themes/tactile`, exports its input as `theme`.
+
+
+
+Your theme always declares its own `name`. Two themes with one name emit a colliding identity class.
+
+What each section inherits:
+
+- `color` merges role by role. A role replaces the base's role whole.
+- `color.neutral` and `color.neutralStyle` are one decision. Setting either replaces the base's
+ neutral decision.
+- `radius` and `typography.fontWeight` merge key by key.
+- `typography.fontFamily` replaces the base value.
+- `depth` and `actionControlFinish` merge per mode, then per rung.
+
+A value set to `undefined` counts as omitted and inherits.
+
+A theme may extend a theme that extends another. A cycle throws.
+
+Contrast validation runs on the merged theme. `ThemeContrastError` carries `inheritance`, naming the
+chain of themes and which colours came from a base.
+
+
## Generate a stylesheet
diff --git a/apps/docs/src/samples/theming/extend-bundled-theme.tsx b/apps/docs/src/samples/theming/extend-bundled-theme.tsx
new file mode 100644
index 00000000..2c85ee92
--- /dev/null
+++ b/apps/docs/src/samples/theming/extend-bundled-theme.tsx
@@ -0,0 +1,8 @@
+import { defineTheme } from '@luke-ui/react/theme';
+import { theme as tactileTheme } from '@luke-ui/react/themes/tactile';
+
+export const css = defineTheme({
+ color: { accent: '#3b82f6' },
+ extends: tactileTheme,
+ name: 'product',
+});
diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts
index 2b95c396..b03bc7df 100644
--- a/packages/@luke-ui/react/src/theme/build-theme.ts
+++ b/packages/@luke-ui/react/src/theme/build-theme.ts
@@ -12,6 +12,7 @@ import type {
} from './diagnostics.js';
import type { GeneratedSurfaces } from './elevation.js';
import { generateSurfaces } from './elevation.js';
+import type { ThemeInheritance } from './extend-theme.js';
import type {
SOURCE_COLOR_FIELDS,
ThemeFoundation,
@@ -68,15 +69,21 @@ export function buildTheme(foundation: ThemeFoundation): string {
// now that the failure shape lives in its own module.
export type { ThemeContrastFailure } from './contrast-validation.js';
+/** The colour provenance `defineTheme` records for a theme built from an `extends` chain. */
+export type { ThemeInheritance } from './extend-theme.js';
+
/**
* Thrown by `buildTheme` when generated colours miss WCAG 2.2 AA contrast. Aggregates every
- * failing mode-and-pair before throwing, one per message line.
+ * failing mode-and-pair before throwing, one per message line. For a theme built with `extends`,
+ * `inheritance` names the chain of themes and which colours came from a base.
*/
export class ThemeContrastError extends Error {
/** Every failing pair across both modes. */
readonly failures: Array;
+ /** The colour provenance, or `null` when the theme extends nothing. */
+ readonly inheritance: ThemeInheritance | null;
- constructor(failures: Array) {
+ constructor(failures: Array, inheritance: ThemeInheritance | null = null) {
super(
[
'Theme foundation fails WCAG 2.2 AA contrast:',
@@ -86,13 +93,29 @@ export class ThemeContrastError extends Error {
`${failure.ratio.toFixed(2)}:1 < ${failure.required}:1`
);
}),
+ ...inheritanceLines(inheritance),
].join('\n'),
);
this.failures = failures;
+ this.inheritance = inheritance;
this.name = 'ThemeContrastError';
}
}
+/** The message lines that trace a failing theme back to the themes its colours came from. */
+function inheritanceLines(inheritance: ThemeInheritance | null): Array {
+ if (inheritance === null) return [];
+ const provenance = [
+ inheritance.inheritedColors.length > 0
+ ? `Inherited colours: ${inheritance.inheritedColors.join(', ')}.`
+ : '',
+ inheritance.ownColors.length > 0 ? `Own colours: ${inheritance.ownColors.join(', ')}.` : '',
+ ].filter((sentence) => sentence !== '');
+ const lines = [`Theme ${inheritance.chain.map((name) => `"${name}"`).join(' extends ')}.`];
+ if (provenance.length > 0) lines.push(provenance.join(' '));
+ return lines;
+}
+
/**
* Thrown by {@link compileTheme} when a role that must guarantee on-solid contrast cannot reach an
* accessible solid — for example an explicit per-mode accent whose whole tone band is an on-solid
diff --git a/packages/@luke-ui/react/src/theme/define-theme.ts b/packages/@luke-ui/react/src/theme/define-theme.ts
index c3d54069..d56366b1 100644
--- a/packages/@luke-ui/react/src/theme/define-theme.ts
+++ b/packages/@luke-ui/react/src/theme/define-theme.ts
@@ -5,10 +5,11 @@
* adaptation and the resolution of curated defaults (materials, radius, scrim).
*/
-import { buildTheme } from './build-theme.js';
+import { buildTheme, ThemeContrastError } from './build-theme.js';
import type { Oklch } from './color.js';
import { clampUnit, formatOklch, gamutMapOklch, parseColor } from './color.js';
import { CONTRAST_SEARCH_STEP, TEXT_RATIO } from './contrast-policy.js';
+import { resolveThemeInput } from './extend-theme.js';
import type { ThemeFoundation, ThemeModeFoundation, ThemeSourceColors } from './foundation.js';
import { defaultSourceColors } from './foundation.js';
import { passesOnSolidGate } from './scale.js';
@@ -46,47 +47,15 @@ export interface ControlFinish {
}
/**
- * The curated theme-authoring input. A basic theme authors an accent and a neutral character and
- * lets everything else default; materials are optional and deep-partial; light and dark stay
- * independently authorable.
+ * The theme identity plus the optional sections a theme inherits key by key. Shared by a theme that
+ * authors its own accent and one that extends another theme.
*/
-export interface ThemeInput {
+interface ThemeInputCommon {
/**
* Kebab-case theme identity, for example `'tactile'`. The theme's identity class is
* `luke-ui-theme-${name}`.
*/
name: string;
- /** Source colours. Each is one value (adapted per mode) or an explicit `{ light, dark }` pair. */
- color: {
- /** Required — the brand or interaction accent. */
- accent: ColorInput;
- /** Neutral canvas anchor. Give a raw colour, or set `neutralStyle` for a curated neutral. */
- neutral?: ColorInput;
- /**
- * Curated neutral character when `neutral` is omitted; sets the neutral hue and tint while the
- * mode sets its lightness.
- * @default 'neutral'
- */
- neutralStyle?: 'cool' | 'neutral' | 'warm';
- /**
- * The canvas anchor, split from `neutral`'s hue/chroma character. Give a raw colour to move the
- * canvas away from the resolved neutral while keeping the neutral family's own character.
- * Defaults to the resolved neutral canvas anchor.
- */
- background?: ColorInput;
- /** Source colour for the `info` role. Defaults to an accessible Luke UI blue for the mode. */
- info?: ColorInput;
- /** Source colour for the `success` role. Defaults to an accessible Luke UI green for the mode. */
- success?: ColorInput;
- /** Source colour for the `warning` role. Defaults to an accessible Luke UI amber for the mode. */
- warning?: ColorInput;
- /** Source colour for the `danger` role. Defaults to an accessible Luke UI red for the mode. */
- danger?: ColorInput;
- /** Keyboard-focus ring colour, used verbatim after gamut mapping. Defaults per mode. */
- focus?: ColorInput;
- /** Modal-backdrop dimming colour, used verbatim; defaults to black at a mode-aware alpha. */
- scrim?: ColorInput;
- };
/** Typography — family and weights only. The type scale is source-owned (not authored here). */
typography?: {
/**
@@ -132,6 +101,62 @@ export interface ThemeInput {
actionControlFinish?: { light?: Partial; dark?: Partial };
}
+/**
+ * The curated theme-authoring input for a theme that authors its own accent. A basic theme authors
+ * an accent and a neutral character and lets everything else default. Materials are optional and
+ * deep-partial, and light and dark stay independently authorable. A theme that starts from another
+ * theme instead uses {@link ExtendingThemeInput}.
+ */
+export interface ThemeInput extends ThemeInputCommon {
+ /** Source colours. Each is one value (adapted per mode) or an explicit `{ light, dark }` pair. */
+ color: {
+ /** Required — the brand or interaction accent. */
+ accent: ColorInput;
+ /** Neutral canvas anchor. Give a raw colour, or set `neutralStyle` for a curated neutral. */
+ neutral?: ColorInput;
+ /**
+ * Curated neutral character when `neutral` is omitted; sets the neutral hue and tint while the
+ * mode sets its lightness.
+ * @default 'neutral'
+ */
+ neutralStyle?: 'cool' | 'neutral' | 'warm';
+ /**
+ * The canvas anchor, split from `neutral`'s hue/chroma character. Give a raw colour to move the
+ * canvas away from the resolved neutral while keeping the neutral family's own character.
+ * Defaults to the resolved neutral canvas anchor.
+ */
+ background?: ColorInput;
+ /** Source colour for the `info` role. Defaults to an accessible Luke UI blue for the mode. */
+ info?: ColorInput;
+ /** Source colour for the `success` role. Defaults to an accessible Luke UI green for the mode. */
+ success?: ColorInput;
+ /** Source colour for the `warning` role. Defaults to an accessible Luke UI amber for the mode. */
+ warning?: ColorInput;
+ /** Source colour for the `danger` role. Defaults to an accessible Luke UI red for the mode. */
+ danger?: ColorInput;
+ /** Keyboard-focus ring colour, used verbatim after gamut mapping. Defaults per mode. */
+ focus?: ColorInput;
+ /** Modal-backdrop dimming colour, used verbatim; defaults to black at a mode-aware alpha. */
+ scrim?: ColorInput;
+ };
+ /**
+ * A theme to start from. Every value this theme leaves out comes from the base. `name` never
+ * inherits, so an extending theme always declares its own identity.
+ */
+ extends?: ThemeInput | ExtendingThemeInput;
+}
+
+/**
+ * A theme that starts from another theme. It declares its own `name` and overrides any part of the
+ * base.
+ */
+export interface ExtendingThemeInput extends ThemeInputCommon {
+ /** The theme to start from. */
+ extends: ThemeInput | ExtendingThemeInput;
+ /** Source-colour overrides. Every role the theme leaves out comes from the base. */
+ color?: Partial;
+}
+
type ColorMode = 'light' | 'dark';
/**
@@ -197,23 +222,34 @@ const DEFAULT_RADIUS_MULTIPLIER = 1;
const RADIUS_STEPS = { control: 2, detail: 1, overlay: 4, surface: 3 } as const;
/**
- * Compiles a curated {@link ThemeInput} into a complete static stylesheet. Normalises the input
- * into the per-mode {@link ThemeFoundation} shape — adapting single-value accents and neutrals per
- * mode, generating the radius scale, and merging materials over curated defaults — then delegates
- * to {@link buildTheme}, whose build-time contrast validation stays authoritative. Throws when a
- * single-value accent has no accessible lightness in a mode, and (via `buildTheme`) throws
- * {@link ThemeContrastError} when any resolved pair misses WCAG 2.2 AA.
+ * Compiles a curated {@link ThemeInput} into a complete static stylesheet. Resolves any `extends`
+ * chain into one merged input first, then normalises it into the per-mode {@link ThemeFoundation}
+ * shape — adapting single-value accents and neutrals per mode, generating the radius scale, and
+ * merging materials over curated defaults — then delegates to {@link buildTheme}, whose build-time
+ * contrast validation stays authoritative. Throws when a single-value accent has no accessible
+ * lightness in a mode, and (via `buildTheme`) throws {@link ThemeContrastError} when any resolved
+ * pair misses WCAG 2.2 AA.
*/
-export function defineTheme(input: ThemeInput): string {
- return buildTheme(normalizeTheme(input));
+export function defineTheme(input: ThemeInput | ExtendingThemeInput): string {
+ const resolved = resolveThemeInput(input);
+ try {
+ return buildTheme(normalizeTheme(resolved.input));
+ } catch (error) {
+ if (error instanceof ThemeContrastError && resolved.inheritance !== null) {
+ throw new ThemeContrastError(error.failures, resolved.inheritance);
+ }
+ throw error;
+ }
}
/**
* 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.
+ * consumes, resolving any `extends` chain into one merged input first. 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 {
+export function normalizeTheme(themeInput: ThemeInput | ExtendingThemeInput): ThemeFoundation {
+ const input = resolveThemeInput(themeInput).input;
const foundation: ThemeFoundation = {
dark: buildModeFoundation(input, 'dark'),
light: buildModeFoundation(input, 'light'),
diff --git a/packages/@luke-ui/react/src/theme/extend-theme.test.ts b/packages/@luke-ui/react/src/theme/extend-theme.test.ts
new file mode 100644
index 00000000..5e847cec
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/extend-theme.test.ts
@@ -0,0 +1,232 @@
+import { describe, expect, it } from 'vite-plus/test';
+import { splitBlocks } from './__fixtures__/theme-css.js';
+import { ThemeContrastError } from './build-theme.js';
+import { parseColor } from './color.js';
+import type { ExtendingThemeInput, ThemeInput } from './define-theme.js';
+import { defaultDepth, defineTheme, normalizeTheme } from './define-theme.js';
+import { tactileTheme } from './foundations/tactile.js';
+
+/** Every `--luke-*` declaration in a stylesheet, keyed by rule block and variable name. */
+function declarations(css: string): Array<[string, string]> {
+ return Object.entries(splitBlocks(css)).flatMap(([blockName, block]) => {
+ return [...block.matchAll(/(--luke-[a-z0-9-]+): ([^;]+);/g)].map((match): [string, string] => [
+ `${blockName} ${match[1] ?? ''}`,
+ match[2] ?? '',
+ ]);
+ });
+}
+
+describe('theme inheritance', () => {
+ it('emits the base stylesheet byte for byte when a theme extends it with no overrides', () => {
+ // The extending theme repeats the base's own name on purpose, so the comparison covers the
+ // whole stylesheet including the identity class.
+ expect(defineTheme({ extends: tactileTheme, name: 'tactile' })).toBe(defineTheme(tactileTheme));
+ });
+
+ it('overrides the accent without touching another token, under the extending name', () => {
+ const reference = defineTheme({ ...tactileTheme, name: 'product' });
+ const subject = defineTheme({
+ color: { accent: '#3b82f6' },
+ extends: tactileTheme,
+ name: 'product',
+ });
+ const withoutAccent = (css: string) =>
+ declarations(css).filter(([name]) => !name.includes('accent'));
+ const accentOnly = (css: string) =>
+ declarations(css).filter(([name]) => name.includes('accent'));
+
+ expect(withoutAccent(subject)).toEqual(withoutAccent(reference));
+ expect(accentOnly(subject)).not.toEqual(accentOnly(reference));
+
+ expect(splitBlocks(subject).identity).toContain('.luke-ui-theme-product');
+ expect(splitBlocks(subject).identity).not.toContain('luke-ui-theme-tactile');
+ });
+
+ it('inherits every colour role a base authors', () => {
+ const base: ThemeInput = {
+ color: {
+ accent: '#3b82f6',
+ background: 'oklch(0.6 0.02 260)',
+ danger: { dark: 'oklch(0.72 0.16 25)', light: 'oklch(0.52 0.18 27)' },
+ focus: { dark: 'oklch(0.72 0.13 255)', light: 'oklch(0.55 0.17 255)' },
+ info: { dark: 'oklch(0.72 0.13 255)', light: 'oklch(0.52 0.16 255)' },
+ neutral: 'oklch(0.5 0.01 260)',
+ neutralStyle: 'cool',
+ scrim: 'oklch(0 0 0 / 0.3)',
+ success: { dark: 'oklch(0.74 0.13 150)', light: 'oklch(0.5 0.13 150)' },
+ warning: { dark: 'oklch(0.78 0.13 80)', light: 'oklch(0.72 0.14 75)' },
+ },
+ name: 'all-roles',
+ };
+
+ expect(defineTheme({ extends: base, name: 'all-roles' })).toBe(defineTheme(base));
+ });
+
+ it('replaces a colour role whole rather than merging it per mode', () => {
+ const base: ThemeInput = {
+ color: { accent: { dark: 'oklch(0.75 0.1 200)', light: 'oklch(0.52 0.11 200)' } },
+ name: 'pair-accent',
+ };
+ const foundation = normalizeTheme({
+ color: { accent: 'oklch(0.6 0.15 30)' },
+ extends: base,
+ name: 'string-accent',
+ });
+
+ expect(foundation.light.color.accent).not.toBe('oklch(0.52 0.11 200)');
+ expect(foundation.dark.color.accent).not.toBe('oklch(0.75 0.1 200)');
+
+ const light = parseColor(foundation.light.color.accent);
+ const dark = parseColor(foundation.dark.color.accent);
+ expect(light.h).toBeCloseTo(30, 0);
+ expect(dark.h).toBeCloseTo(30, 0);
+ expect(light.l).toBeCloseTo(0.5, 1);
+ expect(dark.l).toBeCloseTo(0.72, 1);
+ });
+
+ it('treats the neutral character as one decision', () => {
+ const base: ThemeInput = {
+ color: {
+ accent: '#3b82f6',
+ neutral: { dark: 'oklch(0.25 0.02 210)', light: 'oklch(0.98 0 0)' },
+ },
+ name: 'pair-neutral',
+ };
+ const baseFoundation = normalizeTheme(base);
+ const foundation = normalizeTheme({
+ color: { neutralStyle: 'warm' },
+ extends: base,
+ name: 'warm-neutral',
+ });
+
+ // The extending theme's `neutralStyle` decides the canvas, so the inherited raw `neutral` went
+ // with it rather than shadowing the style.
+ expect(foundation.light.color.neutral).not.toBe(baseFoundation.light.color.neutral);
+ expect(parseColor(foundation.light.color.neutral).h).toBeCloseTo(70, 0);
+ });
+
+ it('inherits materials per rung and radius per step', () => {
+ const base: ThemeInput = {
+ color: { accent: '#3b82f6' },
+ depth: {
+ dark: { overlay: 'base-dark-overlay' },
+ light: { overlay: 'base-light-overlay', resting: 'base-light-resting' },
+ },
+ name: 'material-base',
+ radius: { base: 4, control: 10 },
+ };
+ const foundation = normalizeTheme({
+ depth: { light: { overlay: 'own-light-overlay', resting: undefined } },
+ extends: base,
+ name: 'material-child',
+ radius: { base: 8 },
+ });
+
+ expect(foundation.light.depth.overlay).toBe('own-light-overlay');
+ // A rung authored as `undefined` reads as omitted, so it inherits rather than resetting.
+ expect(foundation.light.depth.resting).toBe('base-light-resting');
+ // A rung neither theme sets still falls back to the curated default.
+ expect(foundation.light.depth.floating).toBe(defaultDepth.light.floating);
+ // Dark is untouched by a light-only override.
+ expect(foundation.dark.depth.overlay).toBe('base-dark-overlay');
+
+ // The base's pinned `control` survives, and every other step regenerates from the new base.
+ expect(foundation.radius).toEqual({ control: 10, detail: 8, overlay: 32, surface: 24 });
+ });
+
+ it('replaces the font family and merges the font weights', () => {
+ const base: ThemeInput = {
+ color: { accent: '#3b82f6' },
+ name: 'type-base',
+ typography: { fontFamily: 'dm-sans', fontWeight: { body: 300, heading: 800 } },
+ };
+ const foundation = normalizeTheme({
+ extends: base,
+ name: 'type-child',
+ typography: { fontFamily: 'apple-system', fontWeight: { body: 400 } },
+ });
+
+ expect(foundation.typography).toEqual({
+ fontFamily: 'apple-system',
+ fontWeight: { body: 400, heading: 800 },
+ });
+ });
+
+ it('resolves a chain of three, and throws when a chain forms a cycle', () => {
+ const root: ThemeInput = {
+ color: {
+ accent: '#3b82f6',
+ success: { dark: 'oklch(0.8 0.12 150)', light: 'oklch(0.45 0.12 150)' },
+ },
+ name: 'root',
+ };
+ const middle: ExtendingThemeInput = {
+ color: { accent: '#ef4444' },
+ extends: root,
+ name: 'middle',
+ };
+ const foundation = normalizeTheme({ extends: middle, name: 'leaf' });
+
+ // A role only the innermost base sets reaches the outermost theme through the middle theme.
+ expect(foundation.light.color.success).toBe('oklch(0.45 0.12 150)');
+ expect(foundation.dark.color.success).toBe('oklch(0.8 0.12 150)');
+
+ const first: ThemeInput = { color: { accent: '#3b82f6' }, name: 'first' };
+ const second: ExtendingThemeInput = { extends: first, name: 'second' };
+ first.extends = second;
+ expect(() => defineTheme(first)).toThrow(/"first".*"second"/);
+ });
+
+ it('names the colour provenance on a contrast failure', () => {
+ // A near-white focus ring misses the hard 3:1 gate for `color.border.focus` against Tactile's
+ // light canvas.
+ let thrown: unknown = null;
+ try {
+ defineTheme({
+ color: { focus: 'oklch(0.99 0 0)' },
+ extends: tactileTheme,
+ name: 'low-contrast-focus',
+ });
+ } catch (error) {
+ thrown = error;
+ }
+
+ expect(thrown).toBeInstanceOf(ThemeContrastError);
+ if (!(thrown instanceof ThemeContrastError)) return;
+ expect(thrown.failures.map((failure) => failure.foreground)).toContain('color.border.focus');
+ expect(thrown.inheritance?.chain).toEqual(['low-contrast-focus', 'tactile']);
+ expect(thrown.inheritance?.ownColors).toContain('color.focus');
+ expect(thrown.inheritance?.inheritedColors).toContain('color.accent');
+ expect(thrown.inheritance?.inheritedColors).toContain('color.neutral');
+ });
+
+ it('does not report a colour a later theme in the chain discarded', () => {
+ const root: ThemeInput = {
+ color: {
+ accent: '#3b82f6',
+ neutral: { dark: 'oklch(0.25 0.02 210)', light: 'oklch(0.98 0 0)' },
+ },
+ name: 'root',
+ };
+ const middle: ExtendingThemeInput = {
+ color: { focus: 'oklch(0.99 0 0)', neutralStyle: 'warm' },
+ extends: root,
+ name: 'middle',
+ };
+ let thrown: unknown = null;
+ try {
+ defineTheme({ extends: middle, name: 'leaf' });
+ } catch (error) {
+ thrown = error;
+ }
+
+ expect(thrown).toBeInstanceOf(ThemeContrastError);
+ if (!(thrown instanceof ThemeContrastError)) return;
+ // middle's neutralStyle discards root's neutral, so the merge carries no neutral to inherit.
+ expect(thrown.inheritance?.chain).toEqual(['leaf', 'middle', 'root']);
+ expect(thrown.inheritance?.ownColors).toEqual([]);
+ expect(thrown.inheritance?.inheritedColors).toContain('color.accent');
+ expect(thrown.inheritance?.inheritedColors).toContain('color.neutralStyle');
+ expect(thrown.inheritance?.inheritedColors).not.toContain('color.neutral');
+ });
+});
diff --git a/packages/@luke-ui/react/src/theme/extend-theme.ts b/packages/@luke-ui/react/src/theme/extend-theme.ts
new file mode 100644
index 00000000..048033c0
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/extend-theme.ts
@@ -0,0 +1,218 @@
+/**
+ * Inheritance between theme-authoring inputs. `resolveThemeInput` folds an `extends` chain into one
+ * {@link ThemeInput}, and records which colours came from a base.
+ *
+ * A theme's values behave as if an author wrote them on top of the base in one input.
+ */
+
+import type { ExtendingThemeInput, ThemeInput } from './define-theme.js';
+
+/** Which colours a theme authored, and which it inherited. Carried by `ThemeContrastError`. */
+export interface ThemeInheritance {
+ /** Theme names, the extending theme first and the innermost base last. */
+ chain: Array;
+ /** Colour roles the theme took from a base, for example `color.accent`. */
+ inheritedColors: Array;
+ /** Colour roles the theme authored itself. */
+ ownColors: Array;
+}
+
+/** A theme input with its `extends` chain already resolved. */
+export interface ResolvedThemeInput {
+ /** The merged input, with no `extends` left to resolve. */
+ input: ThemeInput;
+ /** The colour provenance, or `null` when the theme extends nothing. */
+ inheritance: ThemeInheritance | null;
+}
+
+/** Colour roles in `ThemeInput['color']` declaration order, for a stable provenance report. */
+const COLOR_ROLES = [
+ 'accent',
+ 'neutral',
+ 'neutralStyle',
+ 'background',
+ 'info',
+ 'success',
+ 'warning',
+ 'danger',
+ 'focus',
+ 'scrim',
+] as const satisfies ReadonlyArray;
+
+/** The two keys that spell one neutral decision. `inheritColor` inherits them together. */
+const NEUTRAL_ROLES = ['neutral', 'neutralStyle'] as const satisfies ReadonlyArray<
+ keyof ThemeInput['color']
+>;
+
+/** Whether a colour input authors the neutral character under either of its two keys. */
+function authorsNeutral(color: Partial | undefined): boolean {
+ return NEUTRAL_ROLES.some((role) => color?.[role] !== undefined);
+}
+
+/**
+ * Resolves a theme input's `extends` chain into one merged {@link ThemeInput}, plus the colour
+ * provenance of the outermost theme. Throws when a theme extends a theme that extends it.
+ */
+export function resolveThemeInput(input: ThemeInput | ExtendingThemeInput): ResolvedThemeInput {
+ // Returns the object it was handed, so inheritance cannot affect a theme with no base.
+ if (extendsNothing(input)) return { inheritance: null, input };
+ const { base, inputs } = collectChain(input);
+ // Fold from the innermost base outward, so every step merges over a complete `ThemeInput` and
+ // `color.accent` is always present.
+ let merged = base;
+ for (const own of inputs.slice(0, -1).reverse()) {
+ merged = inheritInput(merged, own);
+ }
+ return {
+ inheritance: describeInheritance(
+ input,
+ merged,
+ inputs.map((entry) => entry.name),
+ ),
+ input: merged,
+ };
+}
+
+interface ThemeChain {
+ /** The innermost input, the one input in the chain that extends nothing. */
+ base: ThemeInput;
+ /** Every input in the chain, the outermost first and `base` last. */
+ inputs: Array;
+}
+
+/**
+ * Whether an input ends a chain. Only {@link ExtendingThemeInput} requires `extends`, so an input
+ * without one is a complete {@link ThemeInput}. `extends` is not a discriminant, so the union needs
+ * this predicate to narrow.
+ */
+function extendsNothing(input: ThemeInput | ExtendingThemeInput): input is ThemeInput {
+ return input.extends === undefined;
+}
+
+/** Walks `extends` outermost first, and throws when the walk reaches an input twice. */
+function collectChain(input: ThemeInput | ExtendingThemeInput): ThemeChain {
+ const inputs: Array = [];
+ const seen = new Set();
+ let current: ThemeInput | ExtendingThemeInput = input;
+ for (;;) {
+ if (seen.has(current)) {
+ const cycle = [...inputs, current].map((entry) => `"${entry.name}"`).join(' -> ');
+ throw new Error(
+ `Theme "${input.name}" has a cyclic extends chain: ${cycle}. ` +
+ 'A theme cannot extend a theme that extends it.',
+ );
+ }
+ seen.add(current);
+ inputs.push(current);
+ if (extendsNothing(current)) return { base: current, inputs };
+ current = current.extends;
+ }
+}
+
+/** Merges one input over an already-merged base, section by section. */
+function inheritInput(base: ThemeInput, own: ThemeInput | ExtendingThemeInput): ThemeInput {
+ return {
+ actionControlFinish: inheritModes(base.actionControlFinish, own.actionControlFinish),
+ color: inheritColor(base.color, own.color),
+ depth: inheritModes(base.depth, own.depth),
+ // `name` never inherits: the identity belongs to the theme the author declares.
+ name: own.name,
+ radius: inheritKeys(base.radius, own.radius),
+ typography: inheritTypography(base.typography, own.typography),
+ };
+}
+
+/** Merges source colours role by role. A role replaces the base's role whole. */
+function inheritColor(
+ base: ThemeInput['color'],
+ own: Partial | undefined,
+): ThemeInput['color'] {
+ if (own === undefined) return base;
+ // `neutral` and `neutralStyle` are two spellings of one decision, and `resolveNeutral` prefers
+ // `neutral`. A theme that sets only `neutralStyle` must drop an inherited `neutral`, or the
+ // inherited value would win.
+ const ownNeutral = authorsNeutral(own);
+ return {
+ accent: own.accent ?? base.accent,
+ background: own.background ?? base.background,
+ danger: own.danger ?? base.danger,
+ focus: own.focus ?? base.focus,
+ info: own.info ?? base.info,
+ neutral: ownNeutral ? own.neutral : base.neutral,
+ neutralStyle: ownNeutral ? own.neutralStyle : base.neutralStyle,
+ scrim: own.scrim ?? base.scrim,
+ success: own.success ?? base.success,
+ warning: own.warning ?? base.warning,
+ };
+}
+
+/** A per-mode material section as the merge reads it: two optional modes of named rungs. */
+interface ModeLadders {
+ dark?: Record;
+ light?: Record;
+}
+
+/** Merges a per-mode material section, mode by mode and then rung by rung. */
+function inheritModes(
+ base: ModeLadders | undefined,
+ own: ModeLadders | undefined,
+): ModeLadders | undefined {
+ if (base === undefined) return own;
+ if (own === undefined) return base;
+ const merged: ModeLadders = {};
+ const dark = inheritKeys(base.dark, own.dark);
+ if (dark !== undefined) merged.dark = dark;
+ const light = inheritKeys(base.light, own.light);
+ if (light !== undefined) merged.light = light;
+ return merged;
+}
+
+/** Merges typography. `fontFamily` is a scalar and replaces, and `fontWeight` merges key by key. */
+function inheritTypography(
+ base: ThemeInput['typography'],
+ own: ThemeInput['typography'],
+): ThemeInput['typography'] {
+ if (base === undefined) return own;
+ if (own === undefined) return base;
+ const merged: NonNullable = {};
+ const fontFamily = own.fontFamily ?? base.fontFamily;
+ if (fontFamily !== undefined) merged.fontFamily = fontFamily;
+ const fontWeight = inheritKeys(base.fontWeight, own.fontWeight);
+ if (fontWeight !== undefined) merged.fontWeight = fontWeight;
+ return merged;
+}
+
+/**
+ * Merges two flat records key by key, an own value winning. A key set to `undefined` counts as
+ * omitted and inherits, because composed authoring writes `{ resting: on ? value : undefined }`.
+ */
+function inheritKeys(
+ base: Readonly> | undefined,
+ own: Readonly> | undefined,
+): Record | undefined {
+ if (base === undefined && own === undefined) return undefined;
+ const merged: Record = {};
+ for (const key of new Set([...Object.keys(base ?? {}), ...Object.keys(own ?? {})])) {
+ const value = own?.[key] ?? base?.[key];
+ if (value !== undefined) merged[key] = value;
+ }
+ return merged;
+}
+
+/**
+ * Reports which colours a theme authored and which it inherited. Reads the merged input, so a
+ * colour a later theme discarded is reported as neither.
+ */
+function describeInheritance(
+ outermost: ThemeInput | ExtendingThemeInput,
+ merged: ThemeInput,
+ chain: Array,
+): ThemeInheritance {
+ const inheritedColors: Array = [];
+ const ownColors: Array = [];
+ for (const role of COLOR_ROLES) {
+ if (outermost.color?.[role] !== undefined) ownColors.push(`color.${role}`);
+ else if (merged.color[role] !== undefined) inheritedColors.push(`color.${role}`);
+ }
+ return { chain, inheritedColors, ownColors };
+}
diff --git a/packages/@luke-ui/react/src/theme/index.tsx b/packages/@luke-ui/react/src/theme/index.tsx
index 8b6c0005..924dca32 100644
--- a/packages/@luke-ui/react/src/theme/index.tsx
+++ b/packages/@luke-ui/react/src/theme/index.tsx
@@ -28,26 +28,38 @@ export type { FontSizeStep } from './contract.js';
* `ThemeContrastError` is thrown by `defineTheme` when a hard-gated pair misses WCAG 2.2 AA: 4.5:1
* for text/on-solid pairs, 3:1 for the focus ring and `border.control`. The six semantic
* `border.` pairs are measured but advisory only and cannot trigger this error. It carries
- * every failing mode-and-pair in its `failures` array. `ThemeGenerationError` is thrown when a role
- * that must guarantee on-solid contrast (an inaccessible explicit per-mode accent, for example)
- * cannot reach an accessible solid. It names the failing `role` and `mode`.
+ * every failing mode-and-pair in its `failures` array. For a theme built with `extends`, it also
+ * carries `inheritance`, naming the chain of themes and which colours came from a base.
+ * `ThemeGenerationError` is thrown when a role that must guarantee on-solid contrast (an
+ * inaccessible explicit per-mode accent, for example) cannot reach an accessible solid. It names the
+ * failing `role` and `mode`.
*/
export { ThemeContrastError, ThemeGenerationError } from './build-theme.js';
/** One WCAG contrast failure recorded on a {@link ThemeContrastError}. */
export type { ThemeContrastFailure } from './build-theme.js';
+/** The colour provenance a `ThemeContrastError` carries for a theme built with `extends`. */
+export type { ThemeInheritance } from './build-theme.js';
+
/**
* `defineTheme(input)` is the curated authoring entry point: it normalises a small {@link ThemeInput}
* (accent + neutral character, with everything else defaulting) into the per-mode foundation and
* compiles it through `buildTheme`. It adapts single-value accents and neutrals per mode, generates
- * the radius scale, and merges optional materials over curated defaults. It throws the same
+ * the radius scale, and merges optional materials over curated defaults. It also accepts an
+ * {@link ExtendingThemeInput} and resolves its `extends` chain first. It throws the same
* {@link ThemeContrastError} and {@link ThemeGenerationError} as `buildTheme`.
*/
export { defineTheme } from './define-theme.js';
/** The curated `defineTheme` authoring input plus its colour and material building blocks. */
-export type { ColorInput, ControlFinish, DepthLadder, ThemeInput } from './define-theme.js';
+export type {
+ ColorInput,
+ ControlFinish,
+ DepthLadder,
+ ExtendingThemeInput,
+ ThemeInput,
+} from './define-theme.js';
/** Curated defaults `defineTheme` applies for omitted materials and scrim. */
export { defaultControlFinish, defaultDepth, defaultScrim } from './define-theme.js';
diff --git a/packages/@luke-ui/react/src/themes/paper/index.ts b/packages/@luke-ui/react/src/themes/paper/index.ts
index 7dd850b6..fe570bcc 100644
--- a/packages/@luke-ui/react/src/themes/paper/index.ts
+++ b/packages/@luke-ui/react/src/themes/paper/index.ts
@@ -5,7 +5,6 @@ export { themeClassName } from './theme-class-name.js';
/**
* Paper's `defineTheme` input, the materially minimal bundled theme: a flat, hairline-bordered
- * look with a blue accent. Read it, copy it, or spread it into your own `defineTheme` call to
- * start from Paper.
+ * look with a blue accent. Set it as `extends` on your own input to start from Paper.
*/
export const theme: ThemeInput = paperTheme;
diff --git a/packages/@luke-ui/react/src/themes/tactile/index.ts b/packages/@luke-ui/react/src/themes/tactile/index.ts
index d8520137..a3c18ddf 100644
--- a/packages/@luke-ui/react/src/themes/tactile/index.ts
+++ b/packages/@luke-ui/react/src/themes/tactile/index.ts
@@ -5,7 +5,7 @@ export { themeClassName } from './theme-class-name.js';
/**
* Tactile's `defineTheme` input, the Luke UI default: a teal accent, a neutral near-white light
- * canvas, and a compact tactile material. Read it, copy it, or spread it into your own
- * `defineTheme` call to start from Tactile.
+ * canvas, and a compact tactile material. Set it as `extends` on your own input to start from
+ * Tactile.
*/
export const theme: ThemeInput = tactileTheme;