diff --git a/docs/STYLING.md b/docs/STYLING.md index 156c7a97..7cf4dcc3 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -63,8 +63,8 @@ with no class and no JS required. Neither step injects styles at runtime. - `theme/contrast-policy.ts`: the WCAG ratios, solver headroom and search step, and the canonical semantic role list the generator, the compiler's validation matrix, and the semantic map all read. - `theme/scale.ts`: the private 12-step family generator (`generateFamily`), including the - constrained step-9 solid-anchor search, the per-role capability guarantees, and - `passesOnSolidGate`, the on-solid accessibility gate. + constrained step-9 solid-anchor search and `passesOnSolidGate`, the on-solid accessibility gate. + Every semantic role's solid clears 4.5:1 against on-solid text. - `theme/motion.ts`: the private ordinal duration scale (`MOTION_DURATION_SCALE`) behind the public `motion.duration` roles in `token-values.ts`. It is resolved in TypeScript and never emitted, so no `--luke-motion-duration-*` custom property exists. @@ -108,8 +108,8 @@ accessible. The raw `ThemeFoundation` object and `buildTheme` are internal only. Every colour token is generated from a private 12-step scale per role (neutral, accent, info, success, warning, danger) plus a mode-aware elevation surface set, then mapped onto the public -colour contract. Every role gets the same background, foreground, on-solid, and border capabilities. -See [THEME_COLOUR_GENERATION.md](THEME_COLOUR_GENERATION.md) for the pipeline, the border and accent +colour contract. Every role gets the same background, foreground, on-solid, and border slots. See +[THEME_COLOUR_GENERATION.md](THEME_COLOUR_GENERATION.md) for the pipeline, the border and accent contrast policies, and what changed when this generator replaced the original per-token solver. The semantic contract includes `font.caption` through `font.display` type styles. Each style groups diff --git a/docs/THEME_COLOUR_GENERATION.md b/docs/THEME_COLOUR_GENERATION.md index 63452780..c2b6b387 100644 --- a/docs/THEME_COLOUR_GENERATION.md +++ b/docs/THEME_COLOUR_GENERATION.md @@ -12,10 +12,9 @@ Per colour mode, `compileTheme` (in `build-theme.ts`): hue/chroma character — see `define-theme.ts`). 2. Generates six private 12-step OKLCH families (`neutral`, `accent`, `info`, `success`, `warning`, `danger`) with `scale.ts`'s `generateFamily`. Each family carries steps 1-12 plus a `contrast` - on-solid colour. Every role now guarantees the same capabilities (see `scale.ts`'s - `FAMILY_REQUIREMENTS`) — the public contract gives all six roles identical background, - foreground, on-solid, and border slots, so there is no role that can opt out of a capability - another role emits. + on-solid colour. Every role publishes the same background, foreground, on-solid, and border + slots, and every role's solid clears 4.5:1 against on-solid text (see `SEMANTIC_ROLES` in + `contrast-policy.ts`). 3. Derives the mode-aware elevation surfaces (`canvas`/`recessed`/`floating`/`overlay`) with `elevation.ts`'s `generateSurfaces`. `surfaces.canvas` is always exactly the resolved `background` — canvas IS the background, not a derived value. @@ -121,10 +120,9 @@ generator alone would report as unsatisfiable. `contrast-policy.ts` declares the shared thresholds: the 4.5 text ratio, the 3:1 non-text ratio, the search headroom, and the search step. It also declares `SEMANTIC_ROLES`, the one canonical role list -used by family generation, the capability matrix, the semantic map, and the validation matrix. -Previously, separate role lists allowed a role added only to the map to emit an ungated colour, -while a role added only to the compiler threw an internal error. One list makes both sides move -together. +used by family generation, the semantic map, and the validation matrix. Previously, separate role +lists allowed a role added only to the map to emit an ungated colour, while a role added only to the +compiler threw an internal error. One list makes both sides move together. ## `loadingSkeleton` diff --git a/packages/@luke-ui/react/src/theme/__fixtures__/radix-scales.ts b/packages/@luke-ui/react/src/theme/__fixtures__/radix-scales.ts index 0c990054..5a4533c9 100644 --- a/packages/@luke-ui/react/src/theme/__fixtures__/radix-scales.ts +++ b/packages/@luke-ui/react/src/theme/__fixtures__/radix-scales.ts @@ -339,7 +339,7 @@ export const HUE_STRESS_CORPUS: ReadonlyArray = [ /** * Dead-zone sources: mid-lightness colours where neither near-white nor near-black on-solid text * clears AA across the solid and its hover, and whose authored tone the generator preserves. A - * `needsOnSolid` role given one of these is genuinely unsatisfiable and throws. + * A source-toned role given one of these is genuinely unsatisfiable and throws. */ export const UNSATISFIABLE_ON_SOLID: Record<'light' | 'dark', CorpusEntry> = { dark: { diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts index 4267dea8..181c5513 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.ts @@ -225,7 +225,6 @@ function buildModeColors(mode: ColorMode, modeFoundation: ThemeModeFoundation): controlBorder, families, focus: source.focus, - mode, scrim: modeFoundation.color.scrim, surfaces, }); diff --git a/packages/@luke-ui/react/src/theme/contrast-policy.ts b/packages/@luke-ui/react/src/theme/contrast-policy.ts index a83be554..048f1681 100644 --- a/packages/@luke-ui/react/src/theme/contrast-policy.ts +++ b/packages/@luke-ui/react/src/theme/contrast-policy.ts @@ -35,13 +35,17 @@ export const RATIO_HEADROOM = 0.05; export const CONTRAST_SEARCH_STEP = 0.0025; /** - * The canonical semantic roles, in contract order. Every role offers the same capabilities, so this - * one list drives family generation, the capability matrix (`scale.ts`), the semantic mapping - * (`semantic-map.ts`), the validation matrix (`contrast-validation.ts`) and diagnostics - * (`build-theme.ts`), and the token-board tooling. A role's meaning never decides what it can style, - * so there is nothing left to split the list on: restating a subset anywhere would reintroduce the - * asymmetry this module exists to prevent. `FamilyRole` in `scale.ts` is derived from this, so the - * type cannot drift from the list either. + * The canonical semantic roles, in contract order. Every role publishes the same visual slots — + * background, foreground, on-solid, and border — so this one list drives family generation + * (`scale.ts`), the semantic mapping (`semantic-map.ts`), the validation matrix + * (`contrast-validation.ts`) and diagnostics (`build-theme.ts`), and the token-board tooling. A + * role's meaning never decides what it can style, so there is nothing left to split the list on: + * restating a subset anywhere would reintroduce the asymmetry this module exists to prevent. + * `FamilyRole` in `scale.ts` is derived from this, so the type cannot drift from the list either. + * + * Every role's solid (step 9) and its hover (step 10) must clear 4.5:1 against on-solid text. The + * scale generator always searches for that contrast. `contrast-validation.ts` enforces it for all + * six roles at compile time. */ export const SEMANTIC_ROLES = [ 'neutral', diff --git a/packages/@luke-ui/react/src/theme/diagnostics.ts b/packages/@luke-ui/react/src/theme/diagnostics.ts index d0539529..da543fb9 100644 --- a/packages/@luke-ui/react/src/theme/diagnostics.ts +++ b/packages/@luke-ui/react/src/theme/diagnostics.ts @@ -8,7 +8,7 @@ import type { Oklch } from './color.js'; import type { GeneratedSurfaces } from './elevation.js'; -import type { FamilyRequirements, FamilyRole, ScaleFamily, ScaleStep } from './scale.js'; +import type { FamilyRole, ScaleFamily, ScaleStep } from './scale.js'; /** A chroma reduction forced by sRGB gamut mapping on one generated rung. */ export interface GamutReduction { @@ -32,7 +32,7 @@ export interface SolidAnchorDiagnostics { onSolidRatioSolidHover: number; /** The lightness the solid anchor resolved to. */ resolvedLightness: number; - /** Whether the family satisfies its on-solid guarantee (always true when the role does not need one). */ + /** Whether on-solid text clears WCAG AA against the solid and its hover. */ satisfied: boolean; /** The lightness the search preferred: the source lightness (vibrant) or the curated target (neutral). */ targetLightness: number; @@ -53,8 +53,6 @@ export interface FamilyDiagnostics { mode: 'light' | 'dark'; /** The chosen on-solid colour and the contrast it reaches over the solid and its hover. */ onSolid: { color: Oklch; ratioSolid: number; ratioSolidHover: number }; - /** The capability guarantees the role declares. */ - requirements: FamilyRequirements; /** The semantic role the family was generated for. */ role: FamilyRole; /** How the step-9 solid anchor was resolved. */ diff --git a/packages/@luke-ui/react/src/theme/scale.test.ts b/packages/@luke-ui/react/src/theme/scale.test.ts index 6fca838a..3457daa6 100644 --- a/packages/@luke-ui/react/src/theme/scale.test.ts +++ b/packages/@luke-ui/react/src/theme/scale.test.ts @@ -12,7 +12,6 @@ import { contrastRatio, parseColor } from './color.js'; import { SEMANTIC_ROLES } from './contrast-policy.js'; import type { FamilyRole } from './scale.js'; import { - FAMILY_REQUIREMENTS, generateFamily, generateFamilyWithDiagnostics, MIN_STATE_DELTA, @@ -32,8 +31,7 @@ const BACKGROUND: Record = { const TEXT_RATIO = 4.5; const MODES: ReadonlyArray = ['light', 'dark']; -// Every role declares the same guarantees, so `SEMANTIC_ROLES` is the complete capability set. The -// one split is geometric rather than semantic: neutral's solid comes from its own +// The one split is geometric rather than semantic: neutral's solid comes from its own // curated dark/light chip band instead of the source lightness, so it is the only role a dead-zone // source cannot make unsatisfiable. const SOURCE_TONED_ROLES = SEMANTIC_ROLES.filter((role) => role !== 'neutral'); @@ -42,24 +40,6 @@ function family(source: string, mode: ColorMode, role: FamilyRole) { return generateFamily({ background: BACKGROUND[mode], mode, role, source: parseColor(source) }); } -describe('FAMILY_REQUIREMENTS', () => { - it('guarantees every capability for every role', () => { - const requirements = SEMANTIC_ROLES.map((role) => [role, FAMILY_REQUIREMENTS[role]] as const); - expect(requirements).toEqual( - SEMANTIC_ROLES.map((role) => [ - role, - { - needsBorder: true, - needsOnSolid: true, - needsSolidStates: true, - needsSubtleStates: true, - needsText: true, - }, - ]), - ); - }); -}); - describe('generateFamily shape', () => { it('returns all twelve steps plus a contrast colour', () => { const scale = family('#0090ff', 'light', 'accent'); @@ -248,9 +228,9 @@ describe('solid-anchor search', () => { }); it('resolves the solid identically for every source-toned role', () => { - // The search is keyed to capabilities, not meaning, and every role now declares the same ones. A - // status role and the accent handed the same character must therefore produce the same solid — - // this is what makes a `danger` badge and an `info` badge equally able to render a solid. + // The solid-anchor search is geometric, not semantic. A status role and the accent handed the + // same character must therefore produce the same solid — this is what makes a `danger` badge + // and an `info` badge equally able to render a solid. const source = parseColor('oklch(0.51 0.19 150)'); const resolvedLightness = (mode: ColorMode, role: FamilyRole) => { return generateFamilyWithDiagnostics({ background: BACKGROUND[mode], mode, role, source }) diff --git a/packages/@luke-ui/react/src/theme/scale.ts b/packages/@luke-ui/react/src/theme/scale.ts index 23c3fcc5..f8f2cb04 100644 --- a/packages/@luke-ui/react/src/theme/scale.ts +++ b/packages/@luke-ui/react/src/theme/scale.ts @@ -2,13 +2,12 @@ * The private 12-step colour scale generator. `generateFamily` produces a Radix-shaped OKLCH family * (steps 1-12 plus a `contrast` on-solid colour) from a family character `source`, the canvas * `background`, a colour `mode`, and a semantic `role`. It owns the constrained solid-anchor (step-9) - * search and the capability-based role guarantees; it is calibrated to testable scale properties - * rather than to exact Radix reproduction. + * search; it is calibrated to testable scale properties rather than to exact Radix reproduction. * * It also owns {@link passesOnSolidGate}, the on-solid accessibility gate. `defineTheme`'s accent * pre-conditioner calls it rather than reimplementing it. It reuses the dependency-free colour math in - * `color.ts` and the shared thresholds in `contrast-policy.ts`, and never distorts a family to satisfy - * a guarantee the public contract does not consume. + * `color.ts` and the shared thresholds in `contrast-policy.ts`. Every semantic role's solid and hover + * must clear 4.5:1 against on-solid text — that invariant lives next to `SEMANTIC_ROLES` there. */ import type { Oklch } from './color.js'; @@ -25,49 +24,6 @@ export type ScaleStep = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; type ColorMode = 'light' | 'dark'; -/** - * The capabilities a role's family must guarantee, driven by what the public colour contract - * actually consumes (not by hue). Named for capabilities so the flags do not leak token names. - */ -export interface FamilyRequirements { - /** Step 7: UI border and focus ring. */ - needsBorder: boolean; - /** `contrast` clears WCAG AA text contrast against steps 9 and 10. */ - needsOnSolid: boolean; - /** Steps 9-10: solid surface and its hover. */ - needsSolidStates: boolean; - /** Steps 3-5: component surface trio (normal / hover / active). */ - needsSubtleStates: boolean; - /** Step 11 low-contrast text (neutral additionally uses 12 / secondary / disabled). */ - needsText: boolean; -} - -/** - * Every capability the public contract can consume. All six roles publish the same visual slots, so - * every family must guarantee all capabilities. A role that opts out would publish an ungated solid. - */ -const everyCapability = { - needsBorder: true, - needsOnSolid: true, - needsSolidStates: true, - needsSubtleStates: true, - needsText: true, -} as const satisfies FamilyRequirements; - -/** - * The locked capability matrix. Kept keyed per role rather than collapsed to one shared value: it is - * what {@link FamilyDiagnostics} reports per family, and the exhaustive `Record` makes - * a newly added role a type error here until its guarantees are stated. - */ -export const FAMILY_REQUIREMENTS = { - accent: everyCapability, - danger: everyCapability, - info: everyCapability, - neutral: everyCapability, - success: everyCapability, - warning: everyCapability, -} as const satisfies Record; - /** * A generated 12-step colour family plus its on-solid `contrast` colour. Step roles: 1-2 app/subtle * backgrounds, 3-5 component surface (normal / hover / active), 6-8 borders (subtle / UI+focus / @@ -86,7 +42,7 @@ export interface ScaleFamily { 10: Oklch; 11: Oklch; 12: Oklch; - /** On-solid text: reads over steps 9 and 10 (guaranteed AA only when `needsOnSolid`). */ + /** On-solid text: reads over steps 9 and 10, guaranteed WCAG AA. */ contrast: Oklch; } @@ -96,17 +52,16 @@ export interface GenerateFamilyRequest { background: Oklch; /** The colour mode the family is generated for. */ mode: ColorMode; - /** The semantic role, which selects the capability guarantees. */ + /** The semantic role. Neutral uses a curated solid band; other roles keep the source tone. */ role: FamilyRole; /** The family's hue/chroma character. Its lightness anchors vibrant solids only. */ source: Oklch; } /** - * Thrown when a family that must guarantee on-solid contrast (`needsOnSolid`) has no lightness in - * its solid band where a near-white or near-black on-solid text clears WCAG AA across the solid and - * its hover. Carries the `role` and `mode` so the caller can name the failing family, plus the best - * attempt for diagnostics. + * Thrown when a family has no lightness in its solid band where a near-white or near-black + * on-solid text clears WCAG AA across the solid and its hover. Carries the `role` and `mode` so the + * caller can name the failing family, plus the best attempt for diagnostics. */ export class ScaleGenerationError extends Error { /** The role whose family could not be generated. */ @@ -275,7 +230,7 @@ export function onSolidGateRatio(request: OnSolidGateRequest): number { /** * Generates the 12-step OKLCH family plus its on-solid `contrast` colour for a role. Owns the * constrained solid-anchor search internally; throws {@link ScaleGenerationError} (carrying `role` - * and `mode`) when a `needsOnSolid` role cannot reach an accessible solid. + * and `mode`) when the family cannot reach an accessible solid. */ export function generateFamily(request: GenerateFamilyRequest): ScaleFamily { return buildFamily(request).family; @@ -305,7 +260,6 @@ function buildFamily(request: GenerateFamilyRequest): { diagnostics: FamilyDiagnostics; } { const { background, mode, role, source } = request; - const requirements = FAMILY_REQUIREMENTS[role]; const hue = source.h; const direction = mode === 'light' ? -1 : 1; const backgroundLightness = clampUnit(background.l); @@ -343,8 +297,8 @@ function buildFamily(request: GenerateFamilyRequest): { const step7 = mutedRung(6); const step8 = mutedRung(7); - // Step 9: the solid anchor, searched only when the role must guarantee on-solid contrast. - const anchor = resolveSolidAnchor(request, requirements); + // Step 9: the solid anchor, searched so on-solid text clears AA across the solid and its hover. + const anchor = resolveSolidAnchor(request); const solid = rung(9, anchor.lightness, source.c); const solidHover = rung(10, anchor.lightness + direction * SOLID_HOVER_DELTA, source.c); @@ -386,7 +340,7 @@ function buildFamily(request: GenerateFamilyRequest): { onSolidRatioSolid: contrastRatio(onSolid.color, solid), onSolidRatioSolidHover: contrastRatio(onSolid.color, solidHover), resolvedLightness: anchor.lightness, - satisfied: !requirements.needsOnSolid || onSolid.minRatio >= TEXT_RATIO, + satisfied: onSolid.minRatio >= TEXT_RATIO, targetLightness: anchor.target, }; @@ -400,7 +354,6 @@ function buildFamily(request: GenerateFamilyRequest): { ratioSolid: solidAnchor.onSolidRatioSolid, ratioSolidHover: solidAnchor.onSolidRatioSolidHover, }, - requirements, role, solidAnchor, source, @@ -417,18 +370,12 @@ interface ResolvedAnchor { } /** - * Resolves the step-9 solid lightness. A role that must guarantee on-solid contrast searches its - * solid band for a lightness whose solid and hover both clear the on-solid gate, preferring the - * lightness nearest the source (vibrant) or the curated target (neutral), and throwing when none - * clears. Every semantic role guarantees on-solid today; the `needsOnSolid: false` path stays because - * the requirement is a capability flag, not a role name — a family generated for a slot the contract - * does not publish a solid for takes its source lightness verbatim rather than being distorted for a - * guarantee nothing consumes. + * Resolves the step-9 solid lightness. Searches the solid band for a lightness whose solid and hover + * both clear the on-solid gate, preferring the lightness nearest the source (vibrant) or the curated + * target (neutral), and throwing when none clears. Every semantic role publishes a solid, so every + * family is searched. */ -function resolveSolidAnchor( - request: GenerateFamilyRequest, - requirements: FamilyRequirements, -): ResolvedAnchor { +function resolveSolidAnchor(request: GenerateFamilyRequest): ResolvedAnchor { const { mode, role, source } = request; const isNeutral = role === 'neutral'; const [rangeLow, rangeHigh] = VIBRANT_SOLID_RANGE; @@ -443,11 +390,6 @@ function resolveSolidAnchor( ]; const [low, high] = band; - if (!requirements.needsOnSolid) { - // Geometric anchor only: honour the source lightness exactly, gamut mapping aside. - return { adapted: false, band, lightness: clampUnit(source.l), target: source.l }; - } - const preferred = clamp(target, low, high); const gateRatio = (lightness: number): number => onSolidGateRatio({ lightness, mode, source }); diff --git a/packages/@luke-ui/react/src/theme/semantic-map.test.ts b/packages/@luke-ui/react/src/theme/semantic-map.test.ts index c2ff1362..dd35c7b2 100644 --- a/packages/@luke-ui/react/src/theme/semantic-map.test.ts +++ b/packages/@luke-ui/react/src/theme/semantic-map.test.ts @@ -78,7 +78,6 @@ describe('mapSemanticColors', () => { controlBorder, families, focus, - mode, scrim, surfaces, }); @@ -125,7 +124,6 @@ describe('mapSemanticColors', () => { const result = mapSemanticColors({ controlBorder: CONTROL_BORDER[mode], families, - mode, scrim: 'oklch(0 0 0 / 0.45)', surfaces, }); @@ -150,7 +148,6 @@ describe('mapSemanticColors', () => { const result = mapSemanticColors({ controlBorder: CONTROL_BORDER[mode], families, - mode, scrim: 'oklch(0 0 0 / 0.45)', surfaces, }); @@ -173,7 +170,6 @@ describe('mapSemanticColors', () => { const result = mapSemanticColors({ controlBorder: CONTROL_BORDER.light, families, - mode: 'light', scrim, surfaces, }); diff --git a/packages/@luke-ui/react/src/theme/semantic-map.ts b/packages/@luke-ui/react/src/theme/semantic-map.ts index 95d17cef..7a4498be 100644 --- a/packages/@luke-ui/react/src/theme/semantic-map.ts +++ b/packages/@luke-ui/react/src/theme/semantic-map.ts @@ -16,8 +16,6 @@ import { SEMANTIC_ROLES } from './contrast-policy.js'; import type { GeneratedSurfaces } from './elevation.js'; import type { FamilyRole, ScaleFamily } from './scale.js'; -type ColorMode = 'light' | 'dark'; - /** Every generated colour contract leaf's CSS value, keyed by its dotted path (for example * `'color.text.primary'`), plus the passed-through `'color.scrim'`. */ export type SemanticColorValues = Record; @@ -30,15 +28,13 @@ interface MapSemanticColorsRequest { * `surfaces.recessed` before this map runs, then this function passes it through verbatim. */ controlBorder: Oklch; - /** The generated scale family for each role, already resolved for `mode`. */ + /** The generated scale family for each role, already mode-resolved. */ families: Record; /** The authored keyboard-focus source colour. Defaults to the accent family's step 8. */ focus?: Oklch; - /** The colour mode the families and surfaces were resolved for. */ - mode: ColorMode; /** The authored scrim value, passed through verbatim (it may carry an alpha channel). */ scrim: string; - /** The generated elevation surface set, already resolved for `mode`. */ + /** The generated elevation surface set, already mode-resolved. */ surfaces: GeneratedSurfaces; } diff --git a/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx b/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx index 8c5fd4ff..44781cae 100644 --- a/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx +++ b/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx @@ -15,7 +15,7 @@ import type { import type { GeneratedSurfaces } from './elevation.js'; import { paperTheme } from './foundations/paper.js'; import { tactileTheme } from './foundations/tactile.js'; -import type { FamilyRequirements, FamilyRole, ScaleFamily, ScaleStep } from './scale.js'; +import type { FamilyRole, ScaleFamily, ScaleStep } from './scale.js'; type BundledThemeKey = 'tactile' | 'paper'; @@ -88,19 +88,10 @@ const familyRowStyle = { gap: vars.space[100], } as const satisfies CSSProperties; -const familyHeaderStyle = { - alignItems: 'baseline', - display: 'flex', - gap: vars.space[300], +const familyRoleStyle = { textTransform: 'capitalize', } as const satisfies CSSProperties; -const requirementsTextStyle = { - color: vars.color.text.secondary, - fontSize: vars.font.caption.fontSize, - textTransform: 'none', -} as const satisfies CSSProperties; - const rampRowStyle = { display: 'flex', flexWrap: 'wrap', @@ -244,10 +235,7 @@ function FamiliesSection({ families }: { families: Record -
- {role} - -
+ {role} ); @@ -256,18 +244,6 @@ function FamiliesSection({ families }: { families: Record = [ - ['subtle states', requirements.needsSubtleStates], - ['solid states', requirements.needsSolidStates], - ['on-solid', requirements.needsOnSolid], - ['text', requirements.needsText], - ['border', requirements.needsBorder], - ]; - const needed = capabilities.filter(([, isNeeded]) => isNeeded).map(([label]) => label); - return {needed.join(' · ')}; -} - function FamilyRamp({ family }: { family: ScaleFamily }) { return (