tokens: names are the kit's vocabulary; values are a theme, and the default theme ships
commit bd28b64990644f55119c3b4bd99790798eb87d06 · parent f48fd5bcf8
· browse this tree
Ximianth, 2026-10-10 06:13:43 UTC
tokens.ts keeps every token's name, kind, and role. Values move to themes/default.ts. The Theme type is derived from the vocabulary (a const tuple of groups), so a theme that misses a token fails to compile; checked by hand with a theme that drops --yk-accent. installTokens() with no argument installs the default theme, so the one setup line consumers already write is unchanged. installTokens(theme) swaps the look wholesale. The tokens gallery entry used to print values from the definitions; it now reads them back from the document's computed style, so it shows whatever theme is installed. Checked in a browser at 2000x1100: values render and the palette is intact.
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");