From 5336f3482438940c2ebdde23339b03c223d2f5a4 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Mon, 9 Nov 2026 13:38:21 -0500 Subject: [PATCH] Revert "chore(gamut-styles): move the color generator to cass-gmt-1825-color-spike" This reverts commit 8c0fcd9b69018915655d392a632ed1c7cdb87456. --- packages/gamut-styles/package.json | 12 +- packages/gamut-styles/scripts/build-tokens.ts | 174 ++ .../gamut-styles/scripts/validate-tokens.ts | 290 +++ packages/gamut-styles/src/Background.tsx | 2 +- .../gamut-styles/src/colorSystem/README.md | 89 + .../__fixtures__/sandbox-dffd84b.json | 969 ++++++++++ .../src/colorSystem/__tests__/system.test.ts | 47 + .../gamut-styles/src/colorSystem/brand.ts | 122 ++ .../src/colorSystem/brands/skillsoft.ts | 20 + .../src/colorSystem/contentModes.ts | 32 + packages/gamut-styles/src/colorSystem/emit.ts | 216 +++ .../gamut-styles/src/colorSystem/migration.ts | 1669 +++++++++++++++++ .../gamut-styles/src/colorSystem/probe.ts | 90 + packages/gamut-styles/src/colorSystem/ramp.ts | 142 ++ .../gamut-styles/src/colorSystem/roles.ts | 422 +++++ .../gamut-styles/src/colorSystem/system.ts | 82 + .../gamut-styles/src/colorSystem/validate.ts | 269 +++ packages/gamut-styles/src/themes/skillsoft.ts | 2 +- packages/gamut/src/Alert/variants.tsx | 2 +- .../ColorMode/CssContract.stories.tsx | 2 +- yarn.lock | 647 ++++++- 21 files changed, 5287 insertions(+), 13 deletions(-) create mode 100644 packages/gamut-styles/scripts/build-tokens.ts create mode 100644 packages/gamut-styles/scripts/validate-tokens.ts create mode 100644 packages/gamut-styles/src/colorSystem/README.md create mode 100644 packages/gamut-styles/src/colorSystem/__fixtures__/sandbox-dffd84b.json create mode 100644 packages/gamut-styles/src/colorSystem/__tests__/system.test.ts create mode 100644 packages/gamut-styles/src/colorSystem/brand.ts create mode 100644 packages/gamut-styles/src/colorSystem/brands/skillsoft.ts create mode 100644 packages/gamut-styles/src/colorSystem/contentModes.ts create mode 100644 packages/gamut-styles/src/colorSystem/emit.ts create mode 100644 packages/gamut-styles/src/colorSystem/migration.ts create mode 100644 packages/gamut-styles/src/colorSystem/probe.ts create mode 100644 packages/gamut-styles/src/colorSystem/ramp.ts create mode 100644 packages/gamut-styles/src/colorSystem/roles.ts create mode 100644 packages/gamut-styles/src/colorSystem/system.ts create mode 100644 packages/gamut-styles/src/colorSystem/validate.ts diff --git a/packages/gamut-styles/package.json b/packages/gamut-styles/package.json index 259f5f81e0..8c279cc9a4 100644 --- a/packages/gamut-styles/package.json +++ b/packages/gamut-styles/package.json @@ -46,8 +46,16 @@ }, "repository": "git@github.com:Codecademy/skillsoft-gamut.git", "scripts": { - "build": "nx build gamut-styles" + "build": "nx build gamut-styles", + "build:tokens": "jiti scripts/build-tokens.ts", + "validate:tokens": "jiti scripts/validate-tokens.ts" }, "sideEffects": false, - "types": "dist/index.d.ts" + "types": "dist/index.d.ts", + "devDependencies": { + "@types/culori": "^4", + "culori": "^4.0.2", + "jiti": "2.4.2", + "style-dictionary": "5.7.0" + } } diff --git a/packages/gamut-styles/scripts/build-tokens.ts b/packages/gamut-styles/scripts/build-tokens.ts new file mode 100644 index 0000000000..9156e9f454 --- /dev/null +++ b/packages/gamut-styles/scripts/build-tokens.ts @@ -0,0 +1,174 @@ +/* eslint-disable no-console -- CLI script; the console is its output. */ +/* + * Brand → DTCG token files → tokens.css (via Style Dictionary) + TS constants. + * + * yarn workspace @skillsoft/gamut-styles build:tokens + * + * Output lands in src/tokens/generated/ and is checked in, so the theme and + * the package build don't need the generator (or culori) at runtime. + */ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import StyleDictionary from 'style-dictionary'; +import { fileHeader, formattedVariables } from 'style-dictionary/utils'; + +import { skillsoftBrand } from '../src/colorSystem/brands/skillsoft'; +import { + EXTENSION, + toDtcgMode, + toDtcgPalette, + toTsModule, +} from '../src/colorSystem/emit'; +import { validateMigration } from '../src/colorSystem/migration'; +import type { Mode } from '../src/colorSystem/roles'; +import { createColorSystem } from '../src/colorSystem/system'; +import { validate } from '../src/colorSystem/validate'; + +const OUT = join(__dirname, '../src/tokens/generated'); + +const SELECTORS: Record = { + light: ':root, [data-color-mode="light"]', + dark: '[data-color-mode="dark"]', +}; + +/* + * css/variables, plus a `--gradient-` line for any role carrying a + * gradient in its $extensions. DTCG's gradient type can't describe the + * brand's stacked circles, so the CSS is precomputed by the emitter. + * + * Mode blocks also get: + * - `color-scheme`, so native controls and scrollbars follow the mode; + * - `color: var(--color-text-default)`, because `color` inherits as a computed + * value — without it, text under a nested `[data-color-mode]` keeps the + * parent mode's color (ColorMode sets it on its wrapper for the same reason); + * - a default `--color-background-current`, which ColorMode sets at runtime + * and a CSS-only subtree needs from somewhere. + */ +StyleDictionary.registerFormat({ + name: 'css/gamut-variables', + format: async ({ dictionary, options, file }) => { + const header = await fileHeader({ file }); + const vars = formattedVariables({ + format: 'css', + dictionary, + outputReferences: options.outputReferences, + usesDtcg: true, + }); + const gradients = dictionary.allTokens + .filter((t) => t.$extensions?.[EXTENSION]?.gradient) + .map( + (t) => + ` --gradient-${t.path.join('-')}: ${ + t.$extensions[EXTENSION].gradient.css + };` + ); + const { mode } = options as { mode?: Mode }; + const modeLines = mode + ? [ + ` color-scheme: ${mode};`, + ' color: var(--color-text-default);', + ' --color-background-current: var(--color-surface-default);', + ] + : []; + return `${header}${options.selector} {\n${[ + ...modeLines, + vars, + ...gradients, + ].join('\n')}\n}\n`; + }, +}); + +const cssFor = async ( + sources: string[], + selector: string, + only?: string, + mode?: Mode +): Promise => { + const sd = new StyleDictionary({ + source: sources, + usesDtcg: true, + log: { verbosity: 'silent', warnings: 'disabled' }, + platforms: { + css: { + transforms: ['name/kebab', 'color/oklch'], + prefix: 'color', + files: [ + { + destination: 'unused.css', + format: 'css/gamut-variables', + filter: only ? (t) => t.filePath.endsWith(only) : undefined, + options: { + selector, + mode, + outputReferences: true, + showFileHeader: false, + }, + }, + ], + }, + }, + }); + const [{ output }] = await sd.formatPlatform('css'); + return output as string; +}; + +const main = async () => { + const system = createColorSystem(skillsoftBrand); + + const failed = [...validate(system), ...validateMigration(system)].filter( + (c) => !c.pass + ); + if (failed.length) { + throw new Error( + `refusing to emit; failed checks:\n${failed + .map((c) => ` ${c.name}: ${c.worst} at ${c.at}`) + .join('\n')}` + ); + } + + await mkdir(OUT, { recursive: true }); + const files = { + palette: join(OUT, 'palette.tokens.json'), + light: join(OUT, 'light.tokens.json'), + dark: join(OUT, 'dark.tokens.json'), + }; + const json = (v: unknown) => `${JSON.stringify(v, null, 2)}\n`; + await writeFile(files.palette, json(toDtcgPalette(system))); + await writeFile(files.light, json(toDtcgMode(system, 'light'))); + await writeFile(files.dark, json(toDtcgMode(system, 'dark'))); + + const css = [ + `/* Generated by scripts/build-tokens.ts from the "${system.brand.name}" brand. Do not edit. */`, + await cssFor([files.palette], ':root'), + await cssFor( + [files.palette, files.light], + SELECTORS.light, + 'light.tokens.json', + 'light' + ), + await cssFor( + [files.palette, files.dark], + SELECTORS.dark, + 'dark.tokens.json', + 'dark' + ), + ].join('\n'); + await writeFile(join(OUT, 'tokens.css'), css); + /* The same CSS as a string, so GamutProvider can inject it through Emotion. */ + await writeFile( + join(OUT, 'tokensCss.ts'), + `/* Generated by scripts/build-tokens.ts: tokens.css as a string. Do not edit. */\nexport const tokensCss = ${JSON.stringify( + css + )};\n` + ); + + await writeFile(join(OUT, 'values.ts'), toTsModule(system)); + + console.log(`wrote ${OUT}`); +}; + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/gamut-styles/scripts/validate-tokens.ts b/packages/gamut-styles/scripts/validate-tokens.ts new file mode 100644 index 0000000000..a2bccb79a0 --- /dev/null +++ b/packages/gamut-styles/scripts/validate-tokens.ts @@ -0,0 +1,290 @@ +/* eslint-disable no-console -- CLI script; the console is its output. */ +/* + * Proves the emitted tokens are the system we meant to ship. + * + * yarn workspace @skillsoft/gamut-styles validate:tokens + * + * 1. The ported generator reproduces gamut-sandbox at dffd84b exactly. + * 2. The guarantees hold on the source system. + * 3. The guarantees hold again on values parsed back out of tokens.css — + * so nothing Style Dictionary did (rounding, references) broke them. + * 4. The generated files are up to date with the generator. + * 5. The content-mode table picks what Background's runtime math picked. + * 6. Sample tenants, run through the probe. + */ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { getContrast } from 'polished'; + +import type { BrandInput, Slot } from '../src/colorSystem/brand'; +import { SLOTS } from '../src/colorSystem/brand'; +import { skillsoftBrand } from '../src/colorSystem/brands/skillsoft'; +import { contentModes } from '../src/colorSystem/contentModes'; +import { toTsModule } from '../src/colorSystem/emit'; +import { validateMigration } from '../src/colorSystem/migration'; +import { probeTenant } from '../src/colorSystem/probe'; +import type { Grade, Ramp } from '../src/colorSystem/ramp'; +import { GRADES } from '../src/colorSystem/ramp'; +import { MODES, ROLES } from '../src/colorSystem/roles'; +import type { ColorSystem, ResolvedValue } from '../src/colorSystem/system'; +import { createColorSystem } from '../src/colorSystem/system'; +import type { Check } from '../src/colorSystem/validate'; +import { validate } from '../src/colorSystem/validate'; + +const ROOT = join(__dirname, '..'); +const GENERATED = join(ROOT, 'src/tokens/generated'); + +const FAMILY_TO_SLOT: Record = { + 'neutral-light': 'neutral-light', + 'neutral-dark': 'neutral-dark', + blue: 'primary', + magenta: 'accent', + violet: 'accent-alt', + red: 'danger', + amber: 'warning', + teal: 'success', +}; + +let failures = 0; +const report = (ok: boolean, line: string) => { + if (!ok) failures += 1; + console.log(`${ok ? 'PASS' : 'FAIL'} ${line}`); +}; +const printChecks = (checks: Check[]) => { + for (const c of checks) { + const line = `${c.name} — worst ${c.worst.toFixed(3)} (${c.at})`; + if (c.reported) console.log(`INFO ${line}`); + else report(c.pass, line); + } +}; + +const sandboxParity = async (system: ColorSystem) => { + const fixture = JSON.parse( + await readFile( + join(ROOT, 'src/colorSystem/__fixtures__/sandbox-dffd84b.json'), + 'utf8' + ) + ); + let rungs = 0; + const rungMisses: string[] = []; + for (const [family, f] of Object.entries(fixture.families)) { + for (const [grade, r] of Object.entries(f.rungs)) { + rungs += 1; + const mine = system.ramps[FAMILY_TO_SLOT[family]][Number(grade) as Grade]; + if (mine.l !== r.l || mine.c !== r.c || mine.hex !== r.hex) { + rungMisses.push(`${family}-${grade}`); + } + } + } + report( + rungMisses.length === 0, + `${rungs} rungs match sandbox (l, c to 4 dp; hex exact)${ + rungMisses.length ? `: ${rungMisses.join(', ')}` : '' + }` + ); + + const toKey = (primitive: string) => { + const base = primitive.split(' @ ')[0]; + if (base === 'white' || base === 'black') return base; + const i = base.lastIndexOf('-'); + return `palette-${FAMILY_TO_SLOT[base.slice(0, i)]}-${base.slice(i + 1)}`; + }; + const roleMisses: string[] = []; + for (const r of fixture.roles) { + for (const mode of MODES) { + const mine = system.roles[mode][r.token]; + const gradient = r[mode].primitive.includes(' on '); + const sameRef = + gradient || mine.key.replace(/-a\d+$/, '') === toKey(r[mode].primitive); + if (!mine || !sameRef || mine.hex !== r[mode].hex) { + roleMisses.push(`${r.token} (${mode})`); + } + } + } + report( + roleMisses.length === 0, + `${fixture.roles.length} roles × 2 modes match sandbox (primitive + hex)${ + roleMisses.length ? `: ${roleMisses.join(', ')}` : '' + }` + ); +}; + +/* + * Read tokens.css back into a ColorSystem-shaped object: palette values from + * the :root block, each role's var() resolved inside its mode block. + */ +const parseCss = (css: string, source: ColorSystem): ColorSystem => { + const blocks = [...css.matchAll(/([^{}]+)\{([^}]*)\}/g)].map((m) => ({ + selector: m[1].trim().split('\n').pop()!.trim(), + decls: Object.fromEntries( + [...m[2].matchAll(/(--[\w-]+):\s*([^;]+);/g)].map((d) => [d[1], d[2]]) + ), + })); + const root = blocks.find((b) => b.selector === ':root')!.decls; + const oklch = (value: string) => { + const m = value.match( + /^oklch\(([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)$/ + ); + if (!m) throw new Error(`not an oklch() value: ${value}`); + return { + l: Number(m[1]), + c: Number(m[2]), + h: Number(m[3]), + alpha: m[4] === undefined ? undefined : Number(m[4]), + }; + }; + + const ramps = Object.fromEntries( + SLOTS.map((slot) => [ + slot, + Object.fromEntries( + GRADES.map((grade) => { + const p = oklch(root[`--color-palette-${slot}-${grade}`]); + return [grade, { grade, l: p.l, c: p.c, h: p.h, hex: '' }]; + }) + ), + ]) + ) as Record; + + const roles = Object.fromEntries( + MODES.map((mode) => { + const block = blocks.find((b) => + b.selector.includes(`data-color-mode="${mode}"`) + )!.decls; + return [ + mode, + Object.fromEntries( + ROLES.map((r) => { + const ref = + block[`--color-${r.token}`].match(/^var\((--[\w-]+)\)$/); + if (!ref) throw new Error(`${r.token} (${mode}) is not a var()`); + const p = oklch(root[ref[1]]); + const value: ResolvedValue = { + ...p, + hex: '', + key: ref[1].replace(/^--color-/, ''), + }; + return [r.token, value]; + }) + ), + ]; + }) + ) as ColorSystem['roles']; + + return { ...source, ramps, roles }; +}; + +const emittedMatchesSource = (parsed: ColorSystem, source: ColorSystem) => { + const misses: string[] = []; + for (const mode of MODES) { + for (const r of ROLES) { + const a = parsed.roles[mode][r.token]; + const b = source.roles[mode][r.token]; + const same = + a.key === b.key && + a.l === b.l && + a.c === b.c && + a.h === b.h && + a.alpha === b.alpha; + if (!same) misses.push(`${r.token} (${mode})`); + } + } + report( + misses.length === 0, + `tokens.css round-trips every role to the source value and reference${ + misses.length ? `: ${misses.join(', ')}` : '' + }` + ); +}; + +const TENANTS: BrandInput[] = [ + { name: 'tier 1: warm primary (orange)', hues: { primary: 45 } }, + { name: 'tier 1: primary near our accent', hues: { primary: 290 } }, + { name: 'tier 1: from a hex (#E4002B)', hues: { primary: '#E4002B' } }, + { + name: 'tier 4: hostile, eight hues in a 40° arc', + hues: { + 'neutral-light': 250, + 'neutral-dark': 255, + 'accent-alt': 260, + primary: 265, + accent: 270, + danger: 275, + warning: 280, + success: 285, + }, + }, +]; + +/* + * Background picked a mode by running polished's getContrast on sRGB hex. + * The table measures OKLCH floats instead, so it can only disagree on a color + * sitting right at the crossover. Any disagreement is listed. + */ +const backgroundParity = (system: ColorSystem) => { + const table = contentModes(system); + const text = (mode: 'light' | 'dark') => + system.roles[mode]['text-default'].hex; + const differ = Object.entries(system.palette).filter(([key, { hex }]) => { + const runtime = + getContrast(text('dark'), hex) > getContrast(text('light'), hex) + ? 'dark' + : 'light'; + return runtime !== table[key]; + }); + report( + differ.length === 0, + `${ + Object.keys(table).length + } palette colors pick the same mode as runtime Background${ + differ.length ? `; differ: ${differ.map(([k]) => k).join(', ')}` : '' + }` + ); +}; + +const main = async () => { + const system = createColorSystem(skillsoftBrand); + + console.log('\n— sandbox parity (dffd84b)'); + await sandboxParity(system); + + console.log('\n— guarantees, source system'); + printChecks(validate(system)); + + console.log('\n— migration map (sandbox 4b73c2a)'); + printChecks(validateMigration(system)); + + console.log('\n— guarantees, re-measured on tokens.css'); + const css = await readFile(join(GENERATED, 'tokens.css'), 'utf8'); + const parsed = parseCss(css, system); + emittedMatchesSource(parsed, system); + printChecks([...validate(parsed), ...validateMigration(parsed)]); + + console.log('\n— generated files are current'); + const ts = await readFile(join(GENERATED, 'values.ts'), 'utf8'); + report(ts === toTsModule(system), 'values.ts matches the generator'); + + console.log('\n— content-mode table vs runtime Background'); + backgroundParity(system); + + console.log('\n— tenant probes (reported, not asserted)'); + for (const tenant of [skillsoftBrand, ...TENANTS]) { + const p = probeTenant(tenant); + console.log( + `INFO ${p.brand}: tier ${p.tier}, contrast ${ + p.contrastHolds ? 'holds' : `fails (${p.failedChecks.join('; ')})` + }, status trio ${p.statusSeparation.toFixed(2)} ΔE (${p.statusWorst})${ + p.derived.length ? `, derived: ${p.derived.join(', ')}` : '' + }` + ); + } + + console.log(failures ? `\n${failures} failed` : '\nall checks pass'); + process.exit(failures ? 1 : 0); +}; + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/gamut-styles/src/Background.tsx b/packages/gamut-styles/src/Background.tsx index ca19d2cbf1..3d425fb6ce 100644 --- a/packages/gamut-styles/src/Background.tsx +++ b/packages/gamut-styles/src/Background.tsx @@ -41,7 +41,7 @@ const textOf = (mode: ColorModeShape) => export const Background = forwardRef( ({ bg, ...rest }, ref) => { const [active, activeColors, modes, getColorValue] = useColorModes(); - /* Generated themes ship the answer (contentModes, decided at build time in the color spike). */ + /* Generated themes ship the answer (colorSystem/contentModes.ts). */ const { contentModes } = useTheme() as Partial; /** If a color alias was used then look up the true color key from the active mode */ diff --git a/packages/gamut-styles/src/colorSystem/README.md b/packages/gamut-styles/src/colorSystem/README.md new file mode 100644 index 0000000000..e563799d31 --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/README.md @@ -0,0 +1,89 @@ +# Color system (spike) + +**Spike, not shipped API.** Branch `cass-color-spike`. Validates the generated +OKLCH color system from gamut-sandbox PR #34 (`stacey/color-tokens-v2`, pinned +at `dffd84b`; generator and roles unchanged through `4b73c2a`) as a tokens-only output of gamut-styles. Research, decisions and +findings live in Base Camp: `gamut/color-tokens/`. + +## The pipeline + +``` +brands/skillsoft.ts one hue per slot (or just `primary`) + → brand.ts fills missing slots, reports the white-label tier + → ramp.ts 11 grades per slot: fixed L, chroma as % of gamut + → roles.ts ~45 roles, light + dark, named by slot not hue + → system.ts resolved system + → contentModes.ts which mode's content goes on each color (build time) + → validate.ts / probe.ts contrast guarantees, veil floors, status trio + → migration.ts design's old → new map and component slots (sandbox 4b73c2a), checked per brand + → emit.ts DTCG 2025.10 JSON + TS constants + → scripts/build-tokens.ts Style Dictionary → tokens.css +``` + +Output is checked in at `src/tokens/generated/` and ships as: + +| Import | What | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `@skillsoft/gamut-styles/tokens` | TS constants: `palette`, `roles`, `gradients`, `contentModes` (OKLCH + sRGB per token) | +| `@skillsoft/gamut-styles/tokens.css` | `--color-*` / `--gradient-*` custom properties. Palette on `:root`; roles on `:root, [data-color-mode="light"]` and `[data-color-mode="dark"]` | +| `@skillsoft/gamut-styles/tokens/{palette,light,dark}.tokens.json` | DTCG 2025.10. Colors are `{ colorSpace: "oklch", components, alpha, hex }` | +| `skillsoftTheme` (main entry) | Emotion theme on the new roles; the default theme and what gamut is typed against | + +## The CSS contract (branch `cass-color-css-contract`) + +`tokens.css` owns the color values. `skillsoftTheme` carries it as +`tokensCss`, and `GamutProvider` injects it through Emotion, so a React app +gets the colors without loading anything. The static file is for pages +without React or Emotion; an app that loads it itself can pass +`useTokens={false}`. + +- A mode switch is an attribute: `data-color-mode="light" | "dark"` on any + element, nested as deep as you like. Mode blocks also set `color-scheme`, + `color: var(--color-text-default)` and a default + `--color-background-current`. +- A surface without Emotion: resolve the role to its palette color in the + outer mode (`roles[mode][role].ref`), paint `var(--color-)`, and + set `data-color-mode` to `contentModes[palette]`. The palette color is the + same in every mode, so the flip can't change the background under it. +- `Background` reads `theme.contentModes` when the theme has it, and only + falls back to polished's contrast math on themes that don't. +- Example: Storybook `Foundations/Color system/CSS contract`. + +## Commands + +```sh +yarn workspace @skillsoft/gamut-styles build:tokens # regenerate src/tokens/generated +yarn workspace @skillsoft/gamut-styles validate:tokens # full report (sandbox parity, CSS round-trip, tenant probes) +npx nx test gamut-styles # guarantees + generated-files-current, in CI +``` + +## Naming + +- Primitives: `--color-palette--`, e.g. `--color-palette-primary-800`. + Slots: `neutral-light`, `neutral-dark`, `primary`, `accent`, `accent-alt`, + `danger`, `warning`, `success`. Not for components; use roles. +- Translucent primitives: `--color-palette-primary-600-a6` (6% alpha), `--color-black-a60`. +- Roles: `--color-`, e.g. `--color-action-primary`. +- `white` / `black` keep their bare names (boundary tokens). + +The `--color-` prefix on primitives is there because variance's `addColors` +always emits `--color-`; keeping one name for both the Emotion and CSS +routes beat the shorter `--palette-*`. + +## Known gaps + +- The categorical solver isn't ported. `viz-1…8` use the slot/grade order the + sandbox solved for Skillsoft's hues; a tenant gets that order unsolved. +- The theme carries sRGB, not OKLCH: `Background` and `BarChart` run + polished's `getContrast` on theme values, and polished can't parse `oklch()`. +- Components that reference core primitives directly (`navy-900`, `gray-100`, + `yellow`, …) render wrong on this theme. Listed in Base Camp `mapping.md`. +- gamut is typed against `skillsoftTheme`, which has no legacy names. The + other themes are still exported but are no longer what components are + checked against. `ColorMode` still works on them (`roleOr` falls back to + the old names). +- `migration.ts` is design's rename table: each alias is a rename, merge, + split or retire. Only rename and merge are mechanical. It isn't shipped in + the tokens output; the codemod reads it from source. +- The sandbox measures component slots on rounded hex; this port measures + OKLCH floats. Same 200 pairings, ratios within 0.074, same pass/fail. diff --git a/packages/gamut-styles/src/colorSystem/__fixtures__/sandbox-dffd84b.json b/packages/gamut-styles/src/colorSystem/__fixtures__/sandbox-dffd84b.json new file mode 100644 index 0000000000..642d85b767 --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/__fixtures__/sandbox-dffd84b.json @@ -0,0 +1,969 @@ +{ + "source": "gamut-sandbox PR #34 (stacey/color-tokens-v2) at dffd84b — dumped from src/colorSystem palette.ts / roles.ts", + "families": { + "neutral-light": { + "hue": 93.7, + "neutral": true, + "rungs": { + "100": { + "l": 0.9862, + "c": 0.0041, + "hex": "#fbfaf7" + }, + "200": { + "l": 0.9364, + "c": 0.0075, + "hex": "#ebeae4" + }, + "300": { + "l": 0.8497, + "c": 0.0191, + "hex": "#d2cec0" + }, + "400": { + "l": 0.7628, + "c": 0.0218, + "hex": "#b6b2a3" + }, + "500": { + "l": 0.6707, + "c": 0.0241, + "hex": "#9a9585" + }, + "600": { + "l": 0.5835, + "c": 0.0239, + "hex": "#807b6c" + }, + "700": { + "l": 0.4972, + "c": 0.0193, + "hex": "#666256" + }, + "800": { + "l": 0.4115, + "c": 0.0151, + "hex": "#4d4b42" + }, + "900": { + "l": 0.3346, + "c": 0.012, + "hex": "#383730" + }, + "1000": { + "l": 0.2499, + "c": 0.0086, + "hex": "#23221d" + }, + "1100": { + "l": 0.1925, + "c": 0.0063, + "hex": "#151411" + } + } + }, + "neutral-dark": { + "hue": 270.7, + "neutral": true, + "rungs": { + "100": { + "l": 0.9862, + "c": 0.0012, + "hex": "#fafafb" + }, + "200": { + "l": 0.9364, + "c": 0.0024, + "hex": "#e9eaeb" + }, + "300": { + "l": 0.8497, + "c": 0.0081, + "hex": "#cbcdd3" + }, + "400": { + "l": 0.7628, + "c": 0.0168, + "hex": "#aeb2bd" + }, + "500": { + "l": 0.6707, + "c": 0.0305, + "hex": "#8e95a9" + }, + "600": { + "l": 0.5835, + "c": 0.0454, + "hex": "#717a97" + }, + "700": { + "l": 0.4972, + "c": 0.0538, + "hex": "#576182" + }, + "800": { + "l": 0.4115, + "c": 0.0482, + "hex": "#414a65" + }, + "900": { + "l": 0.3346, + "c": 0.0383, + "hex": "#2f364b" + }, + "1000": { + "l": 0.2499, + "c": 0.0273, + "hex": "#1d212f" + }, + "1100": { + "l": 0.1925, + "c": 0.02, + "hex": "#11141d" + } + } + }, + "violet": { + "hue": 287.1, + "neutral": false, + "rungs": { + "100": { + "l": 0.9862, + "c": 0.0067, + "hex": "#fafaff" + }, + "200": { + "l": 0.9364, + "c": 0.0127, + "hex": "#e9e9f2" + }, + "300": { + "l": 0.8497, + "c": 0.043, + "hex": "#cbcae9" + }, + "400": { + "l": 0.7628, + "c": 0.0896, + "hex": "#aeaae9" + }, + "500": { + "l": 0.6707, + "c": 0.1624, + "hex": "#9182f3" + }, + "600": { + "l": 0.5835, + "c": 0.2416, + "hex": "#7a52ff" + }, + "700": { + "l": 0.4972, + "c": 0.2722, + "hex": "#661af0" + }, + "800": { + "l": 0.4115, + "c": 0.2134, + "hex": "#4d1cb5" + }, + "900": { + "l": 0.3346, + "c": 0.1696, + "hex": "#381587" + }, + "1000": { + "l": 0.2499, + "c": 0.1209, + "hex": "#220d57" + }, + "1100": { + "l": 0.1925, + "c": 0.0887, + "hex": "#150739" + } + } + }, + "blue": { + "hue": 262.4, + "neutral": false, + "rungs": { + "100": { + "l": 0.9862, + "c": 0.0064, + "hex": "#f8fbff" + }, + "200": { + "l": 0.9364, + "c": 0.0121, + "hex": "#e6eaf2" + }, + "300": { + "l": 0.8497, + "c": 0.0407, + "hex": "#c0cee9" + }, + "400": { + "l": 0.7628, + "c": 0.0846, + "hex": "#95b2e8" + }, + "500": { + "l": 0.6707, + "c": 0.1531, + "hex": "#6192f3" + }, + "600": { + "l": 0.5835, + "c": 0.2274, + "hex": "#276eff" + }, + "700": { + "l": 0.4972, + "c": 0.2254, + "hex": "#0a51df" + }, + "800": { + "l": 0.4115, + "c": 0.1767, + "hex": "#0c3fa8" + }, + "900": { + "l": 0.3346, + "c": 0.1405, + "hex": "#082e7d" + }, + "1000": { + "l": 0.2499, + "c": 0.1001, + "hex": "#051c50" + }, + "1100": { + "l": 0.1925, + "c": 0.0734, + "hex": "#031135" + } + } + }, + "magenta": { + "hue": 357.3, + "neutral": false, + "rungs": { + "100": { + "l": 0.9862, + "c": 0.0073, + "hex": "#fff9fa" + }, + "200": { + "l": 0.9364, + "c": 0.0141, + "hex": "#f2e6ea" + }, + "300": { + "l": 0.8497, + "c": 0.0499, + "hex": "#eac1ce" + }, + "400": { + "l": 0.7628, + "c": 0.11, + "hex": "#ea94b2" + }, + "500": { + "l": 0.6707, + "c": 0.2144, + "hex": "#f44b97" + }, + "600": { + "l": 0.5835, + "c": 0.238, + "hex": "#dd007c" + }, + "700": { + "l": 0.4972, + "c": 0.1927, + "hex": "#af1363" + }, + "800": { + "l": 0.4115, + "c": 0.1511, + "hex": "#85154b" + }, + "900": { + "l": 0.3346, + "c": 0.1201, + "hex": "#620f37" + }, + "1000": { + "l": 0.2499, + "c": 0.0856, + "hex": "#3f0922" + }, + "1100": { + "l": 0.1925, + "c": 0.0628, + "hex": "#290515" + } + } + }, + "red": { + "hue": 17.3, + "neutral": false, + "rungs": { + "100": { + "l": 0.9862, + "c": 0.0066, + "hex": "#fff9f9" + }, + "200": { + "l": 0.9364, + "c": 0.0128, + "hex": "#f2e7e7" + }, + "300": { + "l": 0.8497, + "c": 0.0452, + "hex": "#eac2c3" + }, + "400": { + "l": 0.7628, + "c": 0.0993, + "hex": "#ea989b" + }, + "500": { + "l": 0.6707, + "c": 0.1924, + "hex": "#f45769" + }, + "600": { + "l": 0.5835, + "c": 0.2336, + "hex": "#e40045" + }, + "700": { + "l": 0.4972, + "c": 0.1891, + "hex": "#b51439" + }, + "800": { + "l": 0.4115, + "c": 0.1482, + "hex": "#89162c" + }, + "900": { + "l": 0.3346, + "c": 0.1178, + "hex": "#66101f" + }, + "1000": { + "l": 0.2499, + "c": 0.084, + "hex": "#410912" + }, + "1100": { + "l": 0.1925, + "c": 0.0616, + "hex": "#2a050a" + } + } + }, + "amber": { + "hue": 88, + "neutral": false, + "rungs": { + "100": { + "l": 0.9862, + "c": 0.016, + "hex": "#fffaef" + }, + "200": { + "l": 0.9364, + "c": 0.03, + "hex": "#f2e9d4" + }, + "300": { + "l": 0.8497, + "c": 0.0955, + "hex": "#e8cb84" + }, + "400": { + "l": 0.7628, + "c": 0.1091, + "hex": "#cfae5b" + }, + "500": { + "l": 0.6707, + "c": 0.1206, + "hex": "#b4902d" + }, + "600": { + "l": 0.5835, + "c": 0.1192, + "hex": "#997600" + }, + "700": { + "l": 0.4972, + "c": 0.0965, + "hex": "#795e0e" + }, + "800": { + "l": 0.4115, + "c": 0.0757, + "hex": "#5c4810" + }, + "900": { + "l": 0.3346, + "c": 0.0601, + "hex": "#43340b" + }, + "1000": { + "l": 0.2499, + "c": 0.0429, + "hex": "#2a2006" + }, + "1100": { + "l": 0.1925, + "c": 0.0314, + "hex": "#1a1304" + } + } + }, + "teal": { + "hue": 200, + "neutral": false, + "rungs": { + "100": { + "l": 0.9862, + "c": 0.017, + "hex": "#eefeff" + }, + "200": { + "l": 0.9364, + "c": 0.0318, + "hex": "#d2f1f2" + }, + "300": { + "l": 0.8497, + "c": 0.0794, + "hex": "#8ddee2" + }, + "400": { + "l": 0.7628, + "c": 0.0907, + "hex": "#64c4c9" + }, + "500": { + "l": 0.6707, + "c": 0.1003, + "hex": "#34a8ae" + }, + "600": { + "l": 0.5835, + "c": 0.0991, + "hex": "#018d92" + }, + "700": { + "l": 0.4972, + "c": 0.0803, + "hex": "#117074" + }, + "800": { + "l": 0.4115, + "c": 0.0629, + "hex": "#135558" + }, + "900": { + "l": 0.3346, + "c": 0.05, + "hex": "#0e3e41" + }, + "1000": { + "l": 0.2499, + "c": 0.0356, + "hex": "#082728" + }, + "1100": { + "l": 0.1925, + "c": 0.0261, + "hex": "#041819" + } + } + } + }, + "roles": [ + { + "token": "surface-sunken", + "light": { + "primitive": "neutral-light-200", + "hex": "#ebeae4" + }, + "dark": { + "primitive": "black", + "hex": "#000000" + } + }, + { + "token": "surface-default", + "light": { + "primitive": "neutral-light-100", + "hex": "#fbfaf7" + }, + "dark": { + "primitive": "neutral-dark-1100", + "hex": "#11141d" + } + }, + { + "token": "surface-raised", + "light": { + "primitive": "white", + "hex": "#ffffff" + }, + "dark": { + "primitive": "neutral-dark-1000", + "hex": "#1d212f" + } + }, + { + "token": "surface-success", + "light": { + "primitive": "teal-200", + "hex": "#d2f1f2" + }, + "dark": { + "primitive": "teal-1000", + "hex": "#082728" + } + }, + { + "token": "surface-warning", + "light": { + "primitive": "amber-200", + "hex": "#f2e9d4" + }, + "dark": { + "primitive": "amber-1000", + "hex": "#2a2006" + } + }, + { + "token": "surface-error", + "light": { + "primitive": "red-200", + "hex": "#f2e7e7" + }, + "dark": { + "primitive": "red-1000", + "hex": "#410912" + } + }, + { + "token": "surface-brand", + "light": { + "primitive": "magenta-300 + blue-300 on neutral-light-100", + "hex": "#fbfaf7" + }, + "dark": { + "primitive": "magenta-900 + blue-900 on neutral-dark-1100", + "hex": "#11141d" + } + }, + { + "token": "surface-brand-subtle", + "light": { + "primitive": "blue-300 on neutral-light-100", + "hex": "#fbfaf7" + }, + "dark": { + "primitive": "blue-900 on neutral-dark-1100", + "hex": "#11141d" + } + }, + { + "token": "text-default", + "light": { + "primitive": "neutral-dark-1000", + "hex": "#1d212f" + }, + "dark": { + "primitive": "neutral-light-200", + "hex": "#ebeae4" + } + }, + { + "token": "text-subtle", + "light": { + "primitive": "neutral-dark-800", + "hex": "#414a65" + }, + "dark": { + "primitive": "neutral-light-400", + "hex": "#b6b2a3" + } + }, + { + "token": "text-inverse", + "light": { + "primitive": "neutral-light-200", + "hex": "#ebeae4" + }, + "dark": { + "primitive": "neutral-dark-1000", + "hex": "#1d212f" + } + }, + { + "token": "text-success", + "light": { + "primitive": "teal-900", + "hex": "#0e3e41" + }, + "dark": { + "primitive": "teal-300", + "hex": "#8ddee2" + } + }, + { + "token": "text-warning", + "light": { + "primitive": "amber-900", + "hex": "#43340b" + }, + "dark": { + "primitive": "amber-300", + "hex": "#e8cb84" + } + }, + { + "token": "text-error", + "light": { + "primitive": "red-900", + "hex": "#66101f" + }, + "dark": { + "primitive": "red-300", + "hex": "#eac2c3" + } + }, + { + "token": "border-strong", + "light": { + "primitive": "neutral-dark-1100", + "hex": "#11141d" + }, + "dark": { + "primitive": "neutral-light-100", + "hex": "#fbfaf7" + } + }, + { + "token": "border-default", + "light": { + "primitive": "neutral-dark-600", + "hex": "#717a97" + }, + "dark": { + "primitive": "neutral-light-600", + "hex": "#807b6c" + } + }, + { + "token": "border-subtle", + "light": { + "primitive": "neutral-dark-300", + "hex": "#cbcdd3" + }, + "dark": { + "primitive": "neutral-light-900", + "hex": "#383730" + } + }, + { + "token": "action-primary", + "light": { + "primitive": "blue-800", + "hex": "#0c3fa8" + }, + "dark": { + "primitive": "blue-400", + "hex": "#95b2e8" + } + }, + { + "token": "action-primary-hover", + "light": { + "primitive": "blue-900", + "hex": "#082e7d" + }, + "dark": { + "primitive": "blue-300", + "hex": "#c0cee9" + } + }, + { + "token": "action-primary-pressed", + "light": { + "primitive": "blue-1000", + "hex": "#051c50" + }, + "dark": { + "primitive": "blue-200", + "hex": "#e6eaf2" + } + }, + { + "token": "action-neutral", + "light": { + "primitive": "neutral-dark-1000", + "hex": "#1d212f" + }, + "dark": { + "primitive": "neutral-light-200", + "hex": "#ebeae4" + } + }, + { + "token": "action-neutral-hover", + "light": { + "primitive": "neutral-dark-1100", + "hex": "#11141d" + }, + "dark": { + "primitive": "neutral-light-100", + "hex": "#fbfaf7" + } + }, + { + "token": "action-neutral-pressed", + "light": { + "primitive": "black", + "hex": "#000000" + }, + "dark": { + "primitive": "white", + "hex": "#ffffff" + } + }, + { + "token": "action-danger", + "light": { + "primitive": "red-800", + "hex": "#89162c" + }, + "dark": { + "primitive": "red-400", + "hex": "#ea989b" + } + }, + { + "token": "action-danger-hover", + "light": { + "primitive": "red-900", + "hex": "#66101f" + }, + "dark": { + "primitive": "red-300", + "hex": "#eac2c3" + } + }, + { + "token": "action-danger-pressed", + "light": { + "primitive": "red-1000", + "hex": "#410912" + }, + "dark": { + "primitive": "red-200", + "hex": "#f2e7e7" + } + }, + { + "token": "action-disabled", + "light": { + "primitive": "neutral-light-400", + "hex": "#b6b2a3" + }, + "dark": { + "primitive": "neutral-dark-800", + "hex": "#414a65" + } + }, + { + "token": "state-hover", + "light": { + "primitive": "blue-600 @ .06", + "hex": "#276eff" + }, + "dark": { + "primitive": "blue-600 @ .06", + "hex": "#276eff" + } + }, + { + "token": "state-pressed", + "light": { + "primitive": "blue-600 @ .09", + "hex": "#276eff" + }, + "dark": { + "primitive": "blue-600 @ .09", + "hex": "#276eff" + } + }, + { + "token": "state-selected", + "light": { + "primitive": "blue-600 @ .08", + "hex": "#276eff" + }, + "dark": { + "primitive": "blue-600 @ .08", + "hex": "#276eff" + } + }, + { + "token": "viz-1", + "light": { + "primitive": "blue-800", + "hex": "#0c3fa8" + }, + "dark": { + "primitive": "blue-500", + "hex": "#6192f3" + } + }, + { + "token": "viz-2", + "light": { + "primitive": "magenta-700", + "hex": "#af1363" + }, + "dark": { + "primitive": "magenta-500", + "hex": "#f44b97" + } + }, + { + "token": "viz-3", + "light": { + "primitive": "amber-700", + "hex": "#795e0e" + }, + "dark": { + "primitive": "amber-400", + "hex": "#cfae5b" + } + }, + { + "token": "viz-4", + "light": { + "primitive": "violet-600", + "hex": "#7a52ff" + }, + "dark": { + "primitive": "violet-600", + "hex": "#7a52ff" + } + }, + { + "token": "viz-5", + "light": { + "primitive": "teal-600", + "hex": "#018d92" + }, + "dark": { + "primitive": "teal-300", + "hex": "#8ddee2" + } + }, + { + "token": "viz-6", + "light": { + "primitive": "red-600", + "hex": "#e40045" + }, + "dark": { + "primitive": "red-600", + "hex": "#e40045" + } + }, + { + "token": "viz-7", + "light": { + "primitive": "neutral-dark-1000", + "hex": "#1d212f" + }, + "dark": { + "primitive": "neutral-dark-500", + "hex": "#8e95a9" + } + }, + { + "token": "viz-8", + "light": { + "primitive": "neutral-light-600", + "hex": "#807b6c" + }, + "dark": { + "primitive": "neutral-light-600", + "hex": "#807b6c" + } + }, + { + "token": "viz-seq-1", + "light": { + "primitive": "blue-600", + "hex": "#276eff" + }, + "dark": { + "primitive": "blue-600", + "hex": "#276eff" + } + }, + { + "token": "viz-seq-2", + "light": { + "primitive": "blue-700", + "hex": "#0a51df" + }, + "dark": { + "primitive": "blue-500", + "hex": "#6192f3" + } + }, + { + "token": "viz-seq-3", + "light": { + "primitive": "blue-800", + "hex": "#0c3fa8" + }, + "dark": { + "primitive": "blue-400", + "hex": "#95b2e8" + } + }, + { + "token": "viz-seq-4", + "light": { + "primitive": "blue-900", + "hex": "#082e7d" + }, + "dark": { + "primitive": "blue-300", + "hex": "#c0cee9" + } + }, + { + "token": "backdrop", + "light": { + "primitive": "black @ .6", + "hex": "#000000" + }, + "dark": { + "primitive": "black @ .6", + "hex": "#000000" + } + } + ], + "statusSeparation": 1.867409891063702 +} diff --git a/packages/gamut-styles/src/colorSystem/__tests__/system.test.ts b/packages/gamut-styles/src/colorSystem/__tests__/system.test.ts new file mode 100644 index 0000000000..2c1a73c55f --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/__tests__/system.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { tokensCss } from '../../tokens/generated/tokensCss'; +import { skillsoftBrand } from '../brands/skillsoft'; +import { toTsModule } from '../emit'; +import { validateMigration } from '../migration'; +import { createColorSystem } from '../system'; +import { validate } from '../validate'; + +/* The full report, including the tokens.css round-trip, is `yarn validate:tokens`. */ +describe('color system', () => { + const system = createColorSystem(skillsoftBrand); + + it.each( + [...validate(system), ...validateMigration(system)].filter( + (c) => !c.reported + ) + )('$name', (check) => { + expect(check.worst).toBeGreaterThanOrEqual(check.floor); + }); + + it('generated values are current — run `yarn build:tokens`', () => { + const generated = readFileSync( + join(__dirname, '../../tokens/generated/values.ts'), + 'utf8' + ); + expect(generated).toBe(toTsModule(system)); + }); + + it('tokensCss is tokens.css — run `yarn build:tokens`', () => { + const css = readFileSync( + join(__dirname, '../../tokens/generated/tokens.css'), + 'utf8' + ); + expect(tokensCss).toBe(css); + }); + + it('derives a tier 1 brand from one hue', () => { + const tenant = createColorSystem({ name: 'tenant', hues: { primary: 45 } }); + expect(tenant.brand.tier).toBe(1); + expect(tenant.brand.hues['neutral-dark']).toBe(53.3); + expect( + [...validate(tenant), ...validateMigration(tenant)].every((c) => c.pass) + ).toBe(true); + }); +}); diff --git a/packages/gamut-styles/src/colorSystem/brand.ts b/packages/gamut-styles/src/colorSystem/brand.ts new file mode 100644 index 0000000000..5d87812849 --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/brand.ts @@ -0,0 +1,122 @@ +import { converter } from 'culori'; + +/* + * Brands, as data. + * + * Roles never name a hue ("blue-800"). They name a slot ("primary-800"), and a + * brand maps slots to hues. White-labelling a tenant means writing a new + * `BrandInput`, not a new theme. + * + * A brand only has to supply `primary`. Anything it leaves out is filled in by + * the derivation rules below (gamut-sandbox `whiteLabel.ts`, D1–D4), and the + * resolved brand reports which tier that puts it on. + */ + +export const SLOTS = [ + 'neutral-light', + 'neutral-dark', + 'primary', + 'accent', + 'accent-alt', + 'danger', + 'warning', + 'success', +] as const; + +export type Slot = (typeof SLOTS)[number]; + +export const NEUTRAL_SLOTS: readonly Slot[] = ['neutral-light', 'neutral-dark']; +export const STATUS_SLOTS: readonly Slot[] = ['success', 'warning', 'danger']; +export const ACCENT_SLOTS: readonly Slot[] = ['accent', 'accent-alt']; + +/* A hue in degrees, or a hex to take the OKLCH hue from. */ +export type HueInput = number | string; + +export interface BrandInput { + name: string; + hues: { primary: HueInput } & Partial>; +} + +export type Tier = 1 | 2 | 3 | 4; + +export interface ResolvedBrand { + name: string; + hues: Record; + /* Slots the brand didn't supply, and where their hue came from. */ + derived: Partial>; + tier: Tier; +} + +/* + * The cool neutral sits a few degrees past the primary. Skillsoft's navy and + * Sapphire already measure this offset (270.7° vs 262.4°); nobody arranged it. + */ +export const COOL_NEUTRAL_OFFSET = 8.3; + +/* The warm neutral sits opposite the cool one. Skillsoft's pair is 177° apart. */ +export const WARM_NEUTRAL_OFFSET = 180; + +/* + * Status and accent hues a tenant doesn't supply stay on these. They aren't + * free parameters: red means error everywhere, and amber/teal were solved for + * status-trio separation under color-vision deficiency. + */ +export const DEFAULT_HUES: Record = { + 'neutral-light': 93.7, + 'neutral-dark': 270.7, + primary: 262.4, + accent: 357.3, + 'accent-alt': 287.1, + danger: 17.3, + warning: 88.0, + success: 200.0, +}; + +const toOklch = converter('oklch'); + +const wrap = (deg: number): number => + Number((((deg % 360) + 360) % 360).toFixed(1)); + +export const hueOf = (input: HueInput): number => { + if (typeof input === 'number') return wrap(input); + const color = toOklch(input); + if (color?.h === undefined) { + throw new Error(`"${input}" has no hue — supply a chromatic color`); + } + return wrap(color.h); +}; + +const tierOf = (supplied: Set): Tier => { + if (SLOTS.every((s) => supplied.has(s))) return 4; + if (STATUS_SLOTS.every((s) => supplied.has(s))) return 3; + if (NEUTRAL_SLOTS.every((s) => supplied.has(s))) return 2; + return 1; +}; + +export const resolveBrand = (input: BrandInput): ResolvedBrand => { + const supplied = new Set(Object.keys(input.hues) as Slot[]); + const hues = {} as Record; + const derived: ResolvedBrand['derived'] = {}; + + for (const slot of supplied) hues[slot] = hueOf(input.hues[slot]!); + + if (!supplied.has('neutral-dark')) { + hues['neutral-dark'] = supplied.has('neutral-light') + ? wrap(hues['neutral-light'] - WARM_NEUTRAL_OFFSET) + : wrap(hues.primary + COOL_NEUTRAL_OFFSET); + derived['neutral-dark'] = supplied.has('neutral-light') + ? 'opposite neutral-light' + : `primary + ${COOL_NEUTRAL_OFFSET}°`; + } + if (!supplied.has('neutral-light')) { + hues['neutral-light'] = wrap(hues['neutral-dark'] + WARM_NEUTRAL_OFFSET); + derived['neutral-light'] = 'opposite neutral-dark'; + } + for (const slot of [...STATUS_SLOTS, ...ACCENT_SLOTS]) { + if (supplied.has(slot)) continue; + hues[slot] = DEFAULT_HUES[slot]; + derived[slot] = 'held at the gamut default'; + } + + return { name: input.name, hues, derived, tier: tierOf(supplied) }; +}; diff --git a/packages/gamut-styles/src/colorSystem/brands/skillsoft.ts b/packages/gamut-styles/src/colorSystem/brands/skillsoft.ts new file mode 100644 index 0000000000..2eb7b337cc --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/brands/skillsoft.ts @@ -0,0 +1,20 @@ +import type { BrandInput } from '../brand'; + +/* + * Skillsoft is tier 4: every slot supplied. Hues come from the brand deck via + * OKLCH (gamut-sandbox `palette.ts` at dffd84b); only the hue survives, so no + * brand hex appears verbatim in the ramps. + */ +export const skillsoftBrand: BrandInput = { + name: 'skillsoft', + hues: { + 'neutral-light': 93.7, // Paper #FFFBED + 'neutral-dark': 270.7, // Navy #151C36 + primary: 262.4, // Sapphire #1C50BB + accent: 357.3, // Mulberry #CD427F + 'accent-alt': 287.1, // Lilac #B5B2E7 + danger: 17.3, // Red #FF375B + warning: 88.0, // no brand source; solved for status-trio separation + success: 200.0, // no brand source; solved jointly with warning + }, +}; diff --git a/packages/gamut-styles/src/colorSystem/contentModes.ts b/packages/gamut-styles/src/colorSystem/contentModes.ts new file mode 100644 index 0000000000..6caa2e393f --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/contentModes.ts @@ -0,0 +1,32 @@ +import { contrast } from './ramp'; +import type { Mode } from './roles'; +import { MODES } from './roles'; +import type { Color, ColorSystem } from './system'; + +/* + * Which mode's content sits on a color, decided at build time. + * + * Background used to work this out at runtime, with polished's contrast math + * on every render. The rule is the same — the mode whose `text-default` + * contrasts most with the color — but measured once, on the OKLCH floats, and + * shipped as data. A zero-runtime engine can't measure contrast at all, so + * this table is what a CSS-only Background reads: put the color down, then + * set `data-color-mode` to the mode listed here. + */ + +export const contentModeFor = (system: ColorSystem, color: Color): Mode => + MODES.reduce((best, mode) => + contrast(system.roles[mode]['text-default'], color) > + contrast(system.roles[best]['text-default'], color) + ? mode + : best + ); + +/* Every opaque palette color → the mode its content uses. */ +export const contentModes = (system: ColorSystem): Record => + Object.fromEntries( + Object.entries(system.palette).map(([key, color]) => [ + key, + contentModeFor(system, color), + ]) + ); diff --git a/packages/gamut-styles/src/colorSystem/emit.ts b/packages/gamut-styles/src/colorSystem/emit.ts new file mode 100644 index 0000000000..9c4dde248d --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/emit.ts @@ -0,0 +1,216 @@ +import { contentModes } from './contentModes'; +import type { Gradient, Mode, Ref, RoleValue } from './roles'; +import { FALLOFF, MODES, ROLES } from './roles'; +import type { Color, ColorSystem } from './system'; +import { paletteKey, valueKey } from './system'; + +/* + * Color system → DTCG 2025.10 token files and TS constants. + * + * Three DTCG files, because DTCG has no modes of its own (the 2025.10 + * Resolver module covers that, and Style Dictionary doesn't support it yet): + * one palette, plus one role file per mode that references it. + * + * Every color is stored as OKLCH components with a 6-digit hex fallback. + * OKLCH is the source of truth; hex is for consumers outside CSS. + */ + +export const EXTENSION = 'com.skillsoft.gamut'; + +type DtcgColor = { + colorSpace: 'oklch'; + components: [number, number, number]; + alpha: number; + hex: string; +}; + +type DtcgToken = { + $type: 'color'; + $value: DtcgColor | string; + $description?: string; + $extensions?: Record; +}; + +type DtcgGroup = { [key: string]: DtcgToken | DtcgGroup | string }; + +const dtcgColor = (color: Color, alpha = 1): DtcgColor => ({ + colorSpace: 'oklch', + components: [color.l, color.c, color.h], + alpha, + hex: color.hex, +}); + +export const oklchCss = ({ l, c, h }: Color, alpha?: number): string => + alpha === undefined || alpha === 1 + ? `oklch(${l} ${c} ${h})` + : `oklch(${l} ${c} ${h} / ${alpha})`; + +/* Hex, or rgba() when translucent — what polished-based color math can parse. */ +export const srgbCss = ({ hex }: Color, alpha?: number): string => { + if (alpha === undefined || alpha === 1) return hex; + const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; + +/* `{ slot: 'primary', grade: 800 }` → `['palette', 'primary', '800']`. */ +const refPath = (ref: Ref, alpha?: number): string[] => { + const suffix = alpha === undefined ? '' : `-a${Math.round(alpha * 100)}`; + return typeof ref === 'string' + ? [`${ref}${suffix}`] + : ['palette', ref.slot, `${ref.grade}${suffix}`]; +}; + +const refString = (value: RoleValue) => + `{${refPath(value.ref, value.alpha).join('.')}}`; + +const setPath = (group: DtcgGroup, path: string[], token: DtcgToken) => { + let node = group; + for (const key of path.slice(0, -1)) { + node[key] ??= {}; + node = node[key] as DtcgGroup; + } + node[path[path.length - 1]] = token; +}; + +/* Every translucent value a role uses gets its own palette entry. */ +const translucentValues = (): RoleValue[] => { + const seen = new Map(); + for (const role of ROLES) { + for (const mode of MODES) { + if (role[mode].alpha !== undefined) { + seen.set(valueKey(role[mode]), role[mode]); + } + } + } + return [...seen.values()]; +}; + +export const gradientCss = (system: ColorSystem, gradient: Gradient) => + gradient.circles + .map(({ ref, radius, at }) => { + const color = system.palette[paletteKey(ref)]; + const stops = FALLOFF.map( + ([pos, alpha]) => `${oklchCss(color, alpha)} ${pos}%` + ).join(', '); + return `radial-gradient(circle calc(${radius} * 100cqw) at ${at}, ${stops})`; + }) + .join(', '); + +export const toDtcgPalette = (system: ColorSystem): DtcgGroup => { + const group: DtcgGroup = { + $description: `Generated from the "${system.brand.name}" brand (tier ${system.brand.tier}). Do not edit.`, + }; + const modes = contentModes(system); + for (const [key, color] of Object.entries(system.palette)) { + const path = + key === 'white' || key === 'black' + ? [key] + : ['palette', ...key.replace(/^palette-/, '').split(/-(?=\d+$)/)]; + setPath(group, path, { + $type: 'color', + $value: dtcgColor(color), + /* The mode content on this color uses. See contentModes.ts. */ + $extensions: { [EXTENSION]: { contentMode: modes[key] } }, + }); + } + for (const value of translucentValues()) { + const color = system.palette[paletteKey(value.ref)]; + setPath(group, refPath(value.ref, value.alpha), { + $type: 'color', + $value: dtcgColor(color, value.alpha), + }); + } + return group; +}; + +export const toDtcgMode = (system: ColorSystem, mode: Mode): DtcgGroup => { + const group: DtcgGroup = { + $description: `${mode} mode roles for "${system.brand.name}". Do not edit.`, + }; + for (const role of ROLES) { + const gradient = role.gradient?.[mode]; + group[role.token] = { + $type: 'color', + $value: refString(role[mode]), + $description: role.usage, + ...(gradient && { + $extensions: { + [EXTENSION]: { + gradient: { + circles: gradient.circles.map((c) => ({ + color: `{${refPath(c.ref).join('.')}}`, + radius: c.radius, + at: c.at, + })), + falloff: FALLOFF, + css: gradientCss(system, gradient), + }, + }, + }, + }), + }; + } + return group; +}; + +/* The TS constants the Emotion theme (and anything else in JS) reads. */ +export const toTsModule = (system: ColorSystem): string => { + const entry = (color: Color, alpha?: number, ref?: string) => ({ + ...(ref && { ref }), + oklch: oklchCss(color, alpha), + srgb: srgbCss(color, alpha), + }); + + const palette: Record> = {}; + for (const [key, color] of Object.entries(system.palette)) { + palette[key] = entry(color); + } + for (const value of translucentValues()) { + palette[valueKey(value)] = entry( + system.palette[paletteKey(value.ref)], + value.alpha + ); + } + + const byMode = (source: ColorSystem['roles']) => + Object.fromEntries( + MODES.map((mode) => [ + mode, + Object.fromEntries( + Object.entries(source[mode]).map(([token, v]) => [ + token, + entry(v, v.alpha, v.key), + ]) + ), + ]) + ); + + const gradients = Object.fromEntries( + MODES.map((mode) => [ + mode, + Object.fromEntries( + ROLES.filter((r) => r.gradient).map((r) => [ + r.token, + gradientCss(system, r.gradient![mode]), + ]) + ), + ]) + ); + + const json = (value: unknown) => JSON.stringify(value, null, 2); + return [ + `/* Generated by scripts/build-tokens.ts from the "${system.brand.name}" brand. Do not edit. */`, + '', + `export const brand = ${json(system.brand)} as const;`, + '', + `export const palette = ${json(palette)} as const;`, + '', + `export const roles = ${json(byMode(system.roles))} as const;`, + '', + `export const gradients = ${json(gradients)} as const;`, + '', + '/* Palette color → the mode its content uses. Background reads this instead of measuring. */', + `export const contentModes = ${json(contentModes(system))} as const;`, + '', + ].join('\n'); +}; diff --git a/packages/gamut-styles/src/colorSystem/migration.ts b/packages/gamut-styles/src/colorSystem/migration.ts new file mode 100644 index 0000000000..d0e8d0c6cd --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/migration.ts @@ -0,0 +1,1669 @@ +/* + * The migration layer: how today's Gamut color aliases and component colors + * land on the role layer. Ported from gamut-sandbox PR #34 + * (`src/colorSystem/migration.ts` at 4b73c2a), design's source of truth, shown + * at #/migration. The data below is verbatim apart from `Slot` → `ComponentSlot` + * (`Slot` here means a brand slot) and comment style. Re-syncing is a + * deliberate step, like the rest of the port. + * + * Every target is a role token, never a primitive or a hex. `validateMigration` + * holds the file to that for any color system, not just Skillsoft's: each + * alias mapped exactly once, every target a real role, and each component's + * text and icon slots clearing their tier on the fills they sit on, in both + * modes. + */ + +import type { Color as CuloriColor } from 'culori'; +import { wcagContrast } from 'culori'; + +import type { Mode } from './roles'; +import { FALLOFF, MODES, roleFor, ROLES } from './roles'; +import type { ColorSystem } from './system'; +import { paletteKey } from './system'; +import type { Check } from './validate'; +import { composite } from './validate'; + +// ------------------------------------------------------------- token map + +/* + * Every semantic color alias Gamut's themes define today, in the order + * `coreTheme` declares them. + * + * Listed rather than read from the theme because the validator loads this + * file outside the browser, where `@codecademy/gamut-styles` does not import. + * The page diffs this list against the live theme at render time, so a + * Gamut upgrade that adds an alias shows up there as unmapped. + */ +export const LEGACY_TOKENS = [ + 'text', + 'text-accent', + 'text-disabled', + 'text-secondary', + 'feedback-error', + 'feedback-success', + 'feedback-warning', + 'background', + 'background-contrast', + 'background-current', + 'background-primary', + 'background-selected', + 'background-disabled', + 'background-hover', + 'background-success', + 'background-warning', + 'background-error', + 'shadow-primary', + 'shadow-secondary', + 'primary', + 'primary-hover', + 'primary-inverse', + 'secondary', + 'secondary-hover', + 'danger', + 'danger-hover', + 'interface', + 'interface-hover', + 'border-primary', + 'border-secondary', + 'border-tertiary', + 'border-disabled', +] as const; + +export type LegacyToken = (typeof LEGACY_TOKENS)[number]; + +/* + * What kind of move a mapping is, because the kind decides how much work a + * find-and-replace can do. + * + * - `rename`: one alias, one role. Mechanical. + * - `merge`: several aliases collapse onto one role. Mechanical, but the + * visual difference between the aliases goes away. + * - `split`: one alias becomes one of several roles depending on where it is + * used. Needs a person, and `rule` says how to choose. + * - `retire`: no role replaces it; `rule` says what to do instead. + */ +export type MapKind = 'rename' | 'merge' | 'split' | 'retire'; + +export const MAP_KIND_LABEL: Record = { + rename: 'Rename', + merge: 'Merge', + split: 'Split', + retire: 'Retire', +}; + +export interface TokenMapping { + legacy: LegacyToken; + kind: MapKind; + /* Role tokens, most common target first. Empty only for `retire`. */ + to: string[]; + /* How to apply it. For a split, how to choose between the targets. */ + rule: string; + /* A decision this mapping depends on that has not been made yet. */ + open?: string; +} + +export const TOKEN_GROUPS: { id: string; label: string; prefix: RegExp }[] = [ + { id: 'text', label: 'Text', prefix: /^text/ }, + { id: 'feedback', label: 'Feedback', prefix: /^feedback/ }, + { id: 'background', label: 'Background', prefix: /^background/ }, + { + id: 'action', + label: 'Action', + prefix: /^(primary|secondary|danger|interface)/, + }, + { id: 'border', label: 'Border', prefix: /^border/ }, + { id: 'shadow', label: 'Shadow', prefix: /^shadow/ }, +]; + +export const TOKEN_MAP: TokenMapping[] = [ + // ------------------------------------------------------------------ text + { + legacy: 'text', + kind: 'rename', + to: ['text-default'], + rule: 'Body copy and headings.', + }, + { + legacy: 'text-accent', + kind: 'merge', + to: ['text-default'], + rule: 'Headings take `text-default` too. Emphasis comes from weight and size, not from a second ink.', + }, + { + legacy: 'text-secondary', + kind: 'rename', + to: ['text-subtle'], + rule: 'Descriptions, metadata, helper copy. An opaque color in every theme, so it no longer depends on what sits underneath it.', + }, + { + legacy: 'text-disabled', + kind: 'split', + to: ['action-disabled', 'surface-raised', 'text-subtle'], + rule: 'A disabled label or icon drawn straight on a surface (text button, link, menu item, form label) is `action-disabled`, the same color as a disabled fill. On a disabled fill the label is a knockout: it takes the surface the control sits on, usually `surface-raised`. The value inside a disabled input is the one place `text-subtle` stays.', + }, + + // -------------------------------------------------------------- feedback + { + legacy: 'feedback-error', + kind: 'rename', + to: ['text-error'], + rule: 'Error text and icons, including inline validation and the border of a field in error. Destructive buttons are `action-danger`, not this.', + }, + { + legacy: 'feedback-success', + kind: 'rename', + to: ['text-success'], + rule: 'Success text and icons. Always with an icon.', + }, + { + legacy: 'feedback-warning', + kind: 'rename', + to: ['text-warning'], + rule: 'Warning text and icons. Dark enough to be read as text, so it can carry a message on its own rather than only an icon.', + }, + + // ------------------------------------------------------------ background + { + legacy: 'background', + kind: 'split', + to: ['surface-raised', 'surface-default'], + rule: 'Anything that sits on the page (card, input, menu, popover, dialog) takes `surface-raised`. Where `background` fills the page itself, it is `surface-default`.', + }, + { + legacy: 'background-primary', + kind: 'rename', + to: ['surface-default'], + rule: 'The page background, the base every other surface is placed on.', + }, + { + legacy: 'background-current', + kind: 'split', + to: ['surface-default', 'surface-raised', 'surface-sunken'], + rule: 'Whatever surface the element sits on. Name that surface directly.', + open: 'Keep a `surface-current` alias that `Background` sets, or have components name their surface? Masks and sticky headers are the main consumers.', + }, + { + legacy: 'background-contrast', + kind: 'retire', + to: ['surface-raised'], + rule: 'Use `surface-raised`. Maximum contrast against the page is what raised already provides in dark mode, and light mode has no surface lighter than it.', + open: 'Confirm nothing depends on this resolving to black in dark mode.', + }, + { + legacy: 'background-selected', + kind: 'rename', + to: ['state-selected'], + rule: 'A veil composited over the element’s own fill, not an opaque fill that replaces it. The element keeps its surface underneath.', + }, + { + legacy: 'background-hover', + kind: 'split', + to: ['state-hover', 'surface-sunken'], + rule: 'As a hover, it is the `state-hover` veil. Used as a resting fill (the subtle Alert, a tinted panel), it is `surface-sunken`.', + }, + { + legacy: 'background-disabled', + kind: 'rename', + to: ['action-disabled'], + rule: 'The disabled fill for every action variant. One token, whatever was disabled.', + }, + { + legacy: 'background-success', + kind: 'rename', + to: ['surface-success'], + rule: 'The success tint behind an alert or inline confirmation.', + }, + { + legacy: 'background-warning', + kind: 'rename', + to: ['surface-warning'], + rule: 'The warning tint behind an alert or inline caution.', + }, + { + legacy: 'background-error', + kind: 'rename', + to: ['surface-error'], + rule: 'The error tint behind an alert or inline validation message.', + }, + + // ---------------------------------------------------------------- shadow + { + legacy: 'shadow-primary', + kind: 'retire', + to: [], + rule: 'Elevation is the surface step plus a `border-subtle` hairline. There is no shadow color role.', + open: 'Percipio’s elevation scale uses this for card and menu shadows. Keep shadows (and give them a role), or drop them?', + }, + { + legacy: 'shadow-secondary', + kind: 'retire', + to: [], + rule: 'Same as `shadow-primary`.', + open: 'Decided together with `shadow-primary`.', + }, + + // ---------------------------------------------------------------- action + { + legacy: 'primary', + kind: 'rename', + to: ['action-primary'], + rule: 'Primary fills, strokes, links and focus rings. Pressed is new: `action-primary-pressed`.', + }, + { + legacy: 'primary-hover', + kind: 'rename', + to: ['action-primary-hover'], + rule: 'Hover for the primary action.', + }, + { + legacy: 'primary-inverse', + kind: 'retire', + to: ['action-primary'], + rule: 'A primary action on an inverted ground is `action-primary` inside a dark-mode scope. The mode does the inverting.', + }, + { + legacy: 'secondary', + kind: 'rename', + to: ['action-neutral'], + rule: 'The secondary button and secondary icon/text buttons. Labels on it are `text-inverse`.', + }, + { + legacy: 'secondary-hover', + kind: 'rename', + to: ['action-neutral-hover'], + rule: 'Hover for the secondary action.', + }, + { + legacy: 'danger', + kind: 'rename', + to: ['action-danger'], + rule: 'Destructive actions: delete, remove, revoke. Error messaging is `text-error`.', + }, + { + legacy: 'danger-hover', + kind: 'rename', + to: ['action-danger-hover'], + rule: 'Hover for the destructive action.', + }, + { + legacy: 'interface', + kind: 'merge', + to: ['action-primary'], + rule: 'Checkboxes, radios, toggles, focus rings and inline links share the one interactive blue.', + }, + { + legacy: 'interface-hover', + kind: 'merge', + to: ['action-primary-hover'], + rule: 'Hover for the same controls.', + }, + + // ---------------------------------------------------------------- border + { + legacy: 'border-primary', + kind: 'split', + to: ['border-default', 'border-subtle'], + rule: 'A border that carries meaning (field outline, control stroke, a container that needs an edge to be seen) is `border-default`. A border that only decorates (card hairline, table rule, the edge of a dialog over `backdrop`) is `border-subtle`.', + }, + { + legacy: 'border-secondary', + kind: 'merge', + to: ['border-default'], + rule: 'Same as `border-primary`. One meaningful border weight.', + }, + { + legacy: 'border-tertiary', + kind: 'split', + to: ['border-subtle', 'border-strong'], + rule: 'A divider or card hairline is `border-subtle`. Where it marks an active field or a selected row, it is `border-strong`.', + open: 'Core resolves this to a light navy and Percipio to a dark one, so usages need checking one by one to see which they meant.', + }, + { + legacy: 'border-disabled', + kind: 'merge', + to: ['border-default'], + rule: 'A disabled control is marked by its `action-disabled` fill. Its outline stays `border-default`.', + }, +]; + +/* Roles no legacy alias maps onto — the capabilities the old set lacked. */ +export const NEW_ROLES = ROLES.filter( + (role) => + !TOKEN_MAP.some((m) => m.to.includes(role.token)) && + !role.token.startsWith('viz') +); + +export const mappingFor = (legacy: string): TokenMapping | undefined => + TOKEN_MAP.find((m) => m.legacy === legacy); + +// ---------------------------------------------------------------- surfaces + +/* + * The elevation rule, in the order a reader applies it: start from the page + * and ask what the element is placed on. + */ +export const SURFACE_RULES: { role: string; when: string; examples: string }[] = + [ + { + role: 'surface-default', + when: 'The page itself.', + examples: 'App background, the area behind the main content.', + }, + { + role: 'surface-raised', + when: 'Anything placed on the page.', + examples: + 'Cards, panels, inputs, menus, popovers, dialogs, the header bar.', + }, + { + role: 'surface-sunken', + when: 'Anything recessed into the surface it sits on.', + examples: + 'Code blocks, wells, insets, a progress track, the subtle Alert.', + }, + ]; + +/* + * What to do when a container sits inside a container. Stated separately + * because it is the case the three-level stack does not answer on its own. + */ +export const NESTING_RULE = + 'There is no level above `surface-raised`. A panel inside a card stays `surface-raised` and takes a `border-subtle` hairline; a recessed area inside a card is `surface-sunken`. Overlays (menus, popovers, dialogs) are `surface-raised` wherever they open, and a dialog adds `backdrop` behind it.'; + +// -------------------------------------------------------------- components + +/* + * One colored part of a component. + * + * `kind` is what lets the validator check the mapping rather than just store + * it: text and icon slots are measured against the variant's fill. + */ +export interface ComponentSlot { + slot: string; + kind: 'fill' | 'text' | 'icon' | 'border' | 'veil'; + /* Today's value as it appears in Gamut's source: an alias, a palette key, or a note. */ + legacy: string; + /* The role it maps to, or `inherit` for a slot that takes its parent's color. */ + role: string; + note?: string; + /* + * The label is cut out of a disabled fill and shows the surface behind it. + * `role` names the usual surface; on another surface it takes that one. + */ + knockout?: boolean; +} + +/* A slot target that is deliberately not a role. */ +export const INHERIT = 'inherit'; + +export interface ComponentVariant { + name: string; + /* Today's variant name, when it differs from `name`. */ + legacyName?: string; + slots: ComponentSlot[]; + /* + * The surface a variant without a fill of its own is measured on: a text + * button, a link, a field label. `surface-raised` is the binding case for + * most marks, since it is where controls usually sit. + */ + on?: string; +} + +export type MappingStatus = 'mapped' | 'open'; + +export interface ComponentMapping { + id: string; + name: string; + summary: string; + variants: ComponentVariant[]; + /* Slots shared by every variant (borders, buttons inside the component). */ + shared?: ComponentSlot[]; + open?: string[]; +} + +/* A slot, positionally — the component tables below are long enough that + * the object form buried the four values that matter. */ +const s = ( + slot: string, + kind: ComponentSlot['kind'], + legacy: string, + role: string, + extra?: { note?: string; knockout?: boolean } +): ComponentSlot => ({ slot, kind, legacy, role, ...extra }); + +/* A knocked-out disabled label: the surface shows through the fill. */ +const knockout = (legacy: string): ComponentSlot => + s('Disabled label', 'text', legacy, 'surface-raised', { + knockout: true, + note: 'Knockout: the surface the control sits on shows through.', + }); + +const cap = (word: string): string => word[0].toUpperCase() + word.slice(1); + +/* + * The Alert pattern, shared by its status variants: the tint carries the + * surface, the status ink carries the icon, and the message stays + * `text-default`. The status inks clear text contrast on their own tint, but + * body copy is easier to read in the default ink, and the icon is what + * signals the status. + */ +const statusAlert = ( + name: string, + status: 'success' | 'warning' | 'error', + legacyBg: string, + legacyName?: string +): ComponentVariant => ({ + name, + legacyName, + slots: [ + { slot: 'Fill', kind: 'fill', legacy: legacyBg, role: `surface-${status}` }, + { + slot: 'Icon', + kind: 'icon', + legacy: 'auto (Background)', + role: `text-${status}`, + }, + { + slot: 'Message', + kind: 'text', + legacy: 'auto (Background)', + role: 'text-default', + }, + ], +}); + +export const COMPONENTS: ComponentMapping[] = [ + { + id: 'alert', + name: 'Alert', + summary: + 'Status variants move from saturated fills with auto-contrast text to the status tints: `surface-*` behind, `text-*` on the icon, `text-default` for the message. The buttons inside follow the secondary button.', + variants: [ + statusAlert('Error', 'error', 'red-600'), + statusAlert('Success', 'success', 'green'), + statusAlert('Warning', 'warning', 'orange', 'notice'), + { + name: 'Info', + legacyName: 'general', + slots: [ + { + slot: 'Fill', + kind: 'fill', + legacy: 'blue', + role: 'surface-sunken', + }, + { + slot: 'Icon', + kind: 'icon', + legacy: 'auto (Background)', + role: 'action-primary', + }, + { + slot: 'Message', + kind: 'text', + legacy: 'auto (Background)', + role: 'text-default', + }, + ], + }, + { + name: 'Feature', + slots: [ + { + slot: 'Fill', + kind: 'fill', + legacy: 'blue-300', + role: 'surface-brand', + }, + { + slot: 'Icon', + kind: 'icon', + legacy: 'auto (Background)', + role: 'action-primary', + }, + { + slot: 'Message', + kind: 'text', + legacy: 'auto (Background)', + role: 'text-default', + }, + ], + }, + { + name: 'Subtle', + slots: [ + { + slot: 'Fill', + kind: 'fill', + legacy: 'background-hover', + role: 'surface-sunken', + }, + { + slot: 'Icon', + kind: 'icon', + legacy: 'auto (Background)', + role: 'text-subtle', + }, + { + slot: 'Message', + kind: 'text', + legacy: 'auto (Background)', + role: 'text-default', + }, + ], + }, + ], + shared: [ + { + slot: 'Floating border', + kind: 'border', + legacy: 'navy', + role: 'border-default', + note: 'Inline placement keeps no border.', + }, + { + slot: 'CTA button', + kind: 'fill', + legacy: 'FillButton secondary, auto mode', + role: 'action-neutral', + note: 'Label is `text-inverse`.', + }, + { + slot: 'Close / expand', + kind: 'icon', + legacy: 'IconButton / TextButton secondary', + role: 'action-neutral', + note: 'Hover and press are `state-hover` / `state-pressed`.', + }, + ], + open: [ + 'Info has no status tint of its own. Proposed: `surface-sunken` with an `action-primary` icon, which makes it look like Subtle except for the icon. Alternatively, add a `surface-info` role.', + 'Feature on `surface-brand` puts the two-color gradient behind running text. It clears text contrast at the gradient’s worst point, but check it at banner width.', + ], + }, + + // ---------------------------------------------------------------- buttons + { + id: 'fill-button', + name: 'FillButton', + summary: + 'Each variant maps onto its action family, which now has a pressed step of its own. Labels are `text-inverse` on every fill, so a label never depends on the color mode a parent forces. Danger keeps its focus ring in its own color; disabled labels are a knockout.', + variants: (['primary', 'neutral', 'danger'] as const).map((family) => { + const legacy = family === 'neutral' ? 'secondary' : family; + return { + name: family === 'neutral' ? 'Secondary' : cap(family), + slots: [ + s('Fill', 'fill', legacy, `action-${family}`), + s('Hover fill', 'fill', `${legacy}-hover`, `action-${family}-hover`), + s( + 'Pressed fill', + 'fill', + `${legacy} + border-primary`, + `action-${family}-pressed` + ), + s('Label', 'text', 'background', 'text-inverse'), + s( + 'Focus ring', + 'border', + legacy, + family === 'danger' ? 'action-danger' : 'action-primary' + ), + ], + }; + }), + shared: [ + s('Disabled fill', 'fill', 'background-disabled', 'action-disabled'), + knockout('text-disabled'), + s('Interface variant', 'fill', 'interface', 'action-primary', { + note: 'Merges into primary.', + }), + ], + open: [ + 'Secondary’s focus ring is `action-primary`, while danger’s matches its fill. Today the ring follows the variant for both. Keep blue for secondary, or use `action-neutral`?', + ], + }, + { + id: 'stroke-button', + name: 'StrokeButton', + summary: + 'Stroke and label take the action color; hover and press become veils over the surface instead of a gray fill and a full inversion.', + variants: (['primary', 'neutral', 'danger'] as const).map((family) => { + const legacy = family === 'neutral' ? 'secondary' : family; + return { + name: family === 'neutral' ? 'Secondary' : cap(family), + on: 'surface-raised', + slots: [ + s('Stroke', 'border', legacy, `action-${family}`), + s('Label', 'text', legacy, `action-${family}`), + s('Hover', 'veil', 'background-hover', 'state-hover'), + s( + 'Pressed', + 'veil', + `${legacy} fill, background label`, + 'state-pressed' + ), + s( + 'Focus ring', + 'border', + legacy, + family === 'danger' ? 'action-danger' : 'action-primary' + ), + ], + }; + }), + shared: [ + s('Disabled stroke', 'border', 'background-disabled', 'action-disabled'), + s('Disabled label', 'text', 'text-disabled', 'action-disabled'), + ], + open: [ + 'Pressed is an inversion today (the stroke color fills the button). Proposed: the `state-pressed` veil, the same as every other unfilled control.', + ], + }, + { + id: 'text-button', + name: 'TextButton / IconButton', + summary: + 'Label or icon in the action color, `state-hover` / `state-pressed` behind it. IconButton keeps secondary as its default, which is now `action-neutral`.', + variants: [ + { + name: 'Primary', + on: 'surface-raised', + slots: [ + s('Label / icon', 'text', 'primary', 'action-primary'), + s('Focus ring', 'border', 'primary', 'action-primary'), + ], + }, + { + name: 'Secondary', + on: 'surface-raised', + slots: [ + s('Label / icon', 'text', 'secondary', 'action-neutral'), + s('Focus ring', 'border', 'secondary', 'action-primary'), + ], + }, + { + name: 'Danger', + on: 'surface-raised', + slots: [ + s('Label / icon', 'text', 'danger', 'action-danger'), + s('Focus ring', 'border', 'danger', 'action-danger'), + ], + }, + { + name: 'Interface', + on: 'surface-raised', + slots: [ + s('Label / icon', 'text', 'text', 'text-default'), + s('Focus ring', 'border', 'primary', 'action-primary'), + ], + }, + ], + shared: [ + s('Hover', 'veil', 'background-hover', 'state-hover'), + s('Pressed', 'veil', 'text (label color)', 'state-pressed'), + s('Disabled label', 'text', 'text-disabled', 'action-disabled'), + ], + }, + { + id: 'cta-button', + name: 'CTAButton', + summary: 'Fills follow primary FillButton. The offset shadow has no role.', + variants: [ + { + name: 'Primary', + slots: [ + s('Fill', 'fill', 'primary', 'action-primary'), + s('Hover fill', 'fill', 'primary-hover', 'action-primary-hover'), + s('Pressed fill', 'fill', 'secondary', 'action-primary-pressed'), + s('Label', 'text', 'background', 'text-inverse'), + ], + }, + ], + shared: [ + s('Focus ring', 'border', 'primary', 'action-primary'), + s('Disabled fill', 'fill', 'background-disabled', 'action-disabled'), + knockout('text-disabled'), + s('Offset shadow', 'border', 'text (as box-shadow)', 'action-neutral', { + note: 'Only if the shadow is kept.', + }), + ], + open: [ + 'The offset shadow is a Codecademy brand treatment. Keep it on `action-neutral`, or drop it for Skillsoft themes?', + ], + }, + { + id: 'anchor', + name: 'Anchor', + summary: + 'Links are `action-primary` and keep their underline: color alone never separates a link from body text.', + variants: [ + { + name: 'Inline / standard', + on: 'surface-default', + slots: [ + s('Text', 'text', 'primary', 'action-primary'), + s('Hover text', 'text', 'primary', 'action-primary-hover'), + ], + }, + { + name: 'Interface', + on: 'surface-raised', + slots: [ + s('Text', 'text', 'text', 'text-default'), + s('Hover text', 'text', 'primary', 'action-primary'), + ], + }, + { + name: 'Standard secondary', + on: 'surface-default', + slots: [s('Text', 'text', 'text', 'text-default')], + }, + ], + shared: [ + s('Focus ring', 'border', 'primary / currentColor', 'action-primary'), + s('Disabled', 'text', 'text-disabled', 'action-disabled'), + ], + }, + + // --------------------------------------------------------- badges & tags + { + id: 'badge', + name: 'Badge', + summary: + 'Today’s filled badges use text colors as fills. Proposed: the dark badge takes the neutral action pair, the rest move to surfaces.', + variants: [ + { + name: 'Primary', + slots: [ + s('Fill', 'fill', 'text', 'action-neutral'), + s('Label', 'text', 'background', 'text-inverse'), + ], + }, + { + name: 'Secondary', + slots: [ + s('Fill', 'fill', 'text-secondary', 'surface-sunken'), + s('Label', 'text', 'background', 'text-default'), + ], + }, + { + name: 'Tertiary', + on: 'surface-raised', + slots: [ + s('Label', 'text', 'text-secondary', 'text-subtle'), + s('Border', 'border', 'border-secondary', 'border-default'), + ], + }, + { + name: 'Tertiary fill', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Label', 'text', 'text-secondary', 'text-subtle'), + s('Border', 'border', 'border-secondary', 'border-default'), + ], + }, + { + name: 'Accent', + slots: [ + s('Fill', 'fill', 'yellow', 'action-primary'), + s('Label', 'text', 'navy', 'text-inverse'), + ], + }, + ], + open: [ + 'Badges are not interactive, so `action-neutral` as a fill borrows an action role. The alternative is a `surface-inverse` role, which would also serve Tooltip if it ever goes dark.', + 'Accent has no role. Proposed: `action-primary` (a blue badge). The alternative, `surface-warning`, would read as a caution.', + 'Secondary moves from a dark gray fill to a light one, so it looks very different.', + ], + }, + { + id: 'tag', + name: 'Tag', + summary: + 'Tags sit on surfaces and take veils for their states. A picked selection tag is `state-selected`, not a dark fill.', + variants: [ + { + name: 'Read only', + slots: [ + s('Fill', 'fill', 'background-disabled', 'surface-sunken'), + s('Label', 'text', 'text', 'text-default'), + ], + }, + { + name: 'Selection', + slots: [ + s('Fill', 'fill', 'text-secondary', 'surface-raised'), + s('Selected', 'veil', 'text-secondary', 'state-selected'), + s('Label', 'text', 'background', 'text-default'), + s('Border', 'border', 'none', 'border-default'), + s('Dismiss icon', 'icon', 'background', 'action-neutral'), + s('Dismiss hover', 'veil', 'secondary-hover', 'state-hover'), + ], + }, + { + name: 'Navigation', + slots: [ + s('Fill', 'fill', 'background-current', 'surface-raised'), + s('Label', 'text', 'text', 'text-default'), + s('Border', 'border', 'border-secondary', 'border-default'), + s('Hover', 'veil', 'background-hover', 'state-hover'), + s( + 'Pressed', + 'veil', + 'secondary fill, background label', + 'state-pressed' + ), + ], + }, + { + name: 'Suggestion', + slots: [ + s('Fill', 'fill', 'background-current', 'surface-raised'), + s('Label', 'text', 'text', 'text-default'), + s('Border', 'border', 'primary', 'action-primary'), + s('Hover', 'veil', 'background-hover', 'state-hover'), + s( + 'Pressed', + 'veil', + 'primary fill, background label', + 'state-pressed' + ), + ], + }, + ], + shared: [ + s('Focus ring', 'border', 'primary', 'action-primary'), + s('Disabled fill', 'fill', 'background-disabled', 'action-disabled'), + knockout('text-disabled'), + s('Disabled border', 'border', 'border-disabled', 'border-default'), + ], + open: [ + 'Selection tags change from a dark chip to a light one with a selected tint. Confirm that still reads as “picked” next to unpicked suggestion tags.', + ], + }, + + // ------------------------------------------------------------------ forms + { + id: 'field', + name: 'Input / TextArea / Select', + summary: + 'Fields are `surface-raised` with a `border-default` outline. Hover strengthens the outline, focus turns it `action-primary`, and error is `text-error`.', + variants: [ + { + name: 'Rest', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Value', 'text', 'text', 'text-default'), + s('Placeholder', 'text', 'browser default', 'text-subtle'), + s('Border', 'border', 'border-primary', 'border-default'), + s('Chevron', 'icon', 'text', 'text-subtle'), + ], + }, + { + name: 'Hover', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Border', 'border', 'primary', 'border-strong'), + ], + }, + { + name: 'Focus', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Border + ring', 'border', 'primary', 'action-primary'), + ], + }, + { + name: 'Error', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Border + ring', 'border', 'feedback-error', 'text-error'), + s('Status icon', 'icon', 'feedback-error', 'text-error'), + ], + }, + { + name: 'Valid', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Status icon', 'icon', 'feedback-success', 'text-success'), + ], + }, + { + name: 'Disabled', + slots: [ + s('Fill', 'fill', 'background-disabled', 'action-disabled'), + s('Value', 'text', 'text-disabled', 'text-subtle'), + s( + 'Border', + 'border', + 'currentColor (text-disabled)', + 'border-default' + ), + ], + }, + ], + open: [ + 'Hover today turns the border blue, which reads as focus. Proposed: `border-strong` on hover so blue only ever means focus.', + ], + }, + { + id: 'select-dropdown', + name: 'SelectDropdown', + summary: + 'The control follows the field. The menu is a raised surface, and options take state veils instead of opaque fills.', + variants: [ + { + name: 'Menu', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Border', 'border', 'currentColor (text)', 'border-default'), + s('Option', 'text', 'text', 'text-default'), + s('Option subtitle', 'text', 'text-disabled', 'text-subtle'), + s('Group heading', 'text', 'text-disabled', 'text-subtle'), + s('Group divider', 'border', 'text-disabled', 'border-subtle'), + s('No options', 'text', 'text-secondary', 'text-subtle'), + s('Create option', 'text', 'primary', 'action-primary'), + ], + }, + { + name: 'Option states', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Focused', 'veil', 'background-hover', 'state-hover'), + s('Selected', 'veil', 'background-selected', 'state-selected'), + s('Disabled', 'text', 'text-disabled', 'action-disabled'), + ], + }, + ], + shared: [ + s('Error top border', 'border', 'feedback-error', 'text-error'), + s('Multi-value chip', 'fill', 'text-secondary', 'surface-sunken', { + note: 'Follows the selection Tag.', + }), + ], + }, + { + id: 'checkbox', + name: 'Checkbox / Radio', + summary: + 'Unchecked boxes and rings are `border-default`; checked takes `action-primary`. A checked radio renders in the text color today because of a style override, and becomes blue.', + variants: [ + { + name: 'Checkbox checked', + slots: [ + s('Box fill', 'fill', 'primary', 'action-primary'), + s('Check mark', 'icon', 'background', 'text-inverse'), + ], + }, + { + name: 'Unchecked', + on: 'surface-raised', + slots: [ + s( + 'Box / ring', + 'border', + 'border-primary / currentColor', + 'border-default' + ), + s('Label', 'text', 'text', 'text-default'), + ], + }, + { + name: 'Radio checked', + on: 'surface-raised', + slots: [ + s('Dot', 'icon', 'currentColor (text)', 'action-primary'), + s('Ring', 'border', 'currentColor (text)', 'action-primary'), + ], + }, + { + name: 'Error', + on: 'surface-raised', + slots: [s('Ring / dot', 'icon', 'feedback-error', 'text-error')], + }, + ], + shared: [ + s('Hover / focus ring', 'border', 'primary', 'action-primary'), + s('Disabled fill', 'fill', 'text-disabled', 'action-disabled'), + s('Disabled check', 'icon', 'background', 'surface-raised', { + knockout: true, + note: 'Knockout, like a disabled button label.', + }), + s('Disabled label', 'text', 'text-disabled', 'action-disabled'), + ], + }, + { + id: 'toggle', + name: 'Toggle', + summary: + 'The checked track is `action-primary`. The unchecked track has to be visible at 3:1 against its surface, which no surface or disabled role is.', + variants: [ + { + name: 'On', + slots: [ + s('Track', 'fill', 'primary', 'action-primary'), + s('Knob', 'icon', 'white', 'surface-raised'), + ], + }, + { + name: 'Off', + slots: [ + s('Track', 'fill', 'text-disabled', 'border-default'), + s('Knob', 'icon', 'white', 'surface-raised'), + ], + }, + ], + shared: [ + s('Focus ring', 'border', 'primary', 'action-primary'), + s('Disabled', 'fill', 'opacity 0.5', 'action-disabled'), + ], + open: [ + 'The off track borrows `border-default` as a fill, since it is the role guaranteed at 3:1 on every surface. Accept the borrow, or add a `control-track` role?', + ], + }, + { + id: 'form-group', + name: 'FormGroup', + summary: + 'Description text drops to `text-subtle`; the error message is `text-error` and keeps its icon.', + variants: [ + { + name: 'Labels and messages', + on: 'surface-raised', + slots: [ + s('Label', 'text', 'text', 'text-default'), + s('Description', 'text', 'text', 'text-subtle'), + s('Error', 'text', 'feedback-error', 'text-error'), + s('Disabled label', 'text', 'text-disabled', 'action-disabled'), + ], + }, + ], + }, + + // ------------------------------------------------------------- containers + { + id: 'card', + name: 'Card', + summary: + 'The default card is `surface-raised` with a `border-subtle` hairline. The palette variants and their auto-contrast go away; a dark card is a dark-mode scope.', + variants: [ + { + name: 'Default', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Text', 'text', 'text', 'text-default'), + s('Border', 'border', 'border-primary', 'border-subtle'), + ], + }, + { + name: 'White', + slots: [ + s('Fill', 'fill', 'white', 'surface-raised'), + s('Text', 'text', 'auto (Background)', 'text-default'), + ], + }, + { + name: 'Beige', + slots: [ + s('Fill', 'fill', 'beige', 'surface-default'), + s('Text', 'text', 'auto (Background)', 'text-default'), + ], + }, + { + name: 'Navy', + slots: [ + s('Fill', 'fill', 'navy', 'surface-raised', { + note: 'Inside a dark-mode scope.', + }), + s('Text', 'text', 'auto (Background)', 'text-default'), + ], + }, + { + name: 'Interactive', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Hover', 'veil', 'shadow-primary (offset shadow)', 'state-hover'), + s('Hover border', 'border', 'border-primary', 'border-strong'), + ], + }, + ], + open: [ + 'Yellow and hyper cards have no role. Proposed: retire them, or use `surface-brand` / `surface-brand-subtle` where they marked a featured card.', + 'Interactive cards lift with an offset shadow today. Proposed: a `state-hover` veil plus `border-strong`. Depends on the shadow decision in the token map.', + ], + }, + { + id: 'disclosure', + name: 'Disclosure', + summary: + 'Variants map onto the surface stack; the header gets state veils, and focus is a ring rather than a fill.', + variants: [ + { + name: 'Default', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Header text', 'text', 'text', 'text-default'), + s('Overline', 'text', 'text-secondary', 'text-subtle'), + ], + }, + { + name: 'Subtle', + slots: [ + s('Fill', 'fill', 'background-selected', 'surface-sunken'), + s('Header text', 'text', 'text', 'text-default'), + ], + }, + { + name: 'Transparent', + on: 'surface-default', + slots: [ + s('Fill', 'fill', 'background-current', INHERIT), + s('Header text', 'text', 'text', 'text-default'), + ], + }, + ], + shared: [ + s('Border', 'border', 'border-primary', 'border-default'), + s('Header hover', 'veil', 'background-hover', 'state-hover'), + s( + 'Header focus', + 'border', + 'background-selected + primary ring', + 'action-primary' + ), + s('Panel fill', 'fill', 'background-selected', 'surface-sunken'), + s('Disabled header', 'fill', 'background-disabled', 'action-disabled'), + knockout('text-disabled'), + ], + }, + { + id: 'list', + name: 'List / DataTable / DataList', + summary: + 'Rows sit on `surface-raised` with `border-subtle` rules. Hover and selection are veils, and selected rows get a tint they lack today.', + variants: [ + { + name: 'Row', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Text', 'text', 'text', 'text-default'), + s('Row rule', 'border', 'border-primary', 'border-subtle'), + s('Hover', 'veil', 'background-hover', 'state-hover'), + s('Selected', 'veil', 'none (checkbox only)', 'state-selected'), + s( + 'Focus', + 'border', + 'primary + background-selected', + 'action-primary' + ), + ], + }, + { + name: 'Header', + slots: [ + s('Fill', 'fill', 'background-current', 'surface-raised'), + s('Text', 'text', 'text', 'text-default'), + s('Rule', 'border', 'border-primary (2px)', 'border-default'), + s('Sort icon', 'icon', 'text-disabled', 'text-subtle'), + s('Sort icon active', 'icon', 'text-accent', 'text-default'), + s('Filter set', 'icon', 'primary', 'action-primary'), + ], + }, + { + name: 'Table stripe', + slots: [ + s('Fill', 'fill', 'background-selected', 'surface-sunken'), + s('Text', 'text', 'text', 'text-default'), + ], + }, + ], + shared: [ + s( + 'Scroll shadow', + 'border', + 'background-selected → border-tertiary', + 'border-subtle' + ), + s('Expand chevron', 'icon', 'secondary', 'action-neutral'), + ], + open: [ + 'Striping uses the selection color today, so a striped row and a selected row look alike. Proposed: stripe with `surface-sunken` and keep `state-selected` for selection.', + ], + }, + + // --------------------------------------------------------------- overlays + { + id: 'modal', + name: 'Modal / Dialog', + summary: + 'The scrim becomes `backdrop`; the panel is `surface-raised` with a hairline. Buttons follow their own mappings.', + variants: [ + { + name: 'Panel', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Title / body', 'text', 'text', 'text-default'), + s('Border', 'border', 'border-primary', 'border-subtle'), + s('Close icon', 'icon', 'secondary', 'action-neutral'), + ], + }, + ], + shared: [ + s('Scrim', 'veil', 'navy-600', 'backdrop'), + s('Focus ring', 'border', 'primary', 'action-primary'), + s('Pattern', 'icon', 'currentColor (text)', 'border-subtle', { + note: 'If the checker pattern is kept.', + }), + ], + }, + { + id: 'flyout', + name: 'Flyout / Drawer', + summary: 'Same as Modal: `backdrop` behind, `surface-raised` panel.', + variants: [ + { + name: 'Panel', + slots: [ + s( + 'Fill', + 'fill', + 'background (auto) / background-current', + 'surface-raised' + ), + s('Text', 'text', 'auto (Background)', 'text-default'), + s('Close icon', 'icon', 'secondary', 'action-neutral'), + ], + }, + ], + shared: [ + s('Scrim', 'veil', 'navy-600', 'backdrop'), + s('Focus ring', 'border', 'primary', 'action-primary'), + ], + }, + { + id: 'toast', + name: 'Toast', + summary: + 'A raised panel with a meaningful border, since it floats over unknown content.', + variants: [ + { + name: 'Panel', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Text', 'text', 'text', 'text-default'), + s('Border', 'border', 'border-primary', 'border-default'), + s('Close icon', 'icon', 'secondary', 'action-neutral'), + ], + }, + ], + shared: [ + s('Offset pattern', 'icon', 'currentColor (text)', 'border-subtle', { + note: 'If the checker pattern is kept.', + }), + ], + }, + { + id: 'tooltip', + name: 'ToolTip / InfoTip / Popover', + summary: + 'Every floating surface is `surface-raised` with a `border-default` edge, beak included. `background-contrast` goes away.', + variants: [ + { + name: 'Tip body', + legacyName: 'ToolTip, InfoTip, Popover secondary', + slots: [ + s('Fill', 'fill', 'background-contrast', 'surface-raised'), + s('Text', 'text', 'text', 'text-default'), + s('Border + beak', 'border', 'border-primary', 'border-default'), + ], + }, + { + name: 'Popover', + legacyName: 'Popover primary, Coachmark', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Text', 'text', 'text', 'text-default'), + s('Border + beak', 'border', 'border-primary', 'border-default'), + ], + }, + { + name: 'InfoTip button', + on: 'surface-raised', + slots: [ + s('Icon', 'icon', 'secondary', 'action-neutral'), + s('Hover', 'veil', 'background-hover', 'state-hover'), + s('Open', 'veil', 'background-selected', 'state-selected'), + s('Disabled', 'icon', 'text-disabled', 'action-disabled'), + ], + }, + ], + shared: [s('Target focus ring', 'border', 'primary', 'action-primary')], + }, + { + id: 'menu', + name: 'Menu', + summary: + 'Items hover with a veil instead of turning blue; the active nav item is `state-selected` with an `action-primary` bar.', + variants: [ + { + name: 'List', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Item', 'text', 'text', 'text-default'), + s('Border', 'border', 'border-primary', 'border-default'), + s('Separator', 'border', 'text-disabled', 'border-subtle'), + s('Disabled item', 'text', 'text-disabled', 'action-disabled'), + ], + }, + { + name: 'Item states', + slots: [ + s('Fill', 'fill', 'background', 'surface-raised'), + s('Hover', 'veil', 'primary (text color)', 'state-hover'), + s('Active (fixed)', 'veil', 'background-selected', 'state-selected'), + s('Active bar', 'border', 'secondary', 'action-primary'), + s('Focus ring', 'border', 'primary', 'action-primary'), + ], + }, + ], + open: [ + 'Hover changes from blue text to a tint. Blue text on hover reads like a link, but this is a visible change to every menu.', + ], + }, + { + id: 'tabs', + name: 'Tabs', + summary: + 'Standard tabs: unselected labels `text-subtle`, selected `text-default` with an `action-primary` indicator, hover a veil.', + variants: [ + { + name: 'Standard', + on: 'surface-default', + slots: [ + s('Label', 'text', 'text', 'text-subtle'), + s('Selected label', 'text', 'text (bold)', 'text-default'), + s('Indicator', 'border', 'primary', 'action-primary'), + s('Baseline', 'border', 'text / border-primary', 'border-subtle'), + s('Hover', 'veil', 'background-selected', 'state-hover'), + ], + }, + { + name: 'Block', + slots: [ + s( + 'Container', + 'fill', + 'background-contrast in navy-800 (dark)', + 'surface-sunken' + ), + s('Selected tab', 'fill', 'background', 'surface-raised'), + s('Label', 'text', 'auto (Background)', 'text-default'), + ], + }, + ], + shared: [s('Focus ring', 'border', 'primary', 'action-primary')], + open: [ + 'Block tabs force dark mode today through a navy `Background`. Proposed: a sunken track with a raised selected tab, in the page’s own mode.', + ], + }, + { + id: 'pagination', + name: 'Pagination', + summary: + 'Selected page is `state-selected` plus `border-strong`; hover is a veil.', + variants: [ + { + name: 'Page buttons', + on: 'surface-default', + slots: [ + s('Label', 'text', 'text', 'text-default'), + s('Hover (stroke)', 'text', 'primary', 'action-primary'), + s('Hover (text)', 'veil', 'background-selected', 'state-hover'), + s('Selected', 'veil', 'background-selected', 'state-selected'), + s( + 'Selected border', + 'border', + 'currentColor (text)', + 'border-strong' + ), + s('Focus ring', 'border', 'border-primary', 'action-primary'), + s('Disabled', 'text', 'text-disabled', 'action-disabled'), + ], + }, + ], + }, + { + id: 'breadcrumbs', + name: 'Breadcrumbs', + summary: + 'Links follow the interface Anchor; the separator is `text-subtle`.', + variants: [ + { + name: 'Trail', + on: 'surface-default', + slots: [ + s('Link', 'text', 'text', 'text-default'), + s('Link hover', 'text', 'primary', 'action-primary'), + s('Current', 'text', 'inherit', 'text-default'), + s('Separator', 'text', 'inherit', 'text-subtle'), + ], + }, + ], + }, + + // --------------------------------------------------------------- progress + { + id: 'progress', + name: 'ProgressBar / Loading', + summary: + 'The default bar is `action-primary` on a sunken track. The palette variants and the navy edge go away.', + variants: [ + { + name: 'Bar', + slots: [ + s('Bar', 'fill', 'text', 'action-primary'), + s('Bar label', 'text', 'background', 'text-inverse'), + ], + }, + { + name: 'Track', + slots: [ + s('Track', 'fill', 'none (currentColor border)', 'surface-sunken'), + s('Track border', 'border', 'currentColor', 'border-default'), + ], + }, + { + name: 'Shimmer', + slots: [ + s('Base', 'fill', 'background-disabled @ 0.5', 'surface-sunken'), + s('Sweep', 'veil', 'rgba black/white @ 0.2', 'surface-raised'), + ], + }, + ], + shared: [ + s('Bar edge', 'border', 'navy', INHERIT, { note: 'Dropped.' }), + s('Spinner', 'icon', 'currentColor', INHERIT), + ], + open: [ + 'The blue and yellow ProgressBar variants have no role. Retire them, or map blue onto the default?', + 'The bar label sits on both the bar and the track as the bar grows; `text-inverse` only clears on the bar side.', + ], + }, +]; + +export const statusFor = (c: ComponentMapping): MappingStatus => + c.open && c.open.length > 0 ? 'open' : 'mapped'; + +// ----------------------------------------------------------- measurement + +const PEAK_ALPHA = FALLOFF[0][1]; + +/* + * The color a role puts on screen in a mode, for contrast purposes. Veils and + * the backdrop have no color of their own and aren't measured. A gradient is + * measured at the sandbox's conservative bound: every circle at peak alpha, + * stacked bottom-to-top over the flat base (circles are authored top-first). + */ +export const roleColor = ( + system: ColorSystem, + token: string, + mode: Mode +): CuloriColor | undefined => { + const role = roleFor(token); + if (!role || role.group === 'state' || role.token === 'backdrop') { + return undefined; + } + const flat = system.roles[mode][token]; + const base: CuloriColor = { mode: 'oklch', l: flat.l, c: flat.c, h: flat.h }; + if (!role.gradient) return base; + return [...role.gradient[mode].circles] + .reverse() + .reduce( + (acc, circle) => + composite(system.palette[paletteKey(circle.ref)], PEAK_ALPHA, acc), + base + ); +}; + +export interface SlotCheck { + component: string; + variant: string; + slot: string; + fg: string; + bg: string; + mode: Mode; + ratio: number; + floor: number; +} + +/* + * Every text and icon slot measured against every fill in its variant (rest, + * hover and pressed are all fills a label has to read on), or against the + * variant's `on` surface when it has no fill, in both modes. Text is held to + * 4.5:1 and icons to 3:1. Shared slots aren't measured: they sit on whichever + * fill the variant has, and the role layer already pairs them. + */ +export const componentChecks = ( + system: ColorSystem, + c: ComponentMapping +): SlotCheck[] => { + const out: SlotCheck[] = []; + for (const variant of c.variants) { + const grounds = variant.slots + .filter((s) => s.kind === 'fill' && s.role !== INHERIT) + .map((s) => s.role); + if (grounds.length === 0 && variant.on) grounds.push(variant.on); + for (const slot of variant.slots) { + if (slot.kind !== 'text' && slot.kind !== 'icon') continue; + for (const ground of grounds) { + for (const mode of MODES) { + const fg = roleColor(system, slot.role, mode); + const bg = roleColor(system, ground, mode); + if (!fg || !bg) continue; + out.push({ + component: c.id, + variant: variant.name, + slot: slot.slot, + fg: slot.role, + bg: ground, + mode, + ratio: wcagContrast(fg, bg), + floor: slot.kind === 'text' ? 4.5 : 3, + }); + } + } + } + } + return out; +}; + +/* + * Exempt from a contrast floor: anything on a disabled fill, and a disabled + * label drawn in the disabled color, which WCAG exempts as an inactive control. + */ +export const isExempt = (check: SlotCheck): boolean => + check.bg === 'action-disabled' || check.fg === 'action-disabled'; + +/* The sandbox's `validate:colors` migration checks, for any color system. */ +export const validateMigration = (system: ColorSystem): Check[] => { + const legacy: string[] = [...LEGACY_TOKENS]; + const mapped = TOKEN_MAP.map((m) => m.legacy as string); + const missing = legacy.filter((t) => !mapped.includes(t)); + const twice = mapped.filter((t, i) => mapped.indexOf(t) !== i); + + const badTargets = TOKEN_MAP.flatMap((m) => [ + ...m.to.filter((t) => !roleFor(t)).map((t) => `${m.legacy} → ${t}`), + ...(m.kind !== 'retire' && m.to.length === 0 + ? [`${m.legacy} has no target`] + : []), + ...(m.kind === 'split' && m.to.length < 2 + ? [`${m.legacy} is a split with one target`] + : []), + ]); + + const badSlots = COMPONENTS.flatMap((c) => + [...c.variants, { name: 'shared', slots: c.shared ?? [] }].flatMap((v) => + v.slots + .filter((slot) => slot.role !== INHERIT && !roleFor(slot.role)) + .map((slot) => `${c.id}/${v.name}/${slot.slot} → ${slot.role}`) + ) + ); + + const measured = COMPONENTS.flatMap((c) => componentChecks(system, c)).filter( + (check) => !isExempt(check) + ); + const worst = measured.reduce( + (w, check) => + !w || check.ratio - check.floor < w.ratio - w.floor ? check : w, + undefined + ); + const failing = measured.filter((check) => check.ratio < check.floor); + + /* Structural checks score the offenders as a negative count, so 0 is a pass. */ + const count = (bad: string[]) => -bad.length; + return [ + { + name: `every one of ${legacy.length} Gamut aliases is mapped exactly once`, + pass: missing.length + twice.length === 0, + worst: count([...missing, ...twice]), + floor: 0, + at: + [ + ...missing.map((t) => `unmapped ${t}`), + ...twice.map((t) => `twice ${t}`), + ].join(', ') || '—', + }, + { + name: 'every token-map target is a real role, and every split has a choice', + pass: badTargets.length === 0, + worst: count(badTargets), + floor: 0, + at: badTargets.join(', ') || '—', + }, + { + name: `every slot in ${COMPONENTS.length} components maps to a real role`, + pass: badSlots.length === 0, + worst: count(badSlots), + floor: 0, + at: badSlots.join(', ') || '—', + }, + { + name: `every mapped text and icon slot clears its tier (${measured.length} pairings, margin over floor)`, + pass: failing.length === 0, + worst: worst ? worst.ratio - worst.floor : 0, + floor: 0, + at: failing.length + ? failing + .map( + (f) => + `${f.component} ${f.variant} ${f.slot}: ${f.fg} on ${f.bg} (${ + f.mode + }) ${f.ratio.toFixed(2)} < ${f.floor}` + ) + .join('; ') + : worst + ? `${worst.component} ${worst.variant} ${worst.slot}: ${worst.fg} on ${worst.bg} (${worst.mode})` + : '—', + }, + ]; +}; diff --git a/packages/gamut-styles/src/colorSystem/probe.ts b/packages/gamut-styles/src/colorSystem/probe.ts new file mode 100644 index 0000000000..6891a62a1b --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/probe.ts @@ -0,0 +1,90 @@ +import type { Color as CuloriColor } from 'culori'; +import { + differenceCiede2000, + filterDeficiencyDeuter, + filterDeficiencyProt, + filterDeficiencyTrit, +} from 'culori'; + +import type { BrandInput, Tier } from './brand'; +import { validateMigration } from './migration'; +import type { Grade } from './ramp'; +import { createColorSystem } from './system'; +import { validate } from './validate'; + +/* + * What a tenant's hues do to the system, before we ship them. + * + * Contrast is inherited by construction — the probe re-runs `validate` to + * prove it, not to discover it. What isn't inherited is anything about hues + * relative to each other. The status trio is ported here; the categorical + * separation from gamut-sandbox `probeTenant` is not, because it needs the + * categorical solver, which is deferred. + */ + +/* Below this, the status trio can't be told apart even with an icon. */ +export const STATUS_FLOOR = 2.3; + +const VISION: [string, (c: CuloriColor) => CuloriColor][] = [ + ['normal', (c) => c], + ['protanopia', filterDeficiencyProt(1)], + ['deuteranopia', filterDeficiencyDeuter(1)], + ['tritanopia', filterDeficiencyTrit(1)], +]; + +const deltaE = differenceCiede2000(); + +export interface TenantProbe { + brand: string; + tier: Tier; + derived: string[]; + contrastHolds: boolean; + failedChecks: string[]; + statusSeparation: number; + statusWorst: string; + statusPasses: boolean; +} + +/* Worst pairwise ΔE2000 of success / warning / danger at one grade. */ +export const statusTrioSeparation = ( + input: BrandInput, + grade: Grade = 200 +): { min: number; at: string } => { + const { ramps } = createColorSystem(input); + const trio = (['success', 'warning', 'danger'] as const).map((slot) => ({ + slot, + color: { mode: 'oklch', ...ramps[slot][grade] } as CuloriColor, + })); + let min = Infinity; + let at = ''; + for (let i = 0; i < trio.length; i += 1) { + for (let j = i + 1; j < trio.length; j += 1) { + for (const [vision, sim] of VISION) { + const d = deltaE(sim(trio[i].color), sim(trio[j].color)); + if (d < min) { + min = d; + at = `${trio[i].slot}/${trio[j].slot}, ${vision}`; + } + } + } + } + return { min, at }; +}; + +export const probeTenant = (input: BrandInput): TenantProbe => { + const system = createColorSystem(input); + const failed = [...validate(system), ...validateMigration(system)].filter( + (c) => !c.pass + ); + const status = statusTrioSeparation(input); + return { + brand: input.name, + tier: system.brand.tier, + derived: Object.keys(system.brand.derived), + contrastHolds: failed.length === 0, + failedChecks: failed.map((c) => c.name), + statusSeparation: status.min, + statusWorst: status.at, + statusPasses: status.min >= STATUS_FLOOR, + }; +}; diff --git a/packages/gamut-styles/src/colorSystem/ramp.ts b/packages/gamut-styles/src/colorSystem/ramp.ts new file mode 100644 index 0000000000..3c504f073f --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/ramp.ts @@ -0,0 +1,142 @@ +import { + clampChroma, + converter, + formatHex, + inGamut, + wcagContrast, +} from 'culori'; + +/* + * The ramp generator, ported from gamut-sandbox PR #34 (`generate.ts` at dffd84b). + * + * One input per family: its hue. Lightness is fixed per grade and shared by + * every family, so two colors at the same grade read as equally light. Chroma + * is a percentage of the most sRGB can show at that lightness and hue, so the + * same curve works for any hue a tenant hands us. Hex falls out last and is + * never read back as an input. + */ + +const toRgb = converter('rgb'); +const rgbInGamut = inGamut('rgb'); + +export const GRADES = [ + 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, +] as const; + +export type Grade = (typeof GRADES)[number]; + +/* Which grade mirrors which: light-mode grade → its dark-mode partner. */ +export const MIRROR: Record = { + 100: 1100, + 200: 1000, + 300: 900, + 400: 800, + 500: 700, + 600: 600, + 700: 500, + 800: 400, + 900: 300, + 1000: 200, + 1100: 100, +}; + +/* Authored, not derived. Shared by every family at each grade. */ +export const FIXED_LIGHTNESS: Record = { + 100: 0.9862, + 200: 0.9364, + 300: 0.8497, + 400: 0.7628, + 500: 0.6707, + 600: 0.5835, + 700: 0.4972, + 800: 0.4115, + 900: 0.3346, + 1000: 0.2499, + 1100: 0.1925, +}; + +/* + * Chroma as a share of the in-gamut maximum at each grade. Peaks at 600, the + * UI grade in both modes. + */ +export const CHROMA_CURVE: Record = { + 100: 1.0, + 200: 0.4, + 300: 0.55, + 400: 0.7, + 500: 0.88, + 600: 1.0, + 700: 0.95, + 800: 0.9, + 900: 0.88, + 1000: 0.84, + 1100: 0.8, +}; + +/* Neutral families keep their hue but take a fifth of the curve. */ +export const NEUTRAL_CHROMA_MULTIPLIER = 0.2; + +/* + * Verified by sweep, not solved for: any two grades 500 apart clear 3:1 + * (non-text) and any two 600 apart clear 4.5:1 (text), at every hue. + */ +export const SEPARATION_GUARANTEE = { + nonText: { minGap: 500, floor: 3 }, + text: { minGap: 600, floor: 4.5 }, +} as const; + +export interface Rung { + grade: Grade; + l: number; + c: number; + h: number; + /* Generated. Never an input. */ + hex: string; +} + +export type Ramp = Record; + +/* Floor to 4 decimals, so a rounded chroma can never land outside the gamut. */ +const floor4 = (x: number): number => Math.floor(x * 1e4) / 1e4; + +const oklch = (l: number, c: number, h: number) => + ({ mode: 'oklch', l, c, h } as const); + +export const oklchToHex = (l: number, c: number, h: number): string => + formatHex(clampChroma(toRgb(oklch(l, c, h)), 'rgb')); + +/* + * The largest chroma sRGB can display at this lightness and hue. Bisection, + * because the sRGB boundary in OKLCH has no closed form. + */ +export const maxChroma = (l: number, h: number): number => { + let lo = 0; + let hi = 0.4; + for (let i = 0; i < 40; i += 1) { + const mid = (lo + hi) / 2; + if (rgbInGamut(oklch(l, mid, h))) lo = mid; + else hi = mid; + } + return lo; +}; + +export const rungFor = (h: number, grade: Grade, neutral: boolean): Rung => { + const l = FIXED_LIGHTNESS[grade]; + const pct = CHROMA_CURVE[grade] * (neutral ? NEUTRAL_CHROMA_MULTIPLIER : 1); + const c = floor4(maxChroma(l, h) * pct); + return { grade, l, c, h, hex: oklchToHex(l, c, h) }; +}; + +export const generateRamp = (hue: number, neutral: boolean): Ramp => + Object.fromEntries( + GRADES.map((grade) => [grade, rungFor(hue, grade, neutral)]) + ) as Ramp; + +/* + * WCAG contrast measured on the OKLCH values, not the rounded hex. 8-bit hex + * can't resolve the differences the ladder is built on. + */ +export const contrast = ( + a: Pick, + b: Pick +): number => wcagContrast(oklch(a.l, a.c, a.h), oklch(b.l, b.c, b.h)); diff --git a/packages/gamut-styles/src/colorSystem/roles.ts b/packages/gamut-styles/src/colorSystem/roles.ts new file mode 100644 index 0000000000..92630f0f48 --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/roles.ts @@ -0,0 +1,422 @@ +import type { Slot } from './brand'; +import type { Grade } from './ramp'; + +/* + * The role layer, as data. Ported from gamut-sandbox `roles.ts` at dffd84b + * with every hue family swapped for its slot (blue → primary, magenta → + * accent, violet → accent-alt, red → danger, amber → warning, teal → success). + * + * The sandbox's prose ("why this grade") stays in the sandbox; this file + * keeps only what code needs: the values, the contrast floor a role is held + * to, and what it's held against. + */ + +export type Mode = 'light' | 'dark'; +export const MODES: readonly Mode[] = ['light', 'dark']; + +export type Boundary = 'white' | 'black'; + +export type Ref = { slot: Slot; grade: Grade } | Boundary; + +export interface RoleValue { + ref: Ref; + /* Set for the translucent roles: state veils and the backdrop. */ + alpha?: number; +} + +export interface GradientCircle { + ref: Ref; + /* Radius as a multiple of the element's width. */ + radius: number; + /* CSS background-position for the circle's centre. */ + at: string; +} + +export interface Gradient { + circles: GradientCircle[]; +} + +export type RoleGroup = + | 'surface' + | 'text' + | 'border' + | 'action' + | 'state' + | 'viz' + | 'other'; + +export interface Role { + token: string; + group: RoleGroup; + light: RoleValue; + dark: RoleValue; + usage: string; + /* Minimum WCAG contrast against every role in `against`. */ + floor?: number; + against?: string[]; + gradient?: Record; +} + +const at = (slot: Slot, grade: Grade): RoleValue => ({ + ref: { slot, grade }, +}); +const boundary = (ref: Boundary): RoleValue => ({ ref }); +const veil = (slot: Slot, grade: Grade, alpha: number): RoleValue => ({ + ref: { slot, grade }, + alpha, +}); + +/* + * Veil strengths. The binding constraint is `border-default` against the + * veiled fill it encloses, at 3:1 (see the sandbox's STATE_ALPHA notes). + */ +export const STATE_ALPHA = { + 'state-hover': 0.06, + 'state-pressed': 0.09, + 'state-selected': 0.08, +} as const; + +/* The six-stop falloff every gradient circle uses: [position %, alpha]. */ +export const FALLOFF: [number, number][] = [ + [0, 0.45], + [16, 0.43], + [36, 0.24], + [56, 0.11], + [76, 0.04], + [100, 0], +]; + +const ELEVATION = ['surface-sunken', 'surface-default', 'surface-raised']; +const ALL_SURFACES = [ + ...ELEVATION, + 'surface-success', + 'surface-warning', + 'surface-error', +]; + +const AA_TEXT = 4.5; +const AA_NON_TEXT = 3; + +export const ROLES: Role[] = [ + /* surfaces */ + { + token: 'surface-sunken', + group: 'surface', + light: at('neutral-light', 200), + dark: boundary('black'), + usage: 'Wells, insets, code blocks — anything recessed into the page.', + }, + { + token: 'surface-default', + group: 'surface', + light: at('neutral-light', 100), + dark: at('neutral-dark', 1100), + usage: 'The page background — the base every other surface elevates from.', + }, + { + token: 'surface-raised', + group: 'surface', + light: boundary('white'), + dark: at('neutral-dark', 1000), + usage: 'Cards, panels, dialogs, menus. Higher elevation is lighter.', + }, + { + token: 'surface-success', + group: 'surface', + light: at('success', 200), + dark: at('success', 1000), + usage: 'The success tint behind an alert or inline confirmation.', + }, + { + token: 'surface-warning', + group: 'surface', + light: at('warning', 200), + dark: at('warning', 1000), + usage: 'The warning tint behind an alert or inline caution.', + }, + { + token: 'surface-error', + group: 'surface', + light: at('danger', 200), + dark: at('danger', 1000), + usage: 'The error tint behind an alert or inline validation message.', + }, + { + token: 'surface-brand', + group: 'surface', + light: at('neutral-light', 100), + dark: at('neutral-dark', 1100), + usage: + 'The two-color brand gradient. The color is the flat fallback; the gradient ships as --gradient-surface-brand.', + gradient: { + light: { + circles: [ + { ref: { slot: 'accent', grade: 300 }, radius: 0.85, at: '100% -5%' }, + { ref: { slot: 'primary', grade: 300 }, radius: 1.21, at: '5% 100%' }, + ], + }, + dark: { + circles: [ + { ref: { slot: 'accent', grade: 900 }, radius: 0.85, at: '100% -5%' }, + { ref: { slot: 'primary', grade: 900 }, radius: 1.21, at: '5% 100%' }, + ], + }, + }, + }, + { + token: 'surface-brand-subtle', + group: 'surface', + light: at('neutral-light', 100), + dark: at('neutral-dark', 1100), + usage: + 'The one-color brand gradient, for backgrounds behind real text. Ships as --gradient-surface-brand-subtle.', + gradient: { + light: { + circles: [ + { + ref: { slot: 'primary', grade: 300 }, + radius: 0.78, + at: '50% -10%', + }, + ], + }, + dark: { + circles: [ + { + ref: { slot: 'primary', grade: 900 }, + radius: 0.78, + at: '50% -10%', + }, + ], + }, + }, + }, + + /* text */ + { + token: 'text-default', + group: 'text', + light: at('neutral-dark', 1000), + dark: at('neutral-light', 200), + usage: 'Body copy and headings.', + floor: AA_TEXT, + against: ALL_SURFACES, + }, + { + token: 'text-subtle', + group: 'text', + light: at('neutral-dark', 800), + dark: at('neutral-light', 400), + usage: 'Secondary text — descriptions, metadata, helper copy.', + floor: AA_TEXT, + against: ALL_SURFACES, + }, + { + token: 'text-inverse', + group: 'text', + light: at('neutral-light', 200), + dark: at('neutral-dark', 1000), + usage: 'Labels on a filled control — action-primary, -neutral, -danger.', + floor: AA_TEXT, + against: ['action-primary', 'action-neutral', 'action-danger'], + }, + { + token: 'text-success', + group: 'text', + light: at('success', 900), + dark: at('success', 300), + usage: 'Success text and icons.', + floor: AA_TEXT, + against: [...ELEVATION, 'surface-success'], + }, + { + token: 'text-warning', + group: 'text', + light: at('warning', 900), + dark: at('warning', 300), + usage: 'Warning text and icons. Always with an icon.', + floor: AA_TEXT, + against: [...ELEVATION, 'surface-warning'], + }, + { + token: 'text-error', + group: 'text', + light: at('danger', 900), + dark: at('danger', 300), + usage: 'Error text and icons.', + floor: AA_TEXT, + against: [...ELEVATION, 'surface-error'], + }, + + /* borders */ + { + token: 'border-strong', + group: 'border', + light: at('neutral-dark', 1100), + dark: at('neutral-light', 100), + usage: 'Emphasis — an active field, a selected row.', + floor: AA_NON_TEXT, + against: ALL_SURFACES, + }, + { + token: 'border-default', + group: 'border', + light: at('neutral-dark', 600), + dark: at('neutral-light', 600), + usage: 'Any border that carries meaning — control outlines, field borders.', + floor: AA_NON_TEXT, + against: ALL_SURFACES, + }, + { + token: 'border-subtle', + group: 'border', + light: at('neutral-dark', 300), + dark: at('neutral-light', 900), + usage: 'Decorative hairlines — table rules, dividers. Contrast-exempt.', + }, + + /* actions */ + ...(['primary', 'danger'] as const).flatMap((slot): Role[] => { + const token = slot === 'primary' ? 'action-primary' : 'action-danger'; + return [ + { + token, + group: 'action', + light: at(slot, 800), + dark: at(slot, 400), + usage: + slot === 'primary' + ? 'Primary interactive fills, strokes, links and focus rings.' + : 'Destructive actions — delete, remove, revoke.', + floor: AA_NON_TEXT, + against: ELEVATION, + }, + { + token: `${token}-hover`, + group: 'action', + light: at(slot, 900), + dark: at(slot, 300), + usage: `Hover for ${token}.`, + floor: AA_NON_TEXT, + against: ELEVATION, + }, + { + token: `${token}-pressed`, + group: 'action', + light: at(slot, 1000), + dark: at(slot, 200), + usage: `Pressed for ${token}.`, + floor: AA_NON_TEXT, + against: ELEVATION, + }, + ]; + }), + { + token: 'action-neutral', + group: 'action', + light: at('neutral-dark', 1000), + dark: at('neutral-light', 200), + usage: 'The neutral action fill. Button variant="secondary" resolves here.', + floor: AA_NON_TEXT, + against: ELEVATION, + }, + { + token: 'action-neutral-hover', + group: 'action', + light: at('neutral-dark', 1100), + dark: at('neutral-light', 100), + usage: 'Hover for action-neutral.', + floor: AA_NON_TEXT, + against: ELEVATION, + }, + { + token: 'action-neutral-pressed', + group: 'action', + light: boundary('black'), + dark: boundary('white'), + usage: 'Pressed for action-neutral.', + floor: AA_NON_TEXT, + against: ELEVATION, + }, + { + token: 'action-disabled', + group: 'action', + light: at('neutral-light', 400), + dark: at('neutral-dark', 800), + usage: + 'The disabled fill for every action variant. Contrast-exempt (inactive control).', + }, + + /* state veils: one hue, three strengths */ + ...(Object.keys(STATE_ALPHA) as (keyof typeof STATE_ALPHA)[]).map( + (token): Role => ({ + token, + group: 'state', + light: veil('primary', 600, STATE_ALPHA[token]), + dark: veil('primary', 600, STATE_ALPHA[token]), + usage: { + 'state-hover': 'Rest → hover on rows, tabs, chips, menu items.', + 'state-pressed': 'Hover → pressed on the same elements.', + 'state-selected': 'The persistent "this one is chosen" tint.', + }[token], + }) + ), + + /* + * Categorical viz. The slot order is the sandbox's categorical solve + * (`categorical.ts`) for Skillsoft's hues, taken as fixed input: porting the + * solver is deferred, so a tenant gets these slots and grades unsolved. + */ + ...( + [ + [at('primary', 800), at('primary', 500)], + [at('accent', 700), at('accent', 500)], + [at('warning', 700), at('warning', 400)], + [at('accent-alt', 600), at('accent-alt', 600)], + [at('success', 600), at('success', 300)], + [at('danger', 600), at('danger', 600)], + [at('neutral-dark', 1000), at('neutral-dark', 500)], + [at('neutral-light', 600), at('neutral-light', 600)], + ] as [RoleValue, RoleValue][] + ).map( + ([light, dark], i): Role => ({ + token: `viz-${i + 1}`, + group: 'viz', + light, + dark, + usage: `Categorical series ${i + 1}.`, + floor: AA_NON_TEXT, + against: ['surface-default'], + }) + ), + ...( + [ + [600, 600], + [700, 500], + [800, 400], + [900, 300], + ] as [Grade, Grade][] + ).map( + ([light, dark], i): Role => ({ + token: `viz-seq-${i + 1}`, + group: 'viz', + light: at('primary', light), + dark: at('primary', dark), + usage: `Sequential step ${i + 1}, low to high.`, + floor: AA_NON_TEXT, + against: ['surface-default'], + }) + ), + + /* other */ + { + token: 'backdrop', + group: 'other', + light: { ref: 'black', alpha: 0.6 }, + dark: { ref: 'black', alpha: 0.6 }, + usage: 'The scrim behind modals and dialogs. Mode-independent.', + }, +]; + +export const roleFor = (token: string): Role | undefined => + ROLES.find((r) => r.token === token); diff --git a/packages/gamut-styles/src/colorSystem/system.ts b/packages/gamut-styles/src/colorSystem/system.ts new file mode 100644 index 0000000000..4160dee348 --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/system.ts @@ -0,0 +1,82 @@ +import type { BrandInput, ResolvedBrand, Slot } from './brand'; +import { NEUTRAL_SLOTS, resolveBrand, SLOTS } from './brand'; +import type { Ramp } from './ramp'; +import { generateRamp, GRADES } from './ramp'; +import type { Mode, Ref, Role, RoleValue } from './roles'; +import { MODES, ROLES } from './roles'; + +/* + * A brand in, a fully resolved color system out. Pure: no React, no Emotion, + * no DOM. The build script calls it today; a server or runtime can call it + * later for tenant themes without a rewrite. + */ + +export interface Color { + l: number; + c: number; + h: number; + alpha?: number; + hex: string; +} + +export interface ResolvedValue extends Color { + /* The palette key this value comes from, e.g. `palette-primary-800`. */ + key: string; +} + +export interface ColorSystem { + brand: ResolvedBrand; + ramps: Record; + /* Every opaque primitive, keyed as the theme and CSS name it. */ + palette: Record; + roles: Record>; +} + +export const BOUNDARY: Record<'white' | 'black', Color> = { + white: { l: 1, c: 0, h: 0, hex: '#ffffff' }, + black: { l: 0, c: 0, h: 0, hex: '#000000' }, +}; + +export const paletteKey = (ref: Ref): string => + typeof ref === 'string' ? ref : `palette-${ref.slot}-${ref.grade}`; + +/* `palette-primary-600` at 6% → `palette-primary-600-a6`. */ +export const valueKey = ({ ref, alpha }: RoleValue): string => + alpha === undefined + ? paletteKey(ref) + : `${paletteKey(ref)}-a${Math.round(alpha * 100)}`; + +export const createColorSystem = (input: BrandInput): ColorSystem => { + const brand = resolveBrand(input); + + const ramps = Object.fromEntries( + SLOTS.map((slot) => [ + slot, + generateRamp(brand.hues[slot], NEUTRAL_SLOTS.includes(slot)), + ]) + ) as Record; + + const palette: Record = { ...BOUNDARY }; + for (const slot of SLOTS) { + for (const grade of GRADES) { + const { l, c, h, hex } = ramps[slot][grade]; + palette[paletteKey({ slot, grade })] = { l, c, h, hex }; + } + } + + const resolve = (value: RoleValue): ResolvedValue => { + const base = palette[paletteKey(value.ref)]; + return value.alpha === undefined + ? { ...base, key: valueKey(value) } + : { ...base, alpha: value.alpha, key: valueKey(value) }; + }; + + const roles = Object.fromEntries( + MODES.map((mode) => [ + mode, + Object.fromEntries(ROLES.map((r: Role) => [r.token, resolve(r[mode])])), + ]) + ) as ColorSystem['roles']; + + return { brand, ramps, palette, roles }; +}; diff --git a/packages/gamut-styles/src/colorSystem/validate.ts b/packages/gamut-styles/src/colorSystem/validate.ts new file mode 100644 index 0000000000..0a3a4551b6 --- /dev/null +++ b/packages/gamut-styles/src/colorSystem/validate.ts @@ -0,0 +1,269 @@ +import type { Color as CuloriColor } from 'culori'; +import { converter, differenceCiede2000, wcagContrast } from 'culori'; + +import { SLOTS } from './brand'; +import { contentModeFor } from './contentModes'; +import { contrast, GRADES, SEPARATION_GUARANTEE } from './ramp'; +import type { Mode } from './roles'; +import { MODES, ROLES, STATE_ALPHA } from './roles'; +import type { Color, ColorSystem } from './system'; + +/* + * The guarantees, as a function of any color system — ours or a tenant's. + * Ported from the checks in gamut-sandbox `scripts/validate-colors.ts` that + * the token output depends on. Every number is measured on OKLCH floats, not + * on rounded hex. + */ + +export interface Check { + name: string; + pass: boolean; + /* Measured for the record, never fails the run. */ + reported?: boolean; + /* The tightest measurement the check saw, for the report. */ + worst: number; + floor: number; + at: string; +} + +const toRgb = converter('rgb'); +const deltaE = differenceCiede2000(); + +const asCulori = (c: Pick): CuloriColor => ({ + mode: 'oklch', + l: c.l, + c: c.c, + h: c.h, +}); + +/* + * Paint `top` at `alpha` over `base`, in gamma-encoded sRGB — what a browser + * does with an rgba() / oklch(/ a) fill. + */ +export const composite = ( + top: Color, + alpha: number, + base: Color | CuloriColor +): CuloriColor => { + const t = toRgb(asCulori(top)); + const b = 'mode' in base ? toRgb(base) : toRgb(asCulori(base)); + const mix = (x: number, y: number) => alpha * x + (1 - alpha) * y; + return { mode: 'rgb', r: mix(t.r, b.r), g: mix(t.g, b.g), b: mix(t.b, b.b) }; +}; + +class Tracker { + worst = Infinity; + at = ''; + see(value: number, at: string) { + if (value < this.worst) { + this.worst = value; + this.at = at; + } + } +} + +/* Any two grades 500 apart clear 3:1, 600 apart clear 4.5:1 — every slot pair. */ +const gradeSeparation = (system: ColorSystem): Check[] => + (['nonText', 'text'] as const).map((kind) => { + const { minGap, floor } = SEPARATION_GUARANTEE[kind]; + const t = new Tracker(); + for (const a of SLOTS) { + for (const b of SLOTS) { + for (const g1 of GRADES) { + for (const g2 of GRADES) { + if (g2 - g1 < minGap) continue; + t.see( + contrast(system.ramps[a][g1], system.ramps[b][g2]), + `${a}-${g1} / ${b}-${g2}` + ); + } + } + } + } + return { + name: `grade separation (${minGap} apart, ${kind})`, + pass: t.worst >= floor, + worst: t.worst, + floor, + at: t.at, + }; + }); + +/* Each role against every role it's declared to sit with, in both modes. */ +const roleFloors = (system: ColorSystem): Check[] => + ROLES.filter((r) => r.floor !== undefined).map((role) => { + const t = new Tracker(); + for (const mode of MODES) { + for (const other of role.against ?? []) { + t.see( + contrast(system.roles[mode][role.token], system.roles[mode][other]), + `${mode}: ${other}` + ); + } + } + return { + name: `${role.token} ≥ ${role.floor}:1`, + pass: t.worst >= role.floor!, + worst: t.worst, + floor: role.floor!, + at: t.at, + }; + }); + +const ELEVATION = ['surface-sunken', 'surface-default', 'surface-raised']; + +/* The veil stacks a real element can be in. Selected is the only base. */ +const VEIL_STACKS: [string, string[]][] = [ + ['hover', ['state-hover']], + ['pressed', ['state-pressed']], + ['selected', ['state-selected']], + ['selected+hover', ['state-selected', 'state-hover']], + ['selected+pressed', ['state-selected', 'state-pressed']], +]; + +/* + * The sandbox leaves border-default on a selected stack out of scope: "no + * component pairs a bare border with a selected state". Measured and + * reported here, not asserted, so the carve-out stays visible. + */ +const isCarvedOut = (token: string, stack: string[]) => + token === 'border-default' && stack.includes('state-selected'); + +/* + * Every role with a floor still clears it once a state veil is composited + * over the elevation surface underneath it. + */ +const veilFloors = (system: ColorSystem): Check[] => { + const asserted = new Tracker(); + const carvedOut = new Tracker(); + for (const role of ROLES) { + /* Viz marks don't sit on interactive fills; the sandbox doesn't pair them either. */ + if (role.floor === undefined || role.group === 'viz') continue; + const surfaces = (role.against ?? []).filter((s) => ELEVATION.includes(s)); + for (const mode of MODES) { + for (const surface of surfaces) { + for (const [label, stack] of VEIL_STACKS) { + const fill = stack.reduce( + (acc, token) => + composite( + system.roles[mode][token], + system.roles[mode][token].alpha!, + acc + ), + asCulori(system.roles[mode][surface]) + ); + const margin = + wcagContrast(asCulori(system.roles[mode][role.token]), fill) - + role.floor; + const where = `${mode}: ${role.token} on ${surface} + ${label}`; + (isCarvedOut(role.token, stack) ? carvedOut : asserted).see( + margin, + where + ); + } + } + } + } + return [ + { + name: 'every floor holds on every state veil (margin over floor)', + pass: asserted.worst >= 0, + worst: asserted.worst, + floor: 0, + at: asserted.at, + }, + { + name: 'carve-out: border-default on selected stacks (margin, reported)', + pass: true, + reported: true, + worst: carvedOut.worst, + floor: 0, + at: carvedOut.at, + }, + ]; +}; + +/* Hover → pressed has to be a visible step, not just an authored one. */ +export const STATE_STEP_FLOOR = 1.0; + +const hoverPressedStep = (system: ColorSystem): Check => { + const t = new Tracker(); + for (const mode of MODES as Mode[]) { + for (const surface of ELEVATION) { + const base = asCulori(system.roles[mode][surface]); + const at = (token: keyof typeof STATE_ALPHA) => + composite(system.roles[mode][token], STATE_ALPHA[token], base); + t.see( + deltaE(at('state-hover'), at('state-pressed')), + `${mode}: ${surface}` + ); + } + } + return { + name: `hover → pressed ≥ ${STATE_STEP_FLOOR} ΔE2000`, + pass: t.worst >= STATE_STEP_FLOOR, + worst: t.worst, + floor: STATE_STEP_FLOOR, + at: t.at, + }; +}; + +/* + * Text on a surface, in the mode the content-mode table picks for it. This is + * the guarantee Background makes, now that it reads the table instead of + * measuring. Raw palette colors are reported only: a mid-ramp grade can't give + * AA text in either mode, and that's a reason not to use it as a surface. + */ +const CONTENT_TEXT = ['text-default', 'text-subtle']; + +const surfaceContent = (system: ColorSystem): Check[] => { + const surfaces = new Tracker(); + for (const role of ROLES) { + if (role.group !== 'surface') continue; + for (const mode of MODES) { + const surface = system.roles[mode][role.token]; + const content = contentModeFor(system, surface); + for (const text of CONTENT_TEXT) { + surfaces.see( + contrast(system.roles[content][text], surface), + `${mode}: ${text} (${content}) on ${role.token}` + ); + } + } + } + + const palette = new Tracker(); + let belowFloor = 0; + for (const [key, color] of Object.entries(system.palette)) { + const content = contentModeFor(system, color); + const measured = contrast(system.roles[content]['text-default'], color); + if (measured < 4.5) belowFloor += 1; + palette.see(measured, `text-default (${content}) on ${key}`); + } + + return [ + { + name: 'text on every surface role, in its content mode ≥ 4.5:1', + pass: surfaces.worst >= 4.5, + worst: surfaces.worst, + floor: 4.5, + at: surfaces.at, + }, + { + name: `text-default on raw palette colors (reported; ${belowFloor} below 4.5:1)`, + pass: true, + reported: true, + worst: palette.worst, + floor: 4.5, + at: palette.at, + }, + ]; +}; + +export const validate = (system: ColorSystem): Check[] => [ + ...gradeSeparation(system), + ...roleFloors(system), + ...veilFloors(system), + hoverPressedStep(system), + ...surfaceContent(system), +]; diff --git a/packages/gamut-styles/src/themes/skillsoft.ts b/packages/gamut-styles/src/themes/skillsoft.ts index db8bb66940..e34ce74071 100644 --- a/packages/gamut-styles/src/themes/skillsoft.ts +++ b/packages/gamut-styles/src/themes/skillsoft.ts @@ -19,7 +19,7 @@ import { /* * The first brand on the generated color system (spike, see - * cass-gmt-1825-color-spike), and the theme gamut is typed against. It replaces + * colorSystem/README.md), and the theme gamut is typed against. It replaces * core and the other themes rather than extending them, so none of core's * primitives (navy-*, hyper-*, gray-*, …) or semantic names (text, * background, primary, …) come along; only `white` and `black` survive, as diff --git a/packages/gamut/src/Alert/variants.tsx b/packages/gamut/src/Alert/variants.tsx index 6c032756bd..02cd6ea984 100644 --- a/packages/gamut/src/Alert/variants.tsx +++ b/packages/gamut/src/Alert/variants.tsx @@ -8,7 +8,7 @@ import { import { breakpoints, variant } from '@skillsoft/gamut-styles'; /* - * Design's mapping (the migration map in cass-gmt-1825-color-spike, Alert): the fill + * Design's mapping (gamut-styles colorSystem/migration.ts, Alert): the fill * is a status tint or a surface, the icon carries the status, and the message * stays text-default. Feature paints the brand gradient over its flat * fallback. diff --git a/packages/styleguide/src/lib/Foundations/ColorMode/CssContract.stories.tsx b/packages/styleguide/src/lib/Foundations/ColorMode/CssContract.stories.tsx index d060cb291f..431044b2d7 100644 --- a/packages/styleguide/src/lib/Foundations/ColorMode/CssContract.stories.tsx +++ b/packages/styleguide/src/lib/Foundations/ColorMode/CssContract.stories.tsx @@ -102,7 +102,7 @@ const SURFACES: (RoleName | PaletteKey)[] = [ 'palette-primary-800', 'palette-accent-700', 'palette-neutral-dark-1000', - /* Mid-ramp: neither mode's text reaches 4.5:1 here (validate:tokens in cass-gmt-1825-color-spike reports it). */ + /* Mid-ramp: neither mode's text reaches 4.5:1 here (validate:tokens reports it). */ 'palette-primary-600', ]; diff --git a/yarn.lock b/yarn.lock index 5cf2926de9..96dc36f1ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2040,6 +2040,45 @@ __metadata: languageName: node linkType: hard +"@bundled-es-modules/deepmerge@npm:^4.3.2": + version: 4.3.2 + resolution: "@bundled-es-modules/deepmerge@npm:4.3.2" + dependencies: + deepmerge: "npm:^4.3.1" + checksum: 10c0/e868004dc4474b68efdd83306a13402aeb5ff748542397549ff2d8f922ce51f657792e115b28bfe31245a6fe591cb7a76f944e55ef51baf621366cca6af6f5dc + languageName: node + linkType: hard + +"@bundled-es-modules/glob@npm:^13.0.6-hotfix.0": + version: 13.0.6 + resolution: "@bundled-es-modules/glob@npm:13.0.6" + dependencies: + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + glob: "npm:^13.0.6" + path: "npm:^0.12.7" + stream: "npm:^0.0.3" + string_decoder: "npm:^1.3.0" + url: "npm:^0.11.4" + checksum: 10c0/efe6879e8b33425d50a2cf01ff1a2ccc42b76e2a1fec26ac7293fdd229931554f053edcda6eabc2ee97ad7209b885c74278427357e8e7f41c15cc56438eb7b58 + languageName: node + linkType: hard + +"@bundled-es-modules/memfs@npm:^4.17.0": + version: 4.17.0 + resolution: "@bundled-es-modules/memfs@npm:4.17.0" + dependencies: + assert: "npm:^2.1.0" + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + memfs: "npm:^4.17.0" + path: "npm:^0.12.7" + stream: "npm:^0.0.3" + util: "npm:^0.12.5" + checksum: 10c0/88b573e52afe2f14b0c06f1f4337883ccf3155b7c3f667af8ead248b84ba2743e4eee90eb283ec967a8486576509bb6d42ce1c43714253f70ee23c5e5e24bc73 + languageName: node + linkType: hard + "@capsizecss/unpack@npm:^4.0.0": version: 4.0.1 resolution: "@capsizecss/unpack@npm:4.0.1" @@ -3973,6 +4012,247 @@ __metadata: languageName: node linkType: hard +"@jsonjoy.com/base64@npm:17.67.0": + version: 17.67.0 + resolution: "@jsonjoy.com/base64@npm:17.67.0" + peerDependencies: + tslib: 2 + checksum: 10c0/d9616ec1ac0ea6aa455968b1f96f2d48ce38a2b1835922a909a55147d7b8cff3d648d45e9efe6781c6926beb5f04dc41c75ce548b6b84141b14bc122893e16ee + languageName: node + linkType: hard + +"@jsonjoy.com/base64@npm:^1.1.2": + version: 1.1.2 + resolution: "@jsonjoy.com/base64@npm:1.1.2" + peerDependencies: + tslib: 2 + checksum: 10c0/88717945f66dc89bf58ce75624c99fe6a5c9a0c8614e26d03e406447b28abff80c69fb37dabe5aafef1862cf315071ae66e5c85f6018b437d95f8d13d235e6eb + languageName: node + linkType: hard + +"@jsonjoy.com/buffers@npm:17.67.0, @jsonjoy.com/buffers@npm:^17.65.0": + version: 17.67.0 + resolution: "@jsonjoy.com/buffers@npm:17.67.0" + peerDependencies: + tslib: 2 + checksum: 10c0/ee46d3ea6c2dee4dd5dffd8b156745baeecfe796c7bb3f091f9fe64c402aca5e4d86ba3d736545682f919303fb15359c1f00d41ac91ea1b5d4edbbe74f540d35 + languageName: node + linkType: hard + +"@jsonjoy.com/buffers@npm:^1.0.0, @jsonjoy.com/buffers@npm:^1.2.0": + version: 1.2.1 + resolution: "@jsonjoy.com/buffers@npm:1.2.1" + peerDependencies: + tslib: 2 + checksum: 10c0/5edaf761b78b730ae0598824adb37473fef5b40a8fc100625159700eb36e00057c5129c7ad15fc0e3178e8de58a044da65728e8d7b05fd3eed58e9b9a0d02b5a + languageName: node + linkType: hard + +"@jsonjoy.com/codegen@npm:17.67.0": + version: 17.67.0 + resolution: "@jsonjoy.com/codegen@npm:17.67.0" + peerDependencies: + tslib: 2 + checksum: 10c0/3cc529377cc315acf373dc52dbd39d56285b31ba8ca90a4447230e37e405372cc13bed7df638dc81f9071ff8f4eb8e825217987397d80182d08ded761e609a93 + languageName: node + linkType: hard + +"@jsonjoy.com/codegen@npm:^1.0.0": + version: 1.0.0 + resolution: "@jsonjoy.com/codegen@npm:1.0.0" + peerDependencies: + tslib: 2 + checksum: 10c0/54686352248440ad1484ce7db0270a5a72424fb9651b090e5f1c8e2cd8e55e6c7a3f67dfe4ed90c689cf01ed949e794764a8069f5f52510eaf0a2d0c41d324cd + languageName: node + linkType: hard + +"@jsonjoy.com/fs-core@npm:4.80.0": + version: 4.80.0 + resolution: "@jsonjoy.com/fs-core@npm:4.80.0" + dependencies: + "@jsonjoy.com/fs-node-builtins": "npm:4.80.0" + "@jsonjoy.com/fs-node-utils": "npm:4.80.0" + thingies: "npm:^2.5.0" + peerDependencies: + tslib: 2 + checksum: 10c0/dddfc154fd16ab6bab67916794d91c0aed4d267a02f8e3a1d3062bc2966d727cade1648f550280ea3a9f044aee01dfd1ce56e449682f50a34075be724615cfaa + languageName: node + linkType: hard + +"@jsonjoy.com/fs-fsa@npm:4.80.0": + version: 4.80.0 + resolution: "@jsonjoy.com/fs-fsa@npm:4.80.0" + dependencies: + "@jsonjoy.com/fs-core": "npm:4.80.0" + "@jsonjoy.com/fs-node-builtins": "npm:4.80.0" + "@jsonjoy.com/fs-node-utils": "npm:4.80.0" + thingies: "npm:^2.5.0" + peerDependencies: + tslib: 2 + checksum: 10c0/fc5ddfccae167cb79ba1de4bee1636d73a8289c00fe6978950bfff09c2244a802063d19d55e9f27b6945e70a981b75dfede1efd9ca022c1ed6180653a41611fa + languageName: node + linkType: hard + +"@jsonjoy.com/fs-node-builtins@npm:4.80.0": + version: 4.80.0 + resolution: "@jsonjoy.com/fs-node-builtins@npm:4.80.0" + peerDependencies: + tslib: 2 + checksum: 10c0/9324295979e8be14f64cf8ce54d9b49fe50a42f3ade9301ac00d7c7841da26ff25aa0c37f02edd8ca3247b07b6791db9e68d76e7f0d5dc51e8c2da090c514b3d + languageName: node + linkType: hard + +"@jsonjoy.com/fs-node-to-fsa@npm:4.80.0": + version: 4.80.0 + resolution: "@jsonjoy.com/fs-node-to-fsa@npm:4.80.0" + dependencies: + "@jsonjoy.com/fs-fsa": "npm:4.80.0" + "@jsonjoy.com/fs-node-builtins": "npm:4.80.0" + "@jsonjoy.com/fs-node-utils": "npm:4.80.0" + peerDependencies: + tslib: 2 + checksum: 10c0/53eebb78689e18c851f31dc1f9cea4f427225f3314eca41eefef55fcd894f40bfef829b400f1873675e9823edc67cc9f678da4812b40ae87efed13f215c350c8 + languageName: node + linkType: hard + +"@jsonjoy.com/fs-node-utils@npm:4.80.0": + version: 4.80.0 + resolution: "@jsonjoy.com/fs-node-utils@npm:4.80.0" + dependencies: + "@jsonjoy.com/fs-node-builtins": "npm:4.80.0" + glob-to-regex.js: "npm:^1.3.1" + peerDependencies: + tslib: 2 + checksum: 10c0/718465fd60009a32a76fae8582e7e88eff2047cc613c764c9ae0a6aa74ad4319b062e35849bb2325d24cfdc5e5ff2894027bbc0a4a647c8334437d1db1db9920 + languageName: node + linkType: hard + +"@jsonjoy.com/fs-node@npm:4.80.0": + version: 4.80.0 + resolution: "@jsonjoy.com/fs-node@npm:4.80.0" + dependencies: + "@jsonjoy.com/fs-core": "npm:4.80.0" + "@jsonjoy.com/fs-node-builtins": "npm:4.80.0" + "@jsonjoy.com/fs-node-utils": "npm:4.80.0" + "@jsonjoy.com/fs-print": "npm:4.80.0" + "@jsonjoy.com/fs-snapshot": "npm:4.80.0" + glob-to-regex.js: "npm:^1.3.1" + thingies: "npm:^2.5.0" + peerDependencies: + tslib: 2 + checksum: 10c0/2b4583f2fb79643d6c6cb4d28b46d3ed2450e90b99d458c243d470d70446d16d967a63e4ea9a785cebe43c20f97fb0b2c522a2098c465e4817c6af8be848990c + languageName: node + linkType: hard + +"@jsonjoy.com/fs-print@npm:4.80.0": + version: 4.80.0 + resolution: "@jsonjoy.com/fs-print@npm:4.80.0" + dependencies: + "@jsonjoy.com/fs-node-utils": "npm:4.80.0" + tree-dump: "npm:^1.1.0" + peerDependencies: + tslib: 2 + checksum: 10c0/e76a353fe65d4037bc2620e684f76c749f001e40faf25b836fda8264824fff6f5d5c4a8ce4e705bc38a72950826f9f44b6a52027f561a93fc1953f085c4aa73e + languageName: node + linkType: hard + +"@jsonjoy.com/fs-snapshot@npm:4.80.0": + version: 4.80.0 + resolution: "@jsonjoy.com/fs-snapshot@npm:4.80.0" + dependencies: + "@jsonjoy.com/buffers": "npm:^17.65.0" + "@jsonjoy.com/fs-node-utils": "npm:4.80.0" + "@jsonjoy.com/json-pack": "npm:^17.65.0" + "@jsonjoy.com/util": "npm:^17.65.0" + peerDependencies: + tslib: 2 + checksum: 10c0/d8e75a5c2fd205b5fc839c3c2516b1f7be07fa6c467855c9c856605964b3f74e23b3d915876648f95041010c21750e639862e96af19e3a46dddd318f294f0926 + languageName: node + linkType: hard + +"@jsonjoy.com/json-pack@npm:^1.11.0": + version: 1.21.0 + resolution: "@jsonjoy.com/json-pack@npm:1.21.0" + dependencies: + "@jsonjoy.com/base64": "npm:^1.1.2" + "@jsonjoy.com/buffers": "npm:^1.2.0" + "@jsonjoy.com/codegen": "npm:^1.0.0" + "@jsonjoy.com/json-pointer": "npm:^1.0.2" + "@jsonjoy.com/util": "npm:^1.9.0" + hyperdyperid: "npm:^1.2.0" + thingies: "npm:^2.5.0" + tree-dump: "npm:^1.1.0" + peerDependencies: + tslib: 2 + checksum: 10c0/0183eccccf2ab912389a6784ae81c1a7da48cf178902efe093fb60c457359c7c75da2803f869e0a1489f1342dfa4f8ab9b27b65adc9f44fd9646823773b71e9d + languageName: node + linkType: hard + +"@jsonjoy.com/json-pack@npm:^17.65.0": + version: 17.67.0 + resolution: "@jsonjoy.com/json-pack@npm:17.67.0" + dependencies: + "@jsonjoy.com/base64": "npm:17.67.0" + "@jsonjoy.com/buffers": "npm:17.67.0" + "@jsonjoy.com/codegen": "npm:17.67.0" + "@jsonjoy.com/json-pointer": "npm:17.67.0" + "@jsonjoy.com/util": "npm:17.67.0" + hyperdyperid: "npm:^1.2.0" + thingies: "npm:^2.5.0" + tree-dump: "npm:^1.1.0" + peerDependencies: + tslib: 2 + checksum: 10c0/fee56d024c84f031ef011a85ccca071c73b8a0739506083bd3dc7a17c720a498599f285e79082a9626314324ea938f189d18d47a03341cb76286ca2e7098bf53 + languageName: node + linkType: hard + +"@jsonjoy.com/json-pointer@npm:17.67.0": + version: 17.67.0 + resolution: "@jsonjoy.com/json-pointer@npm:17.67.0" + dependencies: + "@jsonjoy.com/util": "npm:17.67.0" + peerDependencies: + tslib: 2 + checksum: 10c0/763e0b1bc274390a605073b49e5bf55bdf386e784f5940d456faca958d90915b7d9a47dd9d58a08e2113f40167b0640d313897811680eb91630726920618fe7d + languageName: node + linkType: hard + +"@jsonjoy.com/json-pointer@npm:^1.0.2": + version: 1.0.2 + resolution: "@jsonjoy.com/json-pointer@npm:1.0.2" + dependencies: + "@jsonjoy.com/codegen": "npm:^1.0.0" + "@jsonjoy.com/util": "npm:^1.9.0" + peerDependencies: + tslib: 2 + checksum: 10c0/8d959c0fdd77d937d2a829270de51533bb9e3b887b3f6f02943884dc33dd79225071218c93f4bafdee6a3412fd5153264997953a86de444d85c1fff67915af54 + languageName: node + linkType: hard + +"@jsonjoy.com/util@npm:17.67.0, @jsonjoy.com/util@npm:^17.65.0": + version: 17.67.0 + resolution: "@jsonjoy.com/util@npm:17.67.0" + dependencies: + "@jsonjoy.com/buffers": "npm:17.67.0" + "@jsonjoy.com/codegen": "npm:17.67.0" + peerDependencies: + tslib: 2 + checksum: 10c0/44be53d94c99ce74a0eff1bb111f0ff4392a1226e34637321c8bc45b569da3f9e12db8b225eef3694c44b9fd2e9b800d7baf5ea0d38e1d7767bfcbef4fbf91b0 + languageName: node + linkType: hard + +"@jsonjoy.com/util@npm:^1.9.0": + version: 1.9.0 + resolution: "@jsonjoy.com/util@npm:1.9.0" + dependencies: + "@jsonjoy.com/buffers": "npm:^1.0.0" + "@jsonjoy.com/codegen": "npm:^1.0.0" + peerDependencies: + tslib: 2 + checksum: 10c0/a720a6accaae71fa9e7fa06e93e382702aa5760ef2bdc3bc45c19dc2228a01cc735d36cb970c654bc5e88f1328d55d1f0d5eceef0b76bcc327a2ce863e7b0021 + languageName: node + linkType: hard + "@lit-labs/react@npm:^2.0.0": version: 2.1.3 resolution: "@lit-labs/react@npm:2.1.3" @@ -7753,11 +8033,15 @@ __metadata: dependencies: "@emotion/is-prop-valid": "npm:^1.1.0" "@skillsoft/variance": "npm:^0.0.1" + "@types/culori": "npm:^4" csstype: "npm:^3.0.7" + culori: "npm:^4.0.2" framer-motion: "npm:^12.0.0" get-nonce: "npm:^1.0.0" + jiti: "npm:2.4.2" lodash: "npm:^4.17.23" polished: "npm:^4.1.2" + style-dictionary: "npm:5.7.0" peerDependencies: "@emotion/cache": ^11.4.0 "@emotion/react": ^11.4.0 @@ -8861,6 +9145,13 @@ __metadata: languageName: node linkType: hard +"@types/culori@npm:^4": + version: 4.0.1 + resolution: "@types/culori@npm:4.0.1" + checksum: 10c0/f25fcf01fe709d2fce569892eae71a268b19cf85a7c8f6bf6b041f02f232e5a7d1218e8213349d6b2a458be9f61158ed1e1893e7b81e2ee4a7cebc2a172b7ebb + languageName: node + linkType: hard + "@types/debug@npm:^4.0.0": version: 4.1.13 resolution: "@types/debug@npm:4.1.13" @@ -10127,6 +10418,13 @@ __metadata: languageName: node linkType: hard +"@zip.js/zip.js@npm:^2.7.44": + version: 2.24.0 + resolution: "@zip.js/zip.js@npm:2.24.0" + checksum: 10c0/99c4c9cbab8e5161bb834f2cede558d70163f0994f640e57ae64e7af492dcb65e6ceee265cec1521abc28566623830d22b1be03ddbf2bdbc56dbe816be3574dc + languageName: node + linkType: hard + "@zkochan/js-yaml@npm:0.0.7": version: 0.0.7 resolution: "@zkochan/js-yaml@npm:0.0.7" @@ -10602,6 +10900,19 @@ __metadata: languageName: node linkType: hard +"assert@npm:^2.1.0": + version: 2.1.0 + resolution: "assert@npm:2.1.0" + dependencies: + call-bind: "npm:^1.0.2" + is-nan: "npm:^1.3.2" + object-is: "npm:^1.1.5" + object.assign: "npm:^4.1.4" + util: "npm:^0.12.5" + checksum: 10c0/7271a5da883c256a1fa690677bf1dd9d6aa882139f2bed1cd15da4f9e7459683e1da8e32a203d6cc6767e5e0f730c77a9532a87b896b4b0af0dd535f668775f0 + languageName: node + linkType: hard + "assertion-error@npm:^2.0.1": version: 2.0.1 resolution: "assertion-error@npm:2.0.1" @@ -11182,6 +11493,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 10c0/2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0 + languageName: node + linkType: hard + "bundle-name@npm:^4.1.0": version: 4.1.0 resolution: "bundle-name@npm:4.1.0" @@ -11241,6 +11562,18 @@ __metadata: languageName: node linkType: hard +"call-bind@npm:^1.0.0, call-bind@npm:^1.0.9": + version: 1.0.9 + resolution: "call-bind@npm:1.0.9" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + get-intrinsic: "npm:^1.3.0" + set-function-length: "npm:^1.2.2" + checksum: 10c0/a6621f6da1444481919ce3b4983dff725691e0754d3507ae483ce56e54985f2da7d6f1df512c56dbf28660745cf1ca52553f1fc9aef5557f3ce353ef14fab714 + languageName: node + linkType: hard + "call-bind@npm:^1.0.2, call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": version: 1.0.8 resolution: "call-bind@npm:1.0.8" @@ -11372,13 +11705,20 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.4.1": +"chalk@npm:^5.3.0, chalk@npm:^5.4.1": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 languageName: node linkType: hard +"change-case@npm:^5.3.0": + version: 5.4.4 + resolution: "change-case@npm:5.4.4" + checksum: 10c0/2a9c2b9c9ad6ab2491105aaf506db1a9acaf543a18967798dcce20926c6a173aa63266cb6189f3086e3c14bf7ae1f8ea4f96ecc466fcd582310efa00372f3734 + languageName: node + linkType: hard + "char-regex@npm:^1.0.2": version: 1.0.2 resolution: "char-regex@npm:1.0.2" @@ -11717,6 +12057,13 @@ __metadata: languageName: node linkType: hard +"colorjs.io@npm:^0.5.2": + version: 0.5.2 + resolution: "colorjs.io@npm:0.5.2" + checksum: 10c0/2e6ea43629e325e721b92429239de3a6f42fb6d88ba6e4c2aeff0288c196d876f2f7ee82aea95bd40072d5cdc8cb87f042f4d94c134dcabf0e34a717e4caacb9 + languageName: node + linkType: hard + "columnify@npm:^1.6.0": version: 1.6.0 resolution: "columnify@npm:1.6.0" @@ -11764,6 +12111,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^12.1.0": + version: 12.1.0 + resolution: "commander@npm:12.1.0" + checksum: 10c0/6e1996680c083b3b897bfc1cfe1c58dfbcd9842fd43e1aaf8a795fbc237f65efcc860a3ef457b318e73f29a4f4a28f6403c3d653d021d960e4632dd45bde54a9 + languageName: node + linkType: hard + "commander@npm:^14.0.3": version: 14.0.3 resolution: "commander@npm:14.0.3" @@ -11806,6 +12160,13 @@ __metadata: languageName: node linkType: hard +"component-emitter@npm:^2.0.0": + version: 2.0.0 + resolution: "component-emitter@npm:2.0.0" + checksum: 10c0/65dfaf787ea49eb48f0ffec766bda7ec67e8dbeb3b406f08724dcae842e0aa274731fcccb9280b77d2b41693061731a9080b60d276020246a146544cd9900b83 + languageName: node + linkType: hard + "component-test-setup@npm:^0.3.1": version: 0.3.3 resolution: "component-test-setup@npm:0.3.3" @@ -12219,6 +12580,13 @@ __metadata: languageName: node linkType: hard +"culori@npm:^4.0.2": + version: 4.0.2 + resolution: "culori@npm:4.0.2" + checksum: 10c0/5d3c952b947ac5915409bab513e1d2e8bd813caa491af8ded0c769fdc1186a5ff61fb3c59fc8edf5393237d92074dbc54a492e6e99cb039dd6535d5d96106bfe + languageName: node + linkType: hard + "custom-media-element@npm:^1.4.6, custom-media-element@npm:~1.4.6": version: 1.4.6 resolution: "custom-media-element@npm:1.4.6" @@ -13870,7 +14238,7 @@ __metadata: languageName: node linkType: hard -"events@npm:^3.2.0": +"events@npm:^3.2.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 @@ -14766,6 +15134,15 @@ __metadata: languageName: node linkType: hard +"glob-to-regex.js@npm:^1.3.1": + version: 1.3.1 + resolution: "glob-to-regex.js@npm:1.3.1" + peerDependencies: + tslib: 2 + checksum: 10c0/0a4d8824c911a119b2cc7cbc2dd85287e899a5a4d16c26fee2cb0cac6d599a8273d29d04af47192116574212b63ada07f7af1c1369ba8417c75e1c3727b1c266 + languageName: node + linkType: hard + "glob-to-regexp@npm:^0.4.1": version: 0.4.1 resolution: "glob-to-regexp@npm:0.4.1" @@ -14789,7 +15166,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^13.0.0, glob@npm:^13.0.1": +"glob@npm:^13.0.0, glob@npm:^13.0.1, glob@npm:^13.0.6": version: 13.0.6 resolution: "glob@npm:13.0.6" dependencies: @@ -15613,6 +15990,13 @@ __metadata: languageName: node linkType: hard +"hyperdyperid@npm:^1.2.0": + version: 1.2.0 + resolution: "hyperdyperid@npm:1.2.0" + checksum: 10c0/885ba3177c7181d315a856ee9c0005ff8eb5dcb1ce9e9d61be70987895d934d84686c37c981cceeb53216d4c9c15c1cc25f1804e84cc6a74a16993c5d7fd0893 + languageName: node + linkType: hard + "hyphenate-style-name@npm:^1.0.3": version: 1.1.0 resolution: "hyphenate-style-name@npm:1.1.0" @@ -15668,7 +16052,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.1.13": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb @@ -15786,6 +16170,13 @@ __metadata: languageName: node linkType: hard +"inherits@npm:2.0.3": + version: 2.0.3 + resolution: "inherits@npm:2.0.3" + checksum: 10c0/6e56402373149ea076a434072671f9982f5fad030c7662be0332122fe6c0fa490acb3cc1010d90b6eff8d640b1167d77674add52dfd1bb85d545cf29e80e73e7 + languageName: node + linkType: hard + "ini@npm:4.1.3, ini@npm:^4.1.2, ini@npm:^4.1.3": version: 4.1.3 resolution: "ini@npm:4.1.3" @@ -15886,6 +16277,16 @@ __metadata: languageName: node linkType: hard +"is-arguments@npm:^1.0.4": + version: 1.2.0 + resolution: "is-arguments@npm:1.2.0" + dependencies: + call-bound: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/6377344b31e9fcb707c6751ee89b11f132f32338e6a782ec2eac9393b0cbd32235dad93052998cda778ee058754860738341d8114910d50ada5615912bb929fc + languageName: node + linkType: hard + "is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" @@ -16067,6 +16468,19 @@ __metadata: languageName: node linkType: hard +"is-generator-function@npm:^1.0.7": + version: 1.1.2 + resolution: "is-generator-function@npm:1.1.2" + dependencies: + call-bound: "npm:^1.0.4" + generator-function: "npm:^2.0.0" + get-proto: "npm:^1.0.1" + has-tostringtag: "npm:^1.0.2" + safe-regex-test: "npm:^1.1.0" + checksum: 10c0/83da102e89c3e3b71d67b51d47c9f9bc862bceb58f87201727e27f7fa19d1d90b0ab223644ecaee6fc6e3d2d622bb25c966fbdaf87c59158b01ce7c0fe2fa372 + languageName: node + linkType: hard + "is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": version: 4.0.3 resolution: "is-glob@npm:4.0.3" @@ -16115,6 +16529,16 @@ __metadata: languageName: node linkType: hard +"is-nan@npm:^1.3.2": + version: 1.3.2 + resolution: "is-nan@npm:1.3.2" + dependencies: + call-bind: "npm:^1.0.0" + define-properties: "npm:^1.1.3" + checksum: 10c0/8bfb286f85763f9c2e28ea32e9127702fe980ffd15fa5d63ade3be7786559e6e21355d3625dd364c769c033c5aedf0a2ed3d4025d336abf1b9241e3d9eddc5b0 + languageName: node + linkType: hard + "is-negative-zero@npm:^2.0.3": version: 2.0.3 resolution: "is-negative-zero@npm:2.0.3" @@ -16153,7 +16577,7 @@ __metadata: languageName: node linkType: hard -"is-plain-obj@npm:^4.0.0": +"is-plain-obj@npm:^4.0.0, is-plain-obj@npm:^4.1.0": version: 4.1.0 resolution: "is-plain-obj@npm:4.1.0" checksum: 10c0/32130d651d71d9564dc88ba7e6fda0e91a1010a3694648e9f4f47bb6080438140696d3e3e15c741411d712e47ac9edc1a8a9de1fe76f3487b0d90be06ac9975e @@ -16248,7 +16672,7 @@ __metadata: languageName: node linkType: hard -"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15": +"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15, is-typed-array@npm:^1.1.3": version: 1.1.15 resolution: "is-typed-array@npm:1.1.15" dependencies: @@ -18166,6 +18590,28 @@ __metadata: languageName: node linkType: hard +"memfs@npm:^4.17.0": + version: 4.80.0 + resolution: "memfs@npm:4.80.0" + dependencies: + "@jsonjoy.com/fs-core": "npm:4.80.0" + "@jsonjoy.com/fs-fsa": "npm:4.80.0" + "@jsonjoy.com/fs-node": "npm:4.80.0" + "@jsonjoy.com/fs-node-builtins": "npm:4.80.0" + "@jsonjoy.com/fs-node-to-fsa": "npm:4.80.0" + "@jsonjoy.com/fs-node-utils": "npm:4.80.0" + "@jsonjoy.com/fs-print": "npm:4.80.0" + "@jsonjoy.com/fs-snapshot": "npm:4.80.0" + "@jsonjoy.com/json-pack": "npm:^1.11.0" + "@jsonjoy.com/util": "npm:^1.9.0" + glob-to-regex.js: "npm:^1.3.1" + thingies: "npm:^2.5.0" + tree-dump: "npm:^1.0.3" + tslib: "npm:^2.0.0" + checksum: 10c0/0946dcbef734094cacc41c186c55ac85e94cbc413d81bdbb0db0db4a9dfb612f08852a0f89f1eaa29aaa19c70dce20dcf6b90fa4eae70c7d51d524782b28001b + languageName: node + linkType: hard + "memoize-one@npm:^5.1.1": version: 5.2.1 resolution: "memoize-one@npm:5.2.1" @@ -19455,6 +19901,16 @@ __metadata: languageName: node linkType: hard +"object-is@npm:^1.1.5": + version: 1.1.6 + resolution: "object-is@npm:1.1.6" + dependencies: + call-bind: "npm:^1.0.7" + define-properties: "npm:^1.2.1" + checksum: 10c0/506af444c4dce7f8e31f34fc549e2fb8152d6b9c4a30c6e62852badd7f520b579c679af433e7a072f9d78eb7808d230dc12e1cf58da9154dfbf8813099ea0fe0 + languageName: node + linkType: hard + "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -20152,6 +20608,23 @@ __metadata: languageName: node linkType: hard +"path-unified@npm:^0.2.0": + version: 0.2.0 + resolution: "path-unified@npm:0.2.0" + checksum: 10c0/5229bbcbb093b1c76e7a8f568dd7d362bae6bd9348099968252aa17b1ffd86ef845d560a6b483bb2e6a3b2c25a5e8288707b03e41b66b2761aa1e2ba67b07d5b + languageName: node + linkType: hard + +"path@npm:^0.12.7": + version: 0.12.7 + resolution: "path@npm:0.12.7" + dependencies: + process: "npm:^0.11.1" + util: "npm:^0.10.3" + checksum: 10c0/f795ce5438a988a590c7b6dfd450ec9baa1c391a8be4c2dea48baa6e0f5b199e56cd83b8c9ebf3991b81bea58236d2c32bdafe2c17a2e70c3a2e4c69891ade59 + languageName: node + linkType: hard + "pathe@npm:^2.0.3": version: 2.0.3 resolution: "pathe@npm:2.0.3" @@ -20452,6 +20925,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^3.3.3": + version: 3.9.10 + resolution: "prettier@npm:3.9.10" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/0b3531593aa6fb36ac6baf629cb058bda472c59a6938774f931fe55283a07c62e6f56e621b043a82d8a3e21fc284c08c34f9e539fc70072635f79b7bae1a1067 + languageName: node + linkType: hard + "pretty-format@npm:30.4.1, pretty-format@npm:^30.0.0": version: 30.4.1 resolution: "pretty-format@npm:30.4.1" @@ -20510,6 +20992,13 @@ __metadata: languageName: node linkType: hard +"process@npm:^0.11.1": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3 + languageName: node + linkType: hard + "promise-inflight@npm:^1.0.1": version: 1.0.1 resolution: "promise-inflight@npm:1.0.1" @@ -20586,6 +21075,13 @@ __metadata: languageName: node linkType: hard +"punycode@npm:^1.4.1": + version: 1.4.1 + resolution: "punycode@npm:1.4.1" + checksum: 10c0/354b743320518aef36f77013be6e15da4db24c2b4f62c5f1eb0529a6ed02fbaf1cb52925785f6ab85a962f2b590d9cd5ad730b70da72b5f180e2556b8bd3ca08 + languageName: node + linkType: hard + "punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -20623,6 +21119,16 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.12.3": + version: 6.16.1 + resolution: "qs@npm:6.16.1" + dependencies: + es-define-property: "npm:^1.0.1" + side-channel: "npm:^1.1.1" + checksum: 10c0/84ee9124b251ab2953132fbea1d70c966aa6e6496cccabedbbb83afa412bf4177bddebc779ca3ddafe23da83b2f593d530b41be6a726e971404fabb20ff22c94 + languageName: node + linkType: hard + "quansync@npm:^1.0.0": version: 1.0.0 resolution: "quansync@npm:1.0.0" @@ -22554,6 +23060,16 @@ __metadata: languageName: node linkType: hard +"side-channel-list@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-list@npm:1.0.1" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.4" + checksum: 10c0/d346c787fd2f9f1c2fdea14f00e8250118db0e7596d85a6cb9faa75f105d31a73a8f7a341c93d7df2a2429098c3d37a77bd3be9e88c37094b8c01807bc77c7a2 + languageName: node + linkType: hard + "side-channel-map@npm:^1.0.1": version: 1.0.1 resolution: "side-channel-map@npm:1.0.1" @@ -22592,6 +23108,19 @@ __metadata: languageName: node linkType: hard +"side-channel@npm:^1.1.1": + version: 1.1.1 + resolution: "side-channel@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.4" + side-channel-list: "npm:^1.0.1" + side-channel-map: "npm:^1.0.1" + side-channel-weakmap: "npm:^1.0.2" + checksum: 10c0/dc0ab81d67f61bda9247d053ce93f41c3fd8ad2bdcb9cf9d8d2f8540d488f26d87a5e99ebfc07eea49ec025867b2452b705442d974b1478f0395e69f6bfb3270 + languageName: node + linkType: hard + "siginfo@npm:^2.0.0": version: 2.0.0 resolution: "siginfo@npm:2.0.0" @@ -23115,6 +23644,15 @@ __metadata: languageName: node linkType: hard +"stream@npm:^0.0.3": + version: 0.0.3 + resolution: "stream@npm:0.0.3" + dependencies: + component-emitter: "npm:^2.0.0" + checksum: 10c0/5d262408583f3d5fed8077b33ad670320d85c6b7c0fb3ab73a9a632fbad0ee36f3c66e6feb5264cb39dbee3a619174fa886b5f69f98217666d0844f6a2f6510b + languageName: node + linkType: hard + "streamroller@npm:^3.1.5": version: 3.1.5 resolution: "streamroller@npm:3.1.5" @@ -23284,7 +23822,7 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:^1.1.1": +"string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: @@ -23385,6 +23923,29 @@ __metadata: languageName: node linkType: hard +"style-dictionary@npm:5.7.0": + version: 5.7.0 + resolution: "style-dictionary@npm:5.7.0" + dependencies: + "@bundled-es-modules/deepmerge": "npm:^4.3.2" + "@bundled-es-modules/glob": "npm:^13.0.6-hotfix.0" + "@bundled-es-modules/memfs": "npm:^4.17.0" + "@zip.js/zip.js": "npm:^2.7.44" + chalk: "npm:^5.3.0" + change-case: "npm:^5.3.0" + colorjs.io: "npm:^0.5.2" + commander: "npm:^12.1.0" + is-plain-obj: "npm:^4.1.0" + json5: "npm:^2.2.2" + path-unified: "npm:^0.2.0" + prettier: "npm:^3.3.3" + tinycolor2: "npm:^1.6.0" + bin: + style-dictionary: bin/style-dictionary.js + checksum: 10c0/1a2e60957bdab867717337bbfa698d80f82f6b2a2ce61d5d2cff2583d86893f1e6d8c5abcebd6bbcfd451a6644605d457980c632f1af14776c4b7e707e0c4128 + languageName: node + linkType: hard + "style-to-js@npm:^1.0.0": version: 1.1.21 resolution: "style-to-js@npm:1.1.21" @@ -23710,6 +24271,15 @@ __metadata: languageName: node linkType: hard +"thingies@npm:^2.5.0": + version: 2.6.1 + resolution: "thingies@npm:2.6.1" + peerDependencies: + tslib: ^2 + checksum: 10c0/eaf83b2a77dc105ff724c04777a56cea40e945a73bd50cc7554694702ad3d09d1d1843ed32315723ea45490106e8a0f972d9c12dc60d3081c551b4eb4235b24f + languageName: node + linkType: hard + "throttle-debounce@npm:^3.0.1": version: 3.0.1 resolution: "throttle-debounce@npm:3.0.1" @@ -23759,6 +24329,13 @@ __metadata: languageName: node linkType: hard +"tinycolor2@npm:^1.6.0": + version: 1.6.0 + resolution: "tinycolor2@npm:1.6.0" + checksum: 10c0/9aa79a36ba2c2a87cb221453465cabacd04b9e35f9694373e846fdc78b1c768110f81e581ea41440106c0f24d9a023891d0887e8075885e790ac40eb0e74a5c1 + languageName: node + linkType: hard + "tinyexec@npm:^0.3.2": version: 0.3.2 resolution: "tinyexec@npm:0.3.2" @@ -23891,6 +24468,15 @@ __metadata: languageName: node linkType: hard +"tree-dump@npm:^1.0.3, tree-dump@npm:^1.1.0": + version: 1.1.0 + resolution: "tree-dump@npm:1.1.0" + peerDependencies: + tslib: 2 + checksum: 10c0/079f0f0163b68ee2eedc65cab1de6fb121487eba9ae135c106a8bc5e4ab7906ae0b57d86016e4a7da8c0ee906da1eae8c6a1490cd6e2a5e5ccbca321e1f959ca + languageName: node + linkType: hard + "tree-kill@npm:1.2.2, tree-kill@npm:^1.2.2": version: 1.2.2 resolution: "tree-kill@npm:1.2.2" @@ -24820,6 +25406,16 @@ __metadata: languageName: node linkType: hard +"url@npm:^0.11.4": + version: 0.11.4 + resolution: "url@npm:0.11.4" + dependencies: + punycode: "npm:^1.4.1" + qs: "npm:^6.12.3" + checksum: 10c0/cc93405ae4a9b97a2aa60ca67f1cb1481c0221cb4725a7341d149be5e2f9cfda26fd432d64dbbec693d16593b68b8a46aad8e5eab21f814932134c9d8620c662 + languageName: node + linkType: hard + "use-callback-ref@npm:^1.3.3": version: 1.3.3 resolution: "use-callback-ref@npm:1.3.3" @@ -24891,6 +25487,28 @@ __metadata: languageName: node linkType: hard +"util@npm:^0.10.3": + version: 0.10.4 + resolution: "util@npm:0.10.4" + dependencies: + inherits: "npm:2.0.3" + checksum: 10c0/d29f6893e406b63b088ce9924da03201df89b31490d4d011f1c07a386ea4b3dbe907464c274023c237da470258e1805d806c7e4009a5974cd6b1d474b675852a + languageName: node + linkType: hard + +"util@npm:^0.12.5": + version: 0.12.5 + resolution: "util@npm:0.12.5" + dependencies: + inherits: "npm:^2.0.3" + is-arguments: "npm:^1.0.4" + is-generator-function: "npm:^1.0.7" + is-typed-array: "npm:^1.1.3" + which-typed-array: "npm:^1.1.2" + checksum: 10c0/c27054de2cea2229a66c09522d0fa1415fb12d861d08523a8846bf2e4cbf0079d4c3f725f09dcb87493549bcbf05f5798dce1688b53c6c17201a45759e7253f3 + languageName: node + linkType: hard + "utils-merge@npm:1.0.1": version: 1.0.1 resolution: "utils-merge@npm:1.0.1" @@ -25457,6 +26075,21 @@ __metadata: languageName: node linkType: hard +"which-typed-array@npm:^1.1.2": + version: 1.1.24 + resolution: "which-typed-array@npm:1.1.24" + dependencies: + available-typed-arrays: "npm:^1.0.7" + call-bind: "npm:^1.0.9" + call-bound: "npm:^1.0.4" + for-each: "npm:^0.3.5" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/26e66a1828fa60b989515178c58f075dc4b53c9d79e085e8615221eee6c32e5b224ea4b0a74338540bcdbf4986f8bd08d5fb4b2a7657d61e7960efd89a85b904 + languageName: node + linkType: hard + "which@npm:^1.2.14": version: 1.3.1 resolution: "which@npm:1.3.1"