From b9dd672e63dbde7cfa8df1c1bb0ec843a840ca5f Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Sun, 9 Aug 2026 10:59:21 +1000 Subject: [PATCH] Let a theme extend another theme with ThemeInput.extends (#369) * Let a theme extend another theme with ThemeInput.extends * Report colour provenance under the same neutral unit rule the merge uses * Derive colour provenance from the merged theme, and trim the inheritance docs * Say that setting either neutral key replaces the base's neutral decision --- apps/docs/content/docs/authoring-a-theme.mdx | 32 ++- .../samples/theming/extend-bundled-theme.tsx | 8 + .../@luke-ui/react/src/theme/build-theme.ts | 27 +- .../@luke-ui/react/src/theme/define-theme.ts | 130 ++++++---- .../react/src/theme/extend-theme.test.ts | 232 ++++++++++++++++++ .../@luke-ui/react/src/theme/extend-theme.ts | 218 ++++++++++++++++ packages/@luke-ui/react/src/theme/index.tsx | 22 +- .../@luke-ui/react/src/themes/paper/index.ts | 3 +- .../react/src/themes/tactile/index.ts | 4 +- 9 files changed, 615 insertions(+), 61 deletions(-) create mode 100644 apps/docs/src/samples/theming/extend-bundled-theme.tsx create mode 100644 packages/@luke-ui/react/src/theme/extend-theme.test.ts create mode 100644 packages/@luke-ui/react/src/theme/extend-theme.ts 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; -- 2.51.2