Merge branch 'theme': token values become a swappable theme
commit 1037346e882ab5331a4027190f1115de068476c5 · parent f48fd5bcf8 · parent bd28b64990
· browse this tree
Ximianth, 2026-10-10 06:14:02 UTC
diff --git a/docs/precepts-aesthetics.md b/docs/precepts-aesthetics.md index de5fb74..7c9c65a 100644 --- a/docs/precepts-aesthetics.md +++ b/docs/precepts-aesthetics.md @@ -31,3 +31,23 @@ So: (Mechanically: the focus box being drawn on any two neighbors should mean drawing the same shape at the same size, just in a coordinate offset on a single axis.) - Tabular data should be tabular: columns align, etc; and like the rules for rows, so too it still applies that the focus box should be regularly and unsurprisingly sized for neighboring elements for tables. + + +Theming +------- + +Yukey's look is a theme, and the one described above is the default. + +The split works like this: + +- Token **names** are the vocabulary: every color, gap, size, and type choice the components are styled with, as `--yk-*` CSS custom properties. + Each has a semantic role (`--yk-accent`, `--yk-space-md`, `--yk-focus-ring`), and components only ever write `var(--yk-...)`. + The vocabulary is `packages/yukey/src/tokens.ts`; the `tokens` gallery entry renders it live. +- Token **values** are a theme: one CSS value per name. + The default is `packages/yukey/src/themes/default.ts`. + `installTokens()` installs it; `installTokens(myTheme)` installs yours instead. + A theme is typed against the vocabulary, so one that misses a token is a compile error, not a silently unstyled element. + +Names are the durable part. Values are the iteration surface: change them freely, and nothing else moves. +If no token says what you mean, that's a vocabulary conversation, not a license for a literal. +Renaming or re-roling a token is a real API change. diff --git a/packages/yukey/src/press-flash.test.ts b/packages/yukey/src/press-flash.test.ts index fcebb29..8bd7f33 100644 --- a/packages/yukey/src/press-flash.test.ts +++ b/packages/yukey/src/press-flash.test.ts @@ -7,15 +7,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { flashPress, pressMs } from "./press-flash.js"; -import { installTokens, tokenGroups } from "./tokens.js"; +import { defaultTheme } from "./themes/default.js"; +import { installTokens } from "./tokens.js"; installTokens(); -const PRESS_MS = ((): number => { - const t = tokenGroups.flatMap((g) => g.tokens).find((t) => t.name === "--yk-press-ms"); - if (t === undefined) throw new Error("--yk-press-ms token missing"); - return Number.parseFloat(t.value); -})(); +const PRESS_MS = Number.parseFloat(defaultTheme["--yk-press-ms"]); describe("press-flash", () => { beforeEach(() => { diff --git a/packages/yukey/src/themes/default.ts b/packages/yukey/src/themes/default.ts new file mode 100644 index 0000000..c088354 --- /dev/null +++ b/packages/yukey/src/themes/default.ts @@ -0,0 +1,67 @@ +// The kit's default theme: dark, teal-accented, SCADA-console. installTokens() with +// no argument installs this one. +// +// Values only; the names and their roles are the vocabulary in ../tokens.ts. A theme +// supplies every name (the Theme type enforces it) and nothing else. To restyle the +// kit, copy this file, change values, and pass yours to installTokens. +// +// These values are a first palette, not an enshrinement: iterate them freely. The +// `tokens` gallery entry shows whatever theme is installed. + +import type { Theme } from "../tokens.js"; + +export const defaultTheme: Theme = { + "--yk-bg-void": "#05080f", + "--yk-bg-panel-solid": "#0a1220", + "--yk-bg-panel": "color-mix(in srgb, var(--yk-bg-panel-solid) 94%, transparent)", + "--yk-bg-well": "#0d1626", + "--yk-bg-raised": "#101a2c", + "--yk-border": "#3d5578", + "--yk-border-dim": "#1c2a40", + "--yk-border-bright": "#5a7290", + "--yk-text-bright": "#dfe9f5", + "--yk-text-hi": "#a8bdd8", + "--yk-text": "#8194ac", + "--yk-text-dim": "#5a7290", + "--yk-text-faint": "#48566b", + "--yk-accent": "#4f7ba8", + "--yk-accent-bright": "#8fb3d9", + "--yk-wash-pressed": "hsl(from var(--yk-accent) h calc(s * 1.5) calc(l * 1.2) / 0.5)", + "--yk-wash-selected": "color-mix(in srgb, var(--yk-accent) 25%, transparent)", + "--yk-wash-hover": "color-mix(in srgb, var(--yk-accent) 12%, transparent)", + "--yk-wash-faint": "color-mix(in srgb, var(--yk-accent) 6%, transparent)", + "--yk-good": "#8fd4c0", + "--yk-warn": "#c8863c", + "--yk-warn-border": "#6b4a24", + "--yk-warn-wash": "color-mix(in srgb, var(--yk-warn) 13%, transparent)", + "--yk-warn-wash-hover": "color-mix(in srgb, var(--yk-warn) 22%, transparent)", + "--yk-warn-wash-selected": "color-mix(in srgb, var(--yk-warn) 30%, transparent)", + "--yk-danger": "#e8a0a0", + "--yk-danger-border": "#7a4040", + "--yk-hotkey-accent": "#e8c86a", + "--yk-selection": "#d8ecff", + "--yk-focus-ring-color": "#d8ecff", + "--yk-focus-ring-width": "2px", + "--yk-focus-ring-offset": "2px", + "--yk-focus-ring": "var(--yk-focus-ring-width) solid var(--yk-focus-ring-color)", + "--yk-scroll-peek": "28px", + "--yk-press-ms": "140ms", + "--yk-space-xs": "4px", + "--yk-space-sm": "8px", + "--yk-space-md": "12px", + "--yk-space-lg": "16px", + "--yk-space-xl": "24px", + "--yk-space-xxl": "32px", + "--yk-hit-min-h": "24px", + "--yk-hit-min-w": "24px", + "--yk-hit-indicator": "16px", + "--yk-font": "monospace", + "--yk-type-micro": "10px", + "--yk-type-small": "11px", + "--yk-type-ui": "12px", + "--yk-type-body": "13px", + "--yk-type-head": "16px", + "--yk-type-title": "18px", + "--yk-tracking": "0.08em", + "--yk-tracking-wide": "0.12em", +}; diff --git a/packages/yukey/src/tokens.gallery.ts b/packages/yukey/src/tokens.gallery.ts index db9207a..a38583a 100644 --- a/packages/yukey/src/tokens.gallery.ts +++ b/packages/yukey/src/tokens.gallery.ts @@ -1,8 +1,8 @@ // Gallery entry for the design-token vocabulary: the living view of tokens.ts. -// Everything renders from tokenGroups itself, so this page cannot drift from the -// stylesheet -- edit a token value, rebuild, and the swatch, ramp, and demo all -// show the new truth. This page is the iteration venue for the look: change -// values in tokens.ts, watch here. +// Names and roles render from tokenGroups; values are read back from the document's +// computed style, so the page shows whatever theme is installed and cannot drift +// from it. This page is the iteration venue for the look: change a theme value, +// rebuild, watch here. import { css, html, LitElement, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -10,6 +10,11 @@ import type { GalleryEntry } from "./gallery/entry.js"; import "./yk-button.js"; import { type TokenDef, tokenGroups } from "./tokens.js"; +// The installed value of a token, as the page sees it. +function liveValue(name: TokenDef["name"]): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + // One demo element, four faces (palette / spacing / type / focus): the variants // below pick a face. A single element keeps the styles in one block. @customElement("ykg-tokens") @@ -157,7 +162,7 @@ export class YkgTokens extends LitElement { return html`<div class="trow"> <div class="swatch"><div style="background: var(${t.name});"></div></div> <span class="tname">${t.name}</span> - <span class="tvalue">${t.value}</span> + <span class="tvalue">${liveValue(t.name)}</span> <span class="tabout">${t.about}</span> </div>`; } @@ -192,7 +197,7 @@ export class YkgTokens extends LitElement { html`<div class="trow"> <div><div class="bar" style="width: var(${t.name});"></div></div> <span class="tname">${t.name}</span> - <span class="tvalue">${t.value}</span> + <span class="tvalue">${liveValue(t.name)}</span> <span class="tabout">${t.about}</span> </div>`, ) @@ -227,9 +232,9 @@ export class YkgTokens extends LitElement { sizes.map( (t) => html`<div class="trow"> - <span class="tvalue">${t.value}</span> + <span class="tvalue">${liveValue(t.name)}</span> <span class="tname">${t.name}</span> - <span class="specimen" style="font-size: var(${t.name});">Kestrel Reach 74/74</span> + <span class="specimen" style="font-size: var(${t.name});">The quick brown fox 74/74</span> <span class="tabout">${t.about}</span> </div>`, ) @@ -238,10 +243,10 @@ export class YkgTokens extends LitElement { rest.map( (t) => html`<div class="trow"> - <span class="tvalue">${t.value}</span> + <span class="tvalue">${liveValue(t.name)}</span> <span class="tname">${t.name}</span> <span class="specimen" style="letter-spacing: var(${t.name}); text-transform: uppercase;" - >Kestrel Reach</span> + >The quick brown fox</span> <span class="tabout">${t.about}</span> </div>`, ) @@ -286,7 +291,7 @@ export class YkgTokens extends LitElement { motion.tokens.map( (t) => html`<div class="trow"> - <span class="tvalue">${t.value}</span> + <span class="tvalue">${liveValue(t.name)}</span> <span class="tname">${t.name}</span> <span class="tabout">${t.about}</span> </div>`, @@ -309,9 +314,10 @@ const entry: GalleryEntry = { id: "tokens", title: "design tokens", description: "The visual vocabulary everything else is built from: --yk-* custom properties, " - + "rendered live from their definitions in tokens.ts. Names are semantic roles and are " - + "the durable part; values are the iteration surface -- edit tokens.ts, rebuild, " - + "and this page shows the new truth. Intent notes live in docs/precepts-aesthetics.md.", + + "named in tokens.ts and valued by the installed theme (themes/default.ts unless an app " + + "installs its own). Names are semantic roles and are the durable part; values are the " + + "iteration surface -- edit the theme, rebuild, and this page shows the new truth. " + + "Intent notes live in docs/precepts-aesthetics.md.", variants: [ { caption: "The palette, by role. Swatches sit on a checkerboard so translucent washes read as " diff --git a/packages/yukey/src/tokens.test.ts b/packages/yukey/src/tokens.test.ts index 1603312..b672652 100644 --- a/packages/yukey/src/tokens.test.ts +++ b/packages/yukey/src/tokens.test.ts @@ -1,15 +1,19 @@ -// Tests for the token vocabulary's structural invariants. Values are deliberately -// NOT pinned here: they're a v0 seed meant to be iterated hard, and a test that -// breaks on every retint would train people to update it blindly. What must hold -// still is the shape: well-formed names, no duplicates, and a stylesheet that -// actually contains every defined token. +// Tests for the token vocabulary's structural invariants, and that the default theme +// fills it. Theme values are deliberately NOT pinned here: they're a first palette +// meant to be iterated hard, and a test that breaks on every retint would train +// people to update it blindly. What must hold still is the shape: well-formed +// names, no duplicates, and a stylesheet that actually carries every token. import { describe, expect, it } from "vitest"; -import { tokenGroups, tokensCssText } from "./tokens.js"; +import { defaultTheme } from "./themes/default.js"; +import { themeCssText, type TokenDef, type TokenGroup, tokenGroups, type TokenName } from "./tokens.js"; -describe("the token vocabulary", () => { - const all = tokenGroups.flatMap((g) => g.tokens); +// Widened once: the vocabulary is declared `as const` so TokenName can be derived +// from it, and that literal tuple type fights flatMap. +const groups: readonly TokenGroup[] = tokenGroups; +const all: TokenDef[] = groups.flatMap((g) => g.tokens); +describe("the token vocabulary", () => { it("names every token in the --yk- namespace, lowercase-and-dashes only", () => { for (const t of all) { expect(t.name).toMatch(/^--yk-[a-z][a-z0-9-]*$/); @@ -22,12 +26,12 @@ describe("the token vocabulary", () => { }); it("has no duplicate group ids", () => { - const ids = tokenGroups.map((g) => g.id); + const ids = groups.map((g) => g.id); expect(new Set(ids).size).toBe(ids.length); }); it("describes every token and group (the gallery renders these)", () => { - for (const g of tokenGroups) { + for (const g of groups) { expect(g.title).not.toBe(""); expect(g.about).not.toBe(""); } @@ -35,22 +39,31 @@ describe("the token vocabulary", () => { expect(t.about).not.toBe(""); } }); +}); - it("emits every token into the :root stylesheet, values non-empty", () => { - const cssText = tokensCssText(tokenGroups); +describe("the default theme", () => { + it("supplies exactly the vocabulary: every token, nothing extra, no empty values", () => { + const names = all.map((t) => t.name).sort(); + expect(Object.keys(defaultTheme).sort()).toEqual(names); + for (const v of Object.values(defaultTheme)) { + expect(v).not.toBe(""); + } + }); + + it("emits every token into the :root stylesheet", () => { + const cssText = themeCssText(defaultTheme); expect(cssText).toMatch(/^:root \{\n/); expect(cssText).toMatch(/\n\}$/); for (const t of all) { - expect(t.value).not.toBe(""); - expect(cssText).toContain(`${t.name}: ${t.value};`); + expect(cssText).toContain(`${t.name}: ${defaultTheme[t.name as TokenName]};`); } }); it("only references other tokens that exist (var() cycles aside, typos die here)", () => { const names = new Set<string>(all.map((t) => t.name)); - for (const t of all) { - for (const m of t.value.matchAll(/var\((--[a-z0-9-]+)\)/g)) { - expect(names, `${t.name} references ${m[1]}`).toContain(m[1]); + for (const [name, value] of Object.entries(defaultTheme)) { + for (const m of value.matchAll(/var\((--[a-z0-9-]+)\)/g)) { + expect(names, `${name} references ${m[1]}`).toContain(m[1]); } } }); diff --git a/packages/yukey/src/tokens.ts b/packages/yukey/src/tokens.ts index f413485..afb5543 100644 --- a/packages/yukey/src/tokens.ts +++ b/packages/yukey/src/tokens.ts @@ -1,20 +1,20 @@ -// The design-token vocabulary: every color, gap, size, and type choice the UI is -// built from, as CSS custom properties. This file is the SSOT for token names and -// values; docs/precepts-aesthetics.md describes the intent, and the `tokens` gallery -// entry renders this structure live. +// The design-token vocabulary: every color, gap, size, and type choice the kit's +// components are styled with, as CSS custom properties. This file is the SSOT for +// token NAMES and their roles; docs/precepts-aesthetics.md describes the intent, and +// the `tokens` gallery entry renders the vocabulary live. // -// Tokens are defined as data (tokenGroups) and the :root stylesheet is generated -// from it, so the stylesheet and the gallery's living documentation can never -// disagree. Both entry points call installTokens(); custom properties inherit -// through shadow boundaries, so components just write `var(--yk-...)`. +// Values live in a Theme (themes/default.ts is the one the kit ships). The split +// is the point: components write `var(--yk-...)` against names that hold still, +// and an application restyles the whole kit by installing a different theme. +// A Theme is typed against the vocabulary, so a theme missing a token is a compile +// error rather than a silently unstyled element. // -// Naming is the durable part: tokens are named for their semantic role, so values -// can be iterated hard (they will be) while the meaning holds still. The values -// here are a v0 seed extracted from the UI's de facto dark/teal SCADA palette, -// not an enshrinement. +// installTokens(theme) puts the theme's values on :root; custom properties inherit +// through shadow boundaries from there, so that one call is the whole setup. import type { CSSResult } from "lit"; import { unsafeCSS } from "lit"; +import { defaultTheme } from "./themes/default.js"; // What a token's value IS (the W3C design-tokens draft calls this $type). // The gallery picks preview renderings from it; nothing else branches on it. @@ -22,7 +22,6 @@ export type TokenKind = "color" | "size" | "font" | "composite" | "duration"; export interface TokenDef { name: `--yk-${string}`; - value: string; kind: TokenKind; // The role, in a sentence: what kind of thing should use this token. about: string; @@ -34,10 +33,10 @@ export interface TokenGroup { id: string; title: string; about: string; - tokens: TokenDef[]; + tokens: readonly TokenDef[]; } -export const tokenGroups: TokenGroup[] = [ +export const tokenGroups = [ { id: "surfaces", title: "surfaces", @@ -45,31 +44,26 @@ export const tokenGroups: TokenGroup[] = [ tokens: [ { name: "--yk-bg-void", - value: "#05080f", kind: "color", about: "the deepest layer: the page behind everything", }, { name: "--yk-bg-panel-solid", - value: "#0a1220", kind: "color", about: "opaque panel surface: cards, sticky headers, toasts -- anywhere bleed-through would muddy", }, { name: "--yk-bg-panel", - value: "color-mix(in srgb, var(--yk-bg-panel-solid) 94%, transparent)", kind: "color", about: "translucent panel surface: big workspace panels floating over the page", }, { name: "--yk-bg-well", - value: "#0d1626", kind: "color", about: "inset areas within a panel: chart plots, code samples", }, { name: "--yk-bg-raised", - value: "#101a2c", kind: "color", about: "small floating surfaces above panel content: tooltips, popovers", }, @@ -82,19 +76,16 @@ export const tokenGroups: TokenGroup[] = [ tokens: [ { name: "--yk-border", - value: "#3d5578", kind: "color", about: "the standard chrome border: panel edges, buttons, separator bars", }, { name: "--yk-border-dim", - value: "#1c2a40", kind: "color", about: "subtle borders: section rules, chart frames, quiet separations", }, { name: "--yk-border-bright", - value: "#5a7290", kind: "color", about: "emphasized borders: hover states on bordered controls", }, @@ -107,31 +98,26 @@ export const tokenGroups: TokenGroup[] = [ tokens: [ { name: "--yk-text-bright", - value: "#dfe9f5", kind: "color", about: "primary emphasis: names, values, hover text -- what the eye should land on", }, { name: "--yk-text-hi", - value: "#a8bdd8", kind: "color", about: "secondary emphasis: entity names in rows, highlighted prose", }, { name: "--yk-text", - value: "#8194ac", kind: "color", about: "body text: the default reading color", }, { name: "--yk-text-dim", - value: "#5a7290", kind: "color", about: "dim text: labels, section headings, quiet chrome", }, { name: "--yk-text-faint", - value: "#48566b", kind: "color", about: "faint text: ids, empty states, the almost-not-there tier", }, @@ -145,19 +131,16 @@ export const tokenGroups: TokenGroup[] = [ tokens: [ { name: "--yk-accent", - value: "#4f7ba8", kind: "color", about: "the interactive blue: meter fills, checkboxes, chart lines, attention borders", }, { name: "--yk-accent-bright", - value: "#8fb3d9", kind: "color", about: "the accent as text: app title, score, links", }, { name: "--yk-wash-pressed", - value: "hsl(from var(--yk-accent) h calc(s * 1.5) calc(l * 1.2) / 0.5)", kind: "color", about: "background wash for a control being activated: held under a pointer, " + "or flashing for --yk-press-ms after triggering. Deliberately more saturated " @@ -165,19 +148,16 @@ export const tokenGroups: TokenGroup[] = [ }, { name: "--yk-wash-selected", - value: "color-mix(in srgb, var(--yk-accent) 25%, transparent)", kind: "color", about: "background wash for the selected row / the toggled-on button", }, { name: "--yk-wash-hover", - value: "color-mix(in srgb, var(--yk-accent) 12%, transparent)", kind: "color", about: "background wash for the hovered row", }, { name: "--yk-wash-faint", - value: "color-mix(in srgb, var(--yk-accent) 6%, transparent)", kind: "color", about: "the quietest wash: help strips, gently set-off regions", }, @@ -190,49 +170,41 @@ export const tokenGroups: TokenGroup[] = [ tokens: [ { name: "--yk-good", - value: "#8fd4c0", kind: "color", about: "the working-economy teal: producing, positive flow, promises of movement", }, { name: "--yk-warn", - value: "#c8863c", kind: "color", about: "the amber alert: rejected commands, blocked production, negative flow, emergencies", }, { name: "--yk-warn-border", - value: "#6b4a24", kind: "color", about: "border for amber-alert surfaces (the rejection toast)", }, { name: "--yk-warn-wash", - value: "color-mix(in srgb, var(--yk-warn) 13%, transparent)", kind: "color", about: "background wash for rows in an emergency state", }, { name: "--yk-warn-wash-hover", - value: "color-mix(in srgb, var(--yk-warn) 22%, transparent)", kind: "color", about: "hover wash for emergency rows", }, { name: "--yk-warn-wash-selected", - value: "color-mix(in srgb, var(--yk-warn) 30%, transparent)", kind: "color", about: "selected wash for emergency rows", }, { name: "--yk-danger", - value: "#e8a0a0", kind: "color", about: "the red alert: no connection, destructive controls (the row-remove x)", }, { name: "--yk-danger-border", - value: "#7a4040", kind: "color", about: "border for red-alert surfaces (the no-connection toast)", }, @@ -246,44 +218,37 @@ export const tokenGroups: TokenGroup[] = [ tokens: [ { name: "--yk-hotkey-accent", - value: "#e8c86a", kind: "color", about: "the significant color for hotkey marking: the letter-in-label tint, the 70%-offset hotkey slot. " + "Deliberately the only gold in the palette: hotkeys must pop", }, { name: "--yk-selection", - value: "#d8ecff", kind: "color", about: "the selection marker: a dashed ring around the selected entity", }, { name: "--yk-focus-ring-color", - value: "#d8ecff", kind: "color", about: "the keyboard focus ring: same where-you-are family as selection, drawn solid", }, { name: "--yk-focus-ring-width", - value: "2px", kind: "size", about: "focus ring stroke width", }, { name: "--yk-focus-ring-offset", - value: "2px", kind: "size", about: "gap between a focused element and its ring (pair with outline-offset)", }, { name: "--yk-focus-ring", - value: "var(--yk-focus-ring-width) solid var(--yk-focus-ring-color)", kind: "composite", about: "the whole ring, ready for `outline:`", }, { name: "--yk-scroll-peek", - value: "28px", kind: "size", about: "how much neighbor stays visible past the focused row when navigation scrolls: " + "about one row, so the next row is always in view and a scroller's edge is " @@ -299,11 +264,10 @@ export const tokenGroups: TokenGroup[] = [ tokens: [ { name: "--yk-press-ms", - value: "140ms", kind: "duration", about: "how long the activation flash lingers on a triggered control. " + "Below ~100ms a flash risks being missed outright; 180ms already reads " - + "as sluggish. Tuned by hand at the 2026-08-23 playtest", + + "as sluggish", }, ], }, @@ -315,30 +279,26 @@ export const tokenGroups: TokenGroup[] = [ tokens: [ { name: "--yk-space-xs", - value: "4px", kind: "size", about: "within a cluster: icon-to-label", }, { name: "--yk-space-sm", - value: "8px", kind: "size", about: "between related elements: button rows", }, { name: "--yk-space-md", - value: "12px", kind: "size", about: "panel edge padding, roomier relations", }, { name: "--yk-space-lg", - value: "16px", kind: "size", about: "between groups within a section", }, - { name: "--yk-space-xl", value: "24px", kind: "size", about: "between sections" }, - { name: "--yk-space-xxl", value: "32px", kind: "size", about: "the biggest deliberate gap" }, + { name: "--yk-space-xl", kind: "size", about: "between sections" }, + { name: "--yk-space-xxl", kind: "size", about: "the biggest deliberate gap" }, ], }, { @@ -349,19 +309,16 @@ export const tokenGroups: TokenGroup[] = [ tokens: [ { name: "--yk-hit-min-h", - value: "24px", kind: "size", about: "minimum height of anything interactable", }, { name: "--yk-hit-min-w", - value: "24px", kind: "size", about: "minimum width of anything interactable (icon-only buttons, checkboxes)", }, { name: "--yk-hit-indicator", - value: "16px", kind: "size", about: "minimum square for a control's VISIBLE state indicator (checkbox, disclosure, sort caret): drawn geometry sized by this, never a bare text glyph", @@ -373,69 +330,74 @@ export const tokenGroups: TokenGroup[] = [ title: "type", about: "Monospace-heavy by design: this is a SCADA console, not a magazine.", tokens: [ - { name: "--yk-font", value: "monospace", kind: "font", about: "the UI font stack" }, - { name: "--yk-type-micro", value: "10px", kind: "size", about: "column headers, overlines" }, + { name: "--yk-font", kind: "font", about: "the UI font stack" }, + { name: "--yk-type-micro", kind: "size", about: "column headers, overlines" }, { name: "--yk-type-small", - value: "11px", kind: "size", about: "fine print: ids, section headings", }, { name: "--yk-type-ui", - value: "12px", kind: "size", about: "dense chrome: bars, tables, panels", }, { name: "--yk-type-body", - value: "13px", kind: "size", about: "reading surfaces: cards, prose", }, - { name: "--yk-type-head", value: "16px", kind: "size", about: "page and panel headings" }, - { name: "--yk-type-title", value: "18px", kind: "size", about: "the biggest: card names" }, + { name: "--yk-type-head", kind: "size", about: "page and panel headings" }, + { name: "--yk-type-title", kind: "size", about: "the biggest: card names" }, { name: "--yk-tracking", - value: "0.08em", kind: "size", about: "letterspacing for emphasized titles at text size", }, { name: "--yk-tracking-wide", - value: "0.12em", kind: "size", about: "letterspacing for uppercase overlines (section headings, app title)", }, ], }, -]; +] as const satisfies readonly TokenGroup[]; -// Renders the groups into one `:root { ... }` block, group titles as comments. -// Exported for the unit test; everything else should use `tokens` or installTokens. -export function tokensCssText(groups: TokenGroup[]): string { +// Every token name in the vocabulary, as a type. Derived from tokenGroups, so adding +// a token there is the one edit; every Theme then has to supply it. +export type TokenName = (typeof tokenGroups)[number]["tokens"][number]["name"]; + +// A complete set of values for the vocabulary: one CSS value per token name. +export type Theme = Readonly<Record<TokenName, string>>; + +// Renders a theme into one `:root { ... }` block, in vocabulary order, group titles as comments. +// Exported for the unit test; everything else should use installTokens. +export function themeCssText(theme: Theme, groups: readonly TokenGroup[] = tokenGroups): string { const lines: string[] = [":root {"]; for (const group of groups) { lines.push(`\t/* ${group.title} */`); for (const t of group.tokens) { - lines.push(`\t${t.name}: ${t.value};`); + lines.push(`\t${t.name}: ${theme[t.name as TokenName]};`); } } lines.push("}"); return lines.join("\n"); } -// unsafeCSS is safe here: the text is generated from the literal definitions in this -// file, never from runtime data. -export const tokens: CSSResult = unsafeCSS(tokensCssText(tokenGroups)); +// A theme as a lit CSSResult, for callers that compose stylesheets by hand. +// unsafeCSS is safe here: the text is generated from the vocabulary and the theme's +// literal values, never from runtime data. +export function themeStyles(theme: Theme): CSSResult { + return unsafeCSS(themeCssText(theme)); +} -// Installs the token definitions document-wide (a <style> in head). Custom -// properties inherit into every shadow root from there. Idempotent: entry points -// call it once, but a second call is harmless. -export function installTokens(doc: Document = document): void { +// Installs a theme document-wide (a <style> in head). Custom properties inherit +// into every shadow root from there. With no argument, the kit's default theme. +// Idempotent: a second call is harmless and leaves the first theme in place. +export function installTokens(theme: Theme = defaultTheme, doc: Document = document): void { if (doc.head.querySelector("style[data-yk-tokens]") !== null) return; const style = doc.createElement("style"); style.setAttribute("data-yk-tokens", ""); - style.textContent = tokens.cssText; + style.textContent = themeCssText(theme); doc.head.append(style); } diff --git a/packages/yukey/src/yk-button.test.ts b/packages/yukey/src/yk-button.test.ts index 43f7653..f21deeb 100644 --- a/packages/yukey/src/yk-button.test.ts +++ b/packages/yukey/src/yk-button.test.ts @@ -9,7 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MegacolumnController } from "./megacolumn-controller.js"; import type { YkButton } from "./yk-button.js"; import "./yk-button.js"; -import { installTokens, tokenGroups } from "./tokens.js"; +import { defaultTheme } from "./themes/default.js"; +import { installTokens } from "./tokens.js"; import type { YkInfoDetail } from "./yk-info.js"; // jsdom implements no scrollIntoView; the controller scrolls the focused row @@ -22,11 +23,7 @@ installTokens(); // The token's defined default, parsed here rather than imported from // press-flash.ts: the tests must not derive expectations from the code under test. -const PRESS_MS = ((): number => { - const t = tokenGroups.flatMap((g) => g.tokens).find((t) => t.name === "--yk-press-ms"); - if (t === undefined) throw new Error("--yk-press-ms token missing"); - return Number.parseFloat(t.value); -})(); +const PRESS_MS = Number.parseFloat(defaultTheme["--yk-press-ms"]); function makeButton(attrs: Partial<YkButton>): YkButton { const el = document.createElement("yk-button");