diff --git a/apps/docs/package.json b/apps/docs/package.json index 41cd9706..db0610f0 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -22,7 +22,7 @@ "generate:components-index": "node scripts/generate-components-index.ts", "generate:playground": "node scripts/generate-playground-scope.ts && node scripts/generate-playground-types.ts && vp pack", "generate:props": "node scripts/generate-props-pages.ts", - "generate:token-reference": "node scripts/generate-token-reference.ts", + "generate:token-reference": "tsx scripts/generate-token-reference.ts", "postinstall": "fumadocs-mdx", "preview": "vp preview", "routes:generate": "tsr generate", diff --git a/apps/docs/scripts/generate-playground-types.ts b/apps/docs/scripts/generate-playground-types.ts index 0e93249d..1df4b74f 100644 --- a/apps/docs/scripts/generate-playground-types.ts +++ b/apps/docs/scripts/generate-playground-types.ts @@ -16,7 +16,7 @@ import { createRequire } from 'node:module'; import { dirname, join, relative, resolve, sep } from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; -import { z } from 'zod'; +import * as z from 'zod'; const scriptDir = dirname(fileURLToPath(import.meta.url)); const docsPackageJsonPath = resolve(scriptDir, '../package.json'); diff --git a/apps/docs/scripts/generate-token-reference.ts b/apps/docs/scripts/generate-token-reference.ts index f18f795f..12a9ff96 100644 --- a/apps/docs/scripts/generate-token-reference.ts +++ b/apps/docs/scripts/generate-token-reference.ts @@ -3,6 +3,8 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const scriptDir = dirname(fileURLToPath(import.meta.url)); +// `tsx` resolves the library source's `.js` specifiers to the `.ts` files on disk. Node's type +// stripping does not remap them. const contractPath = resolve(scriptDir, '../../../packages/@luke-ui/react/src/theme/contract.ts'); const outputPath = resolve(scriptDir, '../src/generated/token-reference.generated.ts'); const { flattenThemeContract } = (await import(contractPath)) as { diff --git a/apps/docs/src/lib/playground-default-code.tsx b/apps/docs/src/lib/playground-default-code.tsx index 6e0fced0..85a5ff73 100644 --- a/apps/docs/src/lib/playground-default-code.tsx +++ b/apps/docs/src/lib/playground-default-code.tsx @@ -1,37 +1,115 @@ +import { zodResolver } from '@hookform/resolvers/zod'; import { Box } from '@luke-ui/react/box'; import { Button } from '@luke-ui/react/button'; import { Heading } from '@luke-ui/react/heading'; +import { Text } from '@luke-ui/react/text'; import { TextField } from '@luke-ui/react/text-field'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import * as z from 'zod'; + +const DIGIT_PATTERN = /\d/; + +const schema = z.object({ + email: z.email('Enter an email address in the form you@example.com.'), + password: z.string().refine((value) => value.length >= 8 && DIGIT_PATTERN.test(value), { + message: 'At least 8 characters, including a number.', + }), +}); export default () => { - const handleSubmit = (event: React.SubmitEvent) => { - event.preventDefault(); - const formData = new FormData(event.target); - window.alert(JSON.stringify(Object.fromEntries(formData), null, 2)); - }; + const form = useForm({ + defaultValues: { email: '', password: '' }, + resolver: zodResolver(schema), + }); + + const handleSubmit = form.handleSubmit((values) => { + setSignedInAs(values.email); + }); + + // Set on a valid submit only, so the confirmation never tracks keystrokes. + const [signedInAs, setSignedInAs] = useState(null); + return (
} > - Sign in - - + Sign in + + ( + + )} + /> + ( + + )} + /> + - + + {signedInAs && ( + + Signed in as {signedInAs} + + )} ); }; diff --git a/apps/docs/src/lib/playground-protocol.ts b/apps/docs/src/lib/playground-protocol.ts index 660fb0a0..7ac4c9a5 100644 --- a/apps/docs/src/lib/playground-protocol.ts +++ b/apps/docs/src/lib/playground-protocol.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const codeMessageSchema = z.object({ code: z.string(), diff --git a/apps/docs/src/lib/playground-runtime-specifiers.ts b/apps/docs/src/lib/playground-runtime-specifiers.ts index 8f6349e4..3cc1e4a4 100644 --- a/apps/docs/src/lib/playground-runtime-specifiers.ts +++ b/apps/docs/src/lib/playground-runtime-specifiers.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { z } from 'zod'; +import * as z from 'zod'; /** Specifiers the playground always resolves, besides `@luke-ui/react/*` subpaths. */ const PLAYGROUND_BASE_SPECIFIERS = [ @@ -82,7 +82,8 @@ export function importSpecifiersFromSource(source: string): Array { let defaultSpecifiers: ReadonlySet | undefined; function defaultPlaygroundSpecifiers(): ReadonlySet { - return (defaultSpecifiers ??= new Set(playgroundRuntimeSpecifierList())); + defaultSpecifiers ??= new Set(playgroundRuntimeSpecifierList()); + return defaultSpecifiers; } export function canRunInPlayground( diff --git a/docs/STYLING.md b/docs/STYLING.md index 37781b39..5b82dad8 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -321,10 +321,14 @@ The tradeoff is that some values are applied through inline `style`, which raise is acceptable because styling utilities are already the highest-priority escape hatch. `Box` from `@luke-ui/react/box` applies these utilities. See the -[Box documentation](/components/layout/box) for its element and render contracts. - -Do not add style props to every component. Component props should stay focused on component-specific -variants and behaviour. +[Box documentation](/components/layout/box) for its element and render contracts. Its utilities +cover layout (flex, grid, spacing, sizing, position) and appearance (`backgroundColor`, +`borderColor`, `borderWidth`, `borderStyle`, `borderRadius`, `boxShadow`). Use `Box` as the escape +hatch for both. It deliberately excludes typography and text colour. Use `Text`/`Heading` for those. + +Do not add style props to every other component. Component props should stay focused on +component-specific variants and behaviour. Reach for `Box` when a component's own props are too +narrow instead of growing an ad-hoc style prop on that component. ## `createSprinkles()` diff --git a/docs/TESTING.md b/docs/TESTING.md index 54d36ab0..e56ecd18 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -90,6 +90,13 @@ An authored assertion should fail if, and only if, an intention Luke UI owns is Do not test TypeScript guarantees, private functions, call counts inside repository modules, generated class names, selector text, incidental copy, or low-value bugs unlikely to recur. +This targets low-value tests of internal TypeScript guarantees. Emitted declarations in +`dist/*.d.ts` are part of the public consumer contract, not an internal guarantee, so a type test is +warranted when a declaration-emit regression would change what consumers can assign or what their +editor autocompletes, such as a numeric-looking space-step key widening from `'400'` to `number`, or +a token union widening to an index signature or `string`. These live in `*.test-d.ts` and run via +the `types` project. + ## Stories Every story is a render-smoke test and an accessibility fixture. Keep stories curated: show diff --git a/packages/@luke-ui/react/package.json b/packages/@luke-ui/react/package.json index 0c2064b6..d86fe052 100644 --- a/packages/@luke-ui/react/package.json +++ b/packages/@luke-ui/react/package.json @@ -85,8 +85,9 @@ "generate:themes": "tsx scripts/build-themes.ts", "test": "pnpm run test:ci && pnpm run test:visual", "test:browser": "vp test run --project=browser", - "test:ci": "pnpm run test:unit && pnpm run test:browser && pnpm run test:storybook", + "test:ci": "pnpm run test:types && pnpm run test:unit && pnpm run test:browser && pnpm run test:storybook", "test:storybook": "vp test run --project=storybook", + "test:types": "pnpm run generate && pnpm run build && vp test run --project=types", "test:ui": "vp test --project=storybook --ui", "test:unit": "vp test run --project=unit", "test:visual": "tsx scripts/visual-regression.ts", diff --git a/packages/@luke-ui/react/src/styles/utilities-emitted.test-d.ts b/packages/@luke-ui/react/src/styles/utilities-emitted.test-d.ts new file mode 100644 index 00000000..1de8bc67 --- /dev/null +++ b/packages/@luke-ui/react/src/styles/utilities-emitted.test-d.ts @@ -0,0 +1,124 @@ +/** Guards the utility prop types emitted for package consumers. */ + +import { readFile } from 'node:fs/promises'; +import { expect, test } from 'vite-plus/test'; +import { breakpoints } from '../theme/breakpoints.js'; +import type { SpaceStep } from '../theme/contract.js'; +import { spaceScale } from '../theme/contract.js'; +import { SEMANTIC_ROLES } from '../theme/contrast-policy.js'; + +/** Reads the content-hashed declaration chunk referenced by `box/index.d.ts`. */ +async function readUtilitiesDeclaration(): Promise { + const boxDeclaration = await readFile( + new URL('../../dist/box/index.d.ts', import.meta.url), + 'utf8', + ); + const chunk = /utilities\.css-[A-Za-z0-9_-]+/.exec(boxDeclaration)?.[0]; + if (chunk === undefined) { + throw new Error('Could not find the utilities declaration chunk from dist/box/index.d.ts'); + } + return readFile(new URL(`../../dist/${chunk}.d.ts`, import.meta.url), 'utf8'); +} + +/** Extracts one property's declared type while balancing braces. */ +function propertyType(declaration: string, property: string): string { + const marker = new RegExp(`readonly ${property}:`).exec(declaration); + if (marker?.index === undefined) { + throw new Error(`Property "${property}" is not present in the emitted declaration`); + } + const start = marker.index + marker[0].length; + let depth = 0; + for (let index = start; index < declaration.length; index += 1) { + const character = declaration[index]; + if (character === '{') depth += 1; + else if (character === '}') depth -= 1; + else if (character === ';' && depth === 0) return declaration.slice(start, index); + } + throw new Error(`Could not read the emitted type for "${property}"`); +} + +const spaceSteps: ReadonlyArray = spaceScale.map(([step]) => step); +const spaceScaleProperties = ['gap', 'padding', 'margin', 'columnGap', 'rowGap'] as const; + +test('space-scale props keep quoted string keys for every step', async () => { + const declaration = await readUtilitiesDeclaration(); + + // Collect all regressions so one failure reports every property and step. + const unquoted: Array = []; + for (const property of spaceScaleProperties) { + const emitted = propertyType(declaration, property); + for (const step of spaceSteps) { + // An unquoted `400:` makes `keyof` numeric. + const isQuoted = emitted.includes(`'${step}':`); + const isNumeric = new RegExp(`(^|[{;\\s])${step}:`).test(emitted); + if (!isQuoted || isNumeric) unquoted.push(`${property}.${step}`); + } + } + + expect(unquoted).toEqual([]); +}); + +test('backgroundColor is keyed by tokens, not by an index signature', async () => { + const declaration = await readUtilitiesDeclaration(); + const emitted = propertyType(declaration, 'backgroundColor'); + + expect(emitted, 'backgroundColor widened to an index signature').not.toMatch( + /\[\s*\w+\s*:\s*string\s*\]/, + ); + expect(emitted.trim()).not.toBe('true'); + + // Declaration emit may inline the type or retain its alias. + const alias = /type BackgroundColorToken = ([^;]+);/.exec(declaration)?.[1] ?? ''; + const tokenSource = emitted.includes('"') ? emitted : alias; + expect(tokenSource, 'no token vocabulary found for backgroundColor').toBeTruthy(); + + const mentionsRoles = + SEMANTIC_ROLES.every((role) => tokenSource.includes(`"${role}.subtle.rest"`)) || + (tokenSource.includes('SemanticRole') && + tokenSource.includes('ProminenceLevel') && + tokenSource.includes('InteractionState')); + expect(mentionsRoles, `backgroundColor lost its role tokens: ${tokenSource}`).toBe(true); + + const mentionsSurfaces = + tokenSource.includes('surface.') || tokenSource.includes('"surface.canvas"'); + expect(mentionsSurfaces, `backgroundColor lost its surface tokens: ${tokenSource}`).toBe(true); + + // A translucent scrim is not an opaque fill. + expect(tokenSource).not.toContain('overlay.backdrop'); +}); + +test('constrained appearance props do not accept arbitrary CSS values', async () => { + const declaration = await readUtilitiesDeclaration(); + + // `true` accepts arbitrary CSS values, including CSS-wide keywords. + for (const property of ['borderWidth', 'borderStyle', 'borderColor', 'borderRadius'] as const) { + expect(propertyType(declaration, property).trim()).not.toBe('true'); + } + + // Named widths, not raw lengths: the emitted keys must be the token names the scale declares. + const emittedBorderWidth = propertyType(declaration, 'borderWidth'); + const missingWidths = ['none', 'thin', 'thick'].filter((width) => { + return !emittedBorderWidth.includes(`${width}:`); + }); + expect(missingWidths).toEqual([]); + + expect(propertyType(declaration, 'borderStyle')).toContain('solid'); +}); + +test('responsive conditions expose the theme breakpoints in size order', async () => { + const declaration = await readUtilitiesDeclaration(); + // Conditions are matched by name and checked in declaration order. + const expected = ['initial', ...Object.keys(breakpoints)]; + const emittedOrder = expected + .map((name) => ({ index: new RegExp(`readonly ${name}:`).exec(declaration)?.index, name })) + .sort((a, b) => (a.index ?? -1) - (b.index ?? -1)) + .map(({ name }) => name); + + for (const name of expected) { + expect(declaration, `condition "${name}" is missing from the emitted config`).toMatch( + new RegExp(`readonly ${name}:`), + ); + } + // Declaration order controls editor completion order. + expect(emittedOrder).toEqual(expected); +}); diff --git a/packages/@luke-ui/react/src/styles/utilities.css.ts b/packages/@luke-ui/react/src/styles/utilities.css.ts index f5653eeb..9389c7fc 100644 --- a/packages/@luke-ui/react/src/styles/utilities.css.ts +++ b/packages/@luke-ui/react/src/styles/utilities.css.ts @@ -2,6 +2,9 @@ import type { SprinklesFn } from '@luke-ui/rainbow-sprinkles'; import { defineProperties, defineSprinkles } from '@luke-ui/rainbow-sprinkles'; import { breakpoints } from '../theme/breakpoints.js'; import { vars } from '../theme/contract.css.js'; +import type { SpaceStep } from '../theme/contract.js'; +import { SEMANTIC_ROLES } from '../theme/contrast-policy.js'; +import { typedEntries } from '../utils/index.js'; import { layers } from './layers.css.js'; function fromBreakpoint(minimumWidth: number) { @@ -17,6 +20,7 @@ const responsiveConditions = { xxlarge: fromBreakpoint(breakpoints.xxlarge), } as const; +/** Space steps plus `0`, written explicitly so declaration emit preserves string keys. */ const spaceScale = { '0': '0', '100': vars.space[100], @@ -28,10 +32,39 @@ const spaceScale = { '1000': vars.space[1000], '1200': vars.space[1200], '1600': vars.space[1600], -} as const; +} as const satisfies Record<`${SpaceStep}` | '0', string>; const marginScale = { ...spaceScale, auto: 'auto' } as const; +/** Border widths offered by the design system. */ +const borderWidthScale = { none: '0', thin: '1px', thick: '2px' } as const; + +const PROMINENCE_LEVELS = ['subtle', 'solid'] as const; +const INTERACTION_STATES = ['rest', 'hover', 'pressed'] as const; + +type SemanticRole = (typeof SEMANTIC_ROLES)[number]; +type ProminenceLevel = (typeof PROMINENCE_LEVELS)[number]; +type InteractionState = (typeof INTERACTION_STATES)[number]; + +/** Background tokens, excluding the translucent backdrop. */ +type BackgroundColorToken = + | `${SemanticRole}.${ProminenceLevel}.${InteractionState}` + | `surface.${keyof typeof vars.color.surface}`; + +/** Background tokens for semantic roles and elevation surfaces. */ +const backgroundColorScale = Object.fromEntries([ + ...SEMANTIC_ROLES.flatMap((role) => { + return PROMINENCE_LEVELS.flatMap((prominence) => { + return INTERACTION_STATES.map((state): [BackgroundColorToken, string] => { + return [`${role}.${prominence}.${state}`, vars.color.background[role][prominence][state]]; + }); + }); + }), + ...typedEntries(vars.color.surface).map(([surface, value]): [BackgroundColorToken, string] => { + return [`surface.${surface}`, value]; + }), +]) as Record; + const responsiveProperties = defineProperties({ '@layer': layers.utilities, conditions: responsiveConditions, @@ -63,7 +96,25 @@ const responsiveProperties = defineProperties({ normal: 'normal', stretch: 'stretch', }, + backgroundColor: backgroundColorScale, blockSize: true, + borderColor: vars.color.border, + borderRadius: { + detail: vars.radius.detail, + control: vars.radius.control, + surface: vars.radius.surface, + overlay: vars.radius.overlay, + full: vars.radius.full, + }, + borderStyle: { none: 'none', solid: 'solid', dashed: 'dashed', dotted: 'dotted' }, + borderWidth: borderWidthScale, + boxShadow: { + recessed: vars.depth.recessed, + resting: vars.depth.resting, + raised: vars.depth.raised, + floating: vars.depth.floating, + overlay: vars.depth.overlay, + }, columnGap: spaceScale, display: { block: 'block', @@ -180,7 +231,8 @@ const responsiveProperties = defineProperties({ }, }); -export const createSprinkles: SprinklesFn<[typeof responsiveProperties]> = - defineSprinkles(responsiveProperties); +type CreateSprinkles = SprinklesFn<[typeof responsiveProperties]>; + +export const createSprinkles: CreateSprinkles = defineSprinkles(responsiveProperties); export type SprinklesProps = Parameters[0]; diff --git a/packages/@luke-ui/react/src/styles/utilities.test-d.ts b/packages/@luke-ui/react/src/styles/utilities.test-d.ts new file mode 100644 index 00000000..b06fae6c --- /dev/null +++ b/packages/@luke-ui/react/src/styles/utilities.test-d.ts @@ -0,0 +1,86 @@ +/** Type-level assertions against the utility props emitted for package consumers. */ + +import { assertType, expectTypeOf, test } from 'vite-plus/test'; +import type { BoxProps } from '../../dist/box/index.js'; + +type UtilityProps = NonNullable; + +test('space steps assign as strings, not numbers', () => { + // Numeric-looking keys must remain strings after declaration emit. + expectTypeOf<'400'>().toExtend(); + expectTypeOf<'0'>().toExtend(); + expectTypeOf<'1600'>().toExtend(); + expectTypeOf().not.toBeAny(); + + assertType('400'); + // @ts-expect-error — a raw CSS length is not a space step + assertType('16px'); + // @ts-expect-error — the step keys are strings, so the number must not assign + assertType(400); +}); + +test('margin keeps auto alongside the space steps', () => { + assertType('auto'); + // @ts-expect-error — `auto` belongs to margin only, not padding + assertType('auto'); +}); + +test('backgroundColor accepts only role tokens', () => { + expectTypeOf().not.toBeAny(); + assertType('accent.solid.rest'); + assertType('neutral.subtle.pressed'); + + // @ts-expect-error — not a token, and would be allowed by a widened `Record` + assertType('rebeccapurple'); + // @ts-expect-error — unknown role + assertType('magenta.solid.rest'); + // @ts-expect-error — unknown state + assertType('accent.solid.disabled'); +}); + +test('backgroundColor offers the elevation surfaces', () => { + assertType('surface.canvas'); + assertType('surface.recessed'); + assertType('surface.floating'); + assertType('surface.overlay'); + + // @ts-expect-error — a translucent scrim is not an opaque surface fill + assertType('overlay.backdrop'); + // @ts-expect-error — surfaces are namespaced, so the bare name must not assign + assertType('canvas'); +}); + +test('border props stay constrained to the design system vocabulary', () => { + assertType('none'); + assertType('thin'); + assertType('thick'); + assertType('solid'); + assertType('focus'); + assertType('control'); + + // These values would be accepted if the properties were declared `true`. + // @ts-expect-error — CSS-wide keyword + assertType('revert-layer'); + // @ts-expect-error — a raw length, not one of the named widths + assertType('1px'); + // @ts-expect-error — `medium` is a CSS keyword width, not a token + assertType('medium'); + // @ts-expect-error — a style the design system does not offer + assertType('groove'); +}); + +test('responsive objects are keyed by the theme breakpoints', () => { + assertType({ initial: '100', medium: '400' }); + assertType({ initial: 'block', xxlarge: 'flex' }); + + // @ts-expect-error — not a breakpoint + assertType({ initial: '100', tablet: '400' }); + // @ts-expect-error — the value must still be a space step at every breakpoint + assertType({ initial: '16px' }); +}); + +test('unconstrained properties still accept raw CSS values', () => { + // `inlineSize` is deliberately open: it takes any length, unlike the token-backed scales. + assertType('400px'); + assertType('100%'); +}); diff --git a/packages/@luke-ui/react/src/styles/utilities.test.ts b/packages/@luke-ui/react/src/styles/utilities.test.ts index 16fedcce..c524d2c3 100644 --- a/packages/@luke-ui/react/src/styles/utilities.test.ts +++ b/packages/@luke-ui/react/src/styles/utilities.test.ts @@ -1,10 +1,47 @@ import { expect, test } from 'vite-plus/test'; import { createSprinkles } from './utilities.css.js'; -test('exposes the responsive layout-only property surface', () => { +test('exposes the responsive layout and appearance property surface', () => { expect(createSprinkles.properties).toContain('display'); expect(createSprinkles.properties).toContain('gridColumn'); + expect(createSprinkles.properties).toContain('backgroundColor'); + expect(createSprinkles.properties).toContain('borderColor'); + expect(createSprinkles.properties).toContain('borderWidth'); + expect(createSprinkles.properties).toContain('borderStyle'); + expect(createSprinkles.properties).toContain('borderRadius'); expect(createSprinkles.properties).not.toContain('color'); - expect(createSprinkles.properties).not.toContain('backgroundColor'); expect(createSprinkles.properties).not.toContain('fontSize'); }); + +test('resolves backgroundColor and borderColor tokens to their theme variables', () => { + const { className, style } = createSprinkles({ + backgroundColor: 'accent.solid.rest', + borderColor: 'focus', + }); + + expect(className).toBeTruthy(); + // Asserting the `var(--luke-*)` references rather than mere truthiness: an unregistered token + // still yields a class, with the raw string assigned as the custom property value. + expect(Object.values(style ?? {}).sort()).toEqual([ + 'var(--luke-color-background-accent-solid-rest)', + 'var(--luke-color-border-focus)', + ]); +}); + +// Surfaces are opaque backgrounds for cards and panels. The translucent scrim is excluded. +test('resolves every elevation surface background token to its theme variable', () => { + const surfaces = ['canvas', 'recessed', 'floating', 'overlay'] as const; + + // A missing token still returns a class with the raw string, so assert the generated variable. + const resolved = surfaces.map((surface) => { + const { style } = createSprinkles({ backgroundColor: `surface.${surface}` }); + return [surface, Object.values(style ?? {})] as const; + }); + + expect(Object.fromEntries(resolved)).toEqual({ + canvas: ['var(--luke-color-surface-canvas)'], + floating: ['var(--luke-color-surface-floating)'], + overlay: ['var(--luke-color-surface-overlay)'], + recessed: ['var(--luke-color-surface-recessed)'], + }); +}); diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts index 94238af2..9cc76b8e 100644 --- a/packages/@luke-ui/react/src/theme/contract.ts +++ b/packages/@luke-ui/react/src/theme/contract.ts @@ -1,5 +1,17 @@ +import { SEMANTIC_ROLES } from './contrast-policy.js'; import type { FontMetricStep } from './font-metric-scale.js'; +/** + * Gives all six semantic roles the same contract shape, so a role can never be added to one visual + * slot and forgotten in another. `leaf()` builds a fresh sub-tree per role, because two roles sharing + * one object would make them the same node. + */ +function perRole(leaf: () => Leaf) { + return Object.fromEntries(SEMANTIC_ROLES.map((role) => [role, leaf()])) as { + readonly [Role in (typeof SEMANTIC_ROLES)[number]]: Leaf; + }; +} + /** Leaves shared by every public type style. */ const typeStyle = { baselineTrim: null, @@ -163,35 +175,16 @@ export const themeContractTree = { disabled: null, }, /** Subtle and solid background ramps, each with generated rest / hover / pressed states. */ - background: { - neutral: { ...roleBackground }, - accent: { ...roleBackground }, - info: { ...roleBackground }, - success: { ...roleBackground }, - warning: { ...roleBackground }, - danger: { ...roleBackground }, - }, + background: perRole(() => ({ ...roleBackground })), /** Resting, hover, and pressed content colours, plus the guaranteed on-solid pairing. */ - foreground: { - neutral: { ...roleForeground }, - accent: { ...roleForeground }, - info: { ...roleForeground }, - success: { ...roleForeground }, - warning: { ...roleForeground }, - danger: { ...roleForeground }, - }, + foreground: perRole(() => ({ ...roleForeground })), border: { decorative: null, control: null, focus: null, // The shared semantic borders. State-free on purpose: the token carries the meaning and the // component decides when to apply it. - neutral: null, - accent: null, - info: null, - success: null, - warning: null, - danger: null, + ...perRole(() => null), }, }, /** Composite box-shadow values for the shared depth ladder. */ diff --git a/packages/@luke-ui/react/src/theme/define-theme.ts b/packages/@luke-ui/react/src/theme/define-theme.ts index fe6bb2a6..1e90e5b3 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.ts @@ -6,6 +6,7 @@ * backdrop) into {@link Oklch} values the foundation carries. */ +import { typedEntries, typedFromEntries } from '../utils/index.js'; import { buildTheme, ThemeContrastError } from './build-theme.js'; import type { Oklch } from './color.js'; import { gamutMapOklch, parseColor } from './color.js'; @@ -280,9 +281,9 @@ function buildModeFoundation(input: ThemeInput, mode: ColorMode): ThemeModeFound * default it is merged over. */ function omitUndefined>(record: T): Partial { - return Object.fromEntries( - Object.entries(record).filter(([, value]) => value !== undefined), - ) as Partial; + return typedFromEntries>( + typedEntries(record).filter(([, value]) => value !== undefined), + ); } /** Resolves every source-colour role for one mode into the {@link Oklch} values `buildTheme` accepts. */ diff --git a/packages/@luke-ui/react/src/theme/scale.ts b/packages/@luke-ui/react/src/theme/scale.ts index 48dd9d96..5ab8b165 100644 --- a/packages/@luke-ui/react/src/theme/scale.ts +++ b/packages/@luke-ui/react/src/theme/scale.ts @@ -20,6 +20,9 @@ export type FamilyRole = (typeof SEMANTIC_ROLES)[number]; /** A step index in the 12-step scale. */ export type ScaleStep = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; +/** Maps a muted-rung array index to its 1-based scale step, without widening through arithmetic. */ +const MUTED_RUNG_STEP = [1, 2, 3, 4, 5, 6, 7, 8] as const satisfies ReadonlyArray; + type ColorMode = 'light' | 'dark'; /** @@ -313,7 +316,7 @@ function buildFamily(request: GenerateFamilyRequest): { const mutedRung = (index: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7): Oklch => { const spec = RAMP_SPEC[mode][index]; return rung( - (index + 1) as ScaleStep, + MUTED_RUNG_STEP[index], backgroundLightness + direction * spec.offset, Math.min(source.c * spec.chromaFraction, spec.chromaCap), ); 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 e19408fb..d9f65db7 100644 --- a/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx +++ b/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx @@ -1,5 +1,6 @@ import type { CSSProperties, ReactNode } from 'react'; import { useState } from 'react'; +import { typedKeys } from '../utils/index.js'; import { compileTheme } from './build-theme.js'; import type { Oklch } from './color.js'; import { formatOklch } from './color.js'; @@ -186,7 +187,7 @@ export function ThemeDiagnosticsInspector() {
- {(Object.keys(BUNDLED_THEMES) as Array).map((key) => ( + {typedKeys(BUNDLED_THEMES).map((key) => (