yukey

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");