@timmo001/groundwork-tokens 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,648 @@
1
+ /**
2
+ * Writes the solved themes out to every target in `generated/`: CSS, TS,
3
+ * JSON, QML, OpenTUI and ANSI.
4
+ *
5
+ * @module
6
+ */
7
+ import { toCss, toHex } from "./color.ts";
8
+ import type { Font, MonoFont, Style } from "./schema.ts";
9
+ import {
10
+ check,
11
+ modes,
12
+ statuses,
13
+ type Check,
14
+ type Mode,
15
+ type ResolvedTheme,
16
+ type Roles,
17
+ } from "./solve.ts";
18
+
19
+ const header = "Generated by `mise run gen` from packages/tokens. Don't edit.";
20
+
21
+ const fontStack = (font: Font | MonoFont) => {
22
+ if (font === "System UI") {
23
+ return 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
24
+ }
25
+
26
+ const generic = font.endsWith("Mono")
27
+ ? "ui-monospace, monospace"
28
+ : "system-ui, sans-serif";
29
+
30
+ return `"${font} Variable", "${font}", ${generic}`;
31
+ };
32
+
33
+ const density = { compact: 0.85, normal: 1, roomy: 1.2 } as const;
34
+
35
+ const borderColor = {
36
+ none: "transparent",
37
+ hairline: "var(--gw-color-border-quiet)",
38
+ soft: "var(--gw-color-border-normal)",
39
+ strong: "var(--gw-color-border-loud)",
40
+ } as const;
41
+
42
+ /** The style as `--gw-*` properties, so components never branch on a theme. */
43
+ const styleProperties = (style: Style) => ({
44
+ "font-sans": fontStack(style.font),
45
+ "font-figures": fontStack(style.figures ?? style.font),
46
+ "font-mono": fontStack(style.mono),
47
+ "font-weight-regular": String(style.weights.regular),
48
+ "font-weight-strong": String(style.weights.strong),
49
+ density: String(density[style.density]),
50
+ ...spacing,
51
+ "radius-panel": `${style.radius.panel}px`,
52
+ "radius-control": `${style.radius.control}px`,
53
+ "radius-chip": `${style.radius.chip}px`,
54
+ "border-width":
55
+ style.border.strength === "none" ? "0px" : `${style.border.width}px`,
56
+ "border-color": borderColor[style.border.strength],
57
+ "label-transform": style.capitals ? "uppercase" : "none",
58
+ "label-tracking": style.capitals ? "0.06em" : "normal",
59
+ "link-color": "var(--gw-color-link)",
60
+ "link-underline": style.link.underline === "thick" ? "2px" : "1px",
61
+ "panel-sheen":
62
+ style.surfaceAccent === "sheen"
63
+ ? "linear-gradient(160deg, color-mix(in oklch, var(--gw-color-accent) 14%, transparent), transparent 55%)"
64
+ : "none",
65
+ "panel-marker": style.surfaceAccent === "marker" ? "4px" : "0px",
66
+ "panel-flange": style.surfaceAccent === "flange" ? "3px" : "0px",
67
+ "panel-flange-color":
68
+ style.surfaceAccent === "flange"
69
+ ? "var(--gw-color-accent)"
70
+ : "var(--gw-border-color)",
71
+ "row-padding":
72
+ style.rows === "dense"
73
+ ? "calc(var(--gw-space-1) * 1.5)"
74
+ : "var(--gw-space-3)",
75
+ });
76
+
77
+ const shadows = (dark: boolean) => {
78
+ const ink = dark ? "0 0 0" : "16 20 28";
79
+ const strength = dark ? 3 : 1;
80
+
81
+ return {
82
+ "shadow-s": `0 1px 2px rgb(${ink} / ${0.06 * strength})`,
83
+ "shadow-m": `0 4px 12px rgb(${ink} / ${0.08 * strength})`,
84
+ "shadow-l": `0 12px 32px rgb(${ink} / ${0.12 * strength})`,
85
+ };
86
+ };
87
+
88
+ /**
89
+ * Spacing scales with the theme's density. A custom property's `var()`
90
+ * resolves where it's declared, so the scale is declared again in every
91
+ * theme beside its density, not only on `:root`.
92
+ */
93
+ const spacing = {
94
+ // Home Assistant's scale: 4px steps, space-1 to space-20, plus a half step
95
+ "space-0-5": "calc(0.125rem * var(--gw-density))",
96
+ ...Object.fromEntries(
97
+ Array.from({ length: 20 }, (_, index) => [
98
+ `space-${index + 1}`,
99
+ `calc(${(index + 1) * 0.25}rem * var(--gw-density))`,
100
+ ]),
101
+ ),
102
+ };
103
+
104
+ const foundations = {
105
+ density: "1",
106
+ ...spacing,
107
+ "font-size-multiplier": "1",
108
+ "font-size-2xs": "calc(0.6875rem * var(--gw-font-size-multiplier))",
109
+ "font-size-xs": "calc(0.75rem * var(--gw-font-size-multiplier))",
110
+ "font-size-s": "calc(0.875rem * var(--gw-font-size-multiplier))",
111
+ "font-size-m": "calc(1rem * var(--gw-font-size-multiplier))",
112
+ "font-size-l": "calc(1.125rem * var(--gw-font-size-multiplier))",
113
+ "font-size-xl": "calc(1.375rem * var(--gw-font-size-multiplier))",
114
+ "font-size-2xl": "calc(1.75rem * var(--gw-font-size-multiplier))",
115
+ "font-size-3xl": "calc(2.125rem * var(--gw-font-size-multiplier))",
116
+ "font-size-4xl": "calc(2.5rem * var(--gw-font-size-multiplier))",
117
+ "radius-xs": "2px",
118
+ "radius-s": "4px",
119
+ "radius-m": "8px",
120
+ "radius-l": "12px",
121
+ "radius-xl": "16px",
122
+ "radius-pill": "999px",
123
+ // Home Assistant's animation durations
124
+ "motion-instant": "75ms",
125
+ "motion-fast": "150ms",
126
+ "motion-normal": "250ms",
127
+ "motion-slow": "350ms",
128
+ "motion-easing": "cubic-bezier(0.2, 0, 0, 1)",
129
+ "focus-width": "2px",
130
+ "focus-offset": "2px",
131
+ "content-width": "72rem",
132
+ "content-width-narrow": "44rem",
133
+ };
134
+
135
+ const declarations = (properties: Record<string, string>, indent = " ") =>
136
+ Object.entries(properties)
137
+ .map(([name, value]) => `${indent}--gw-${name}: ${value};`)
138
+ .join("\n");
139
+
140
+ const colorProperties = (roles: Roles) =>
141
+ Object.fromEntries(
142
+ Object.entries(roles).map(([role, color]) => [
143
+ `color-${role}`,
144
+ toCss(color),
145
+ ]),
146
+ );
147
+
148
+ const modeBlock = (roles: Roles, dark: boolean, indent = " ") =>
149
+ [
150
+ `${indent}color-scheme: ${dark ? "dark" : "light"};`,
151
+ declarations({ ...colorProperties(roles), ...shadows(dark) }, indent),
152
+ ].join("\n");
153
+
154
+ /** One theme's `--gw-*` rules under `[data-gw-theme]`, in each of its modes. */
155
+ export const themeCss = (theme: ResolvedTheme) => {
156
+ const selector = `[data-gw-theme="${theme.id}"]`;
157
+ const style = `${selector} {\n${declarations(styleProperties(theme.style))}\n}`;
158
+ const { light, dark, darker } = theme.modes;
159
+ const blocks = [style];
160
+
161
+ if (light !== undefined) {
162
+ blocks.push(`${selector} {\n${modeBlock(light, false)}\n}`);
163
+ } else if (dark !== undefined) {
164
+ blocks.push(`${selector} {\n${modeBlock(dark, true)}\n}`);
165
+ }
166
+
167
+ if (dark !== undefined) {
168
+ blocks.push(
169
+ `${selector}[data-gw-mode="dark"] {\n${modeBlock(dark, true)}\n}`,
170
+ );
171
+
172
+ if (light !== undefined) {
173
+ blocks.push(
174
+ `@media (prefers-color-scheme: dark) {\n ${selector}:not([data-gw-mode]) {\n${modeBlock(dark, true, " ")}\n }\n}`,
175
+ );
176
+ }
177
+ }
178
+
179
+ if (darker !== undefined) {
180
+ blocks.push(
181
+ `${selector}[data-gw-mode="darker"] {\n${modeBlock(darker, true)}\n}`,
182
+ );
183
+ }
184
+
185
+ return blocks.join("\n\n");
186
+ };
187
+
188
+ /**
189
+ * What each `gw-` element looks like before its script defines it: the
190
+ * display and block size it will have, so nothing moves when it upgrades.
191
+ */
192
+ const preUpgrade = {
193
+ "gw-fade-in": { opacity: "0" },
194
+ "gw-button, gw-menu-button, gw-segmented": {
195
+ display: "inline-block",
196
+ "min-block-size": "2.5rem",
197
+ "vertical-align": "middle",
198
+ },
199
+ "gw-button": {
200
+ "padding-inline": "calc(var(--gw-space-4) + 1px)",
201
+ "font-size": "var(--gw-font-size-s)",
202
+ "font-weight": "var(--gw-font-weight-strong)",
203
+ "line-height": "2.5rem",
204
+ "white-space": "nowrap",
205
+ "text-transform": "var(--gw-label-transform)",
206
+ "letter-spacing": "var(--gw-label-tracking)",
207
+ },
208
+ 'gw-button[size="xs"]': {
209
+ "min-block-size": "1.5rem",
210
+ "line-height": "1.5rem",
211
+ },
212
+ 'gw-button[size="small"]': {
213
+ "min-block-size": "2rem",
214
+ "line-height": "2rem",
215
+ },
216
+ 'gw-button[size="large"]': {
217
+ "min-block-size": "3rem",
218
+ "line-height": "3rem",
219
+ },
220
+ 'gw-button[size="xl"]': {
221
+ "min-block-size": "3.5rem",
222
+ "line-height": "3.5rem",
223
+ },
224
+ "gw-card": {
225
+ display: "block",
226
+ padding: "var(--gw-space-4)",
227
+ border: "var(--gw-border-width) solid transparent",
228
+ },
229
+ "gw-card[heading]": {
230
+ "padding-block-start":
231
+ "calc(var(--gw-space-4) + 1.5rem + var(--gw-space-3))",
232
+ },
233
+ "gw-page-header": { display: "flex", "min-block-size": "3rem" },
234
+ "gw-page-header[description]": { "min-block-size": "3.6875rem" },
235
+ "gw-section-title": { display: "flex", "align-items": "center" },
236
+ "gw-button[icon]": {
237
+ "padding-inline-start":
238
+ "calc(var(--gw-space-4) + var(--gw-space-2) + 1.125rem + 1px)",
239
+ },
240
+ 'gw-button:not(:defined) > [slot="icon"]': {
241
+ "inline-size": "1.125rem",
242
+ "block-size": "1.125rem",
243
+ "margin-inline-end": "var(--gw-space-2)",
244
+ "vertical-align": "middle",
245
+ },
246
+ "gw-chip": { display: "inline-flex", "min-block-size": "2rem" },
247
+ "gw-badge": { display: "inline-flex", "min-block-size": "2.25rem" },
248
+ 'gw-badge[size="small"]': { "min-block-size": "1.75rem" },
249
+ "gw-dot": {
250
+ display: "inline-block",
251
+ "inline-size": "0.5rem",
252
+ "min-block-size": "0.5rem",
253
+ },
254
+ "gw-meter": { display: "block", "min-block-size": "2.25rem" },
255
+ "gw-gauge": {
256
+ display: "inline-block",
257
+ "inline-size": "8.5rem",
258
+ "min-block-size": "9.625rem",
259
+ },
260
+ "gw-alert": { display: "block", "min-block-size": "2.5rem" },
261
+ "gw-spinner, gw-skeleton-icon": {
262
+ display: "inline-flex",
263
+ "min-block-size": "1.25rem",
264
+ },
265
+ "gw-input, gw-select": { display: "flex", "min-block-size": "4.5rem" },
266
+ "gw-input:is([hint], [error]), gw-select:is([hint], [error])": {
267
+ "min-block-size": "6.25rem",
268
+ },
269
+ "gw-select:is([label-hidden], [inline-label])": {
270
+ "min-block-size": "2.5rem",
271
+ },
272
+ "gw-select[inline-label]": { display: "inline-flex" },
273
+ "gw-checkbox, gw-switch": { display: "flex", "min-block-size": "1.5rem" },
274
+ "gw-stack": {
275
+ display: "flex",
276
+ "flex-direction": "column",
277
+ gap: "var(--gw-space-3)",
278
+ },
279
+ "gw-row": {
280
+ display: "flex",
281
+ "flex-wrap": "wrap",
282
+ "align-items": "center",
283
+ gap: "var(--gw-space-2)",
284
+ },
285
+ "gw-grid": {
286
+ display: "grid",
287
+ "grid-template-columns":
288
+ "repeat(auto-fill, minmax(min(100%, var(--gw-grid-min, 18rem)), 1fr))",
289
+ gap: "var(--gw-space-3)",
290
+ },
291
+ "gw-dialog, gw-popover, gw-command-menu, gw-toaster, gw-option-list": {
292
+ display: "none",
293
+ },
294
+ };
295
+
296
+ const preUpgradeCss = Object.entries(preUpgrade)
297
+ .map(([selectors, properties]) => {
298
+ const selector = selectors
299
+ .split(", ")
300
+ .map((item) =>
301
+ item.includes(":not(:defined)") ? item : `${item}:not(:defined)`,
302
+ )
303
+ .join(",\n");
304
+
305
+ const body = Object.entries(properties)
306
+ .map(([name, value]) => ` ${name}: ${value};`)
307
+ .join("\n");
308
+
309
+ return `${selector} {\n${body}\n}`;
310
+ })
311
+ .join("\n\n");
312
+
313
+ export const css = (themes: readonly ResolvedTheme[]) =>
314
+ [
315
+ `/* ${header} */`,
316
+ `:root {\n${declarations(foundations)}\n}`,
317
+ `@media (prefers-reduced-motion: reduce) {\n :root {\n --gw-motion-instant: 1ms;\n --gw-motion-fast: 1ms;\n --gw-motion-normal: 1ms;\n --gw-motion-slow: 1ms;\n }\n}`,
318
+ preUpgradeCss,
319
+ ...themes.map(themeCss),
320
+ ].join("\n\n") + "\n";
321
+
322
+ /** Each Blume color token and the role it takes. */
323
+ const blumeRoles = {
324
+ background: "surface-default",
325
+ foreground: "text-primary",
326
+ muted: "surface-low",
327
+ "muted-foreground": "text-secondary",
328
+ border: "border-normal",
329
+ accent: "accent",
330
+ "accent-foreground": "on-accent",
331
+ action: "fill-primary-resting",
332
+ "action-foreground": "on-primary",
333
+ "code-background": "surface-raised",
334
+ "code-highlight": "fill-info-quiet",
335
+ "code-highlight-border": "border-info",
336
+ "code-add": "fill-success-quiet",
337
+ "code-add-border": "border-success",
338
+ "code-remove": "fill-danger-quiet",
339
+ "code-remove-border": "border-danger",
340
+ "code-error": "fill-danger-quiet",
341
+ "code-error-border": "border-danger",
342
+ "code-warning": "fill-warning-quiet",
343
+ "code-warning-border": "border-warning",
344
+ "code-word": "fill-info-quiet",
345
+ "code-word-border": "border-info",
346
+ };
347
+
348
+ /** Blume's color tokens from a theme's roles in one mode. */
349
+ const blumeColors = (roles: Roles) =>
350
+ Object.fromEntries(
351
+ Object.entries(blumeRoles).flatMap(([token, role]) => {
352
+ const color = roles[role];
353
+
354
+ return color === undefined ? [] : [[token, toCss(color)]];
355
+ }),
356
+ );
357
+
358
+ const blumeDeclarations = (properties: Record<string, string>) =>
359
+ Object.entries(properties)
360
+ .map(([name, value]) => ` --blume-${name}: ${value};`)
361
+ .join("\n");
362
+
363
+ /**
364
+ * A theme's Blume rules under one root selector: light on the root, dark on
365
+ * Blume's `data-theme="dark"`, and Darker when `data-gw-darker` is set too.
366
+ */
367
+ const blumeRules = (theme: ResolvedTheme, root: string) => {
368
+ const { light, dark, darker } = theme.modes;
369
+
370
+ const style = {
371
+ radius: `${theme.style.radius.panel}px`,
372
+ "font-body": fontStack(theme.style.font),
373
+ "font-display": fontStack(theme.style.font),
374
+ "font-mono": fontStack(theme.style.mono),
375
+ };
376
+
377
+ const lightTokens: Record<string, string> =
378
+ light === undefined ? style : { ...style, ...blumeColors(light) };
379
+
380
+ const rules = [`${root} {\n${blumeDeclarations(lightTokens)}\n}`];
381
+
382
+ if (dark !== undefined) {
383
+ rules.push(
384
+ `${root}[data-theme="dark"] {\n${blumeDeclarations(blumeColors(dark))}\n}`,
385
+ );
386
+ }
387
+
388
+ if (darker !== undefined) {
389
+ rules.push(
390
+ `${root}[data-theme="dark"][data-gw-darker] {\n${blumeDeclarations(blumeColors(darker))}\n}`,
391
+ );
392
+ }
393
+
394
+ return rules;
395
+ };
396
+
397
+ /**
398
+ * One theme as Blume's tokens, for docs-kit. Fonts are stacks, so the site
399
+ * loads the files.
400
+ */
401
+ export const blumeTheme = (theme: ResolvedTheme) =>
402
+ [
403
+ `/* ${header} */`,
404
+ `/* ${theme.name}: ${theme.description} */`,
405
+ ...blumeRules(theme, ":root"),
406
+ ].join("\n\n") + "\n";
407
+
408
+ /**
409
+ * Every core theme as Blume's tokens, each under `data-gw-docs-theme`, for a
410
+ * site that lets readers pick one with the theme picker.
411
+ */
412
+ export const blumeThemes = (themes: readonly ResolvedTheme[]) =>
413
+ [
414
+ `/* ${header} */`,
415
+ ...themes
416
+ .filter((theme) => !theme.optional)
417
+ .flatMap((theme) =>
418
+ blumeRules(theme, `:root[data-gw-docs-theme="${theme.id}"]`),
419
+ ),
420
+ ].join("\n\n") + "\n";
421
+
422
+ const hexRoles = (roles: Roles) =>
423
+ Object.fromEntries(
424
+ Object.entries(roles).map(([role, color]) => [role, toHex(color)]),
425
+ );
426
+
427
+ const hexModes = (theme: ResolvedTheme) =>
428
+ Object.fromEntries(
429
+ modes.flatMap((mode) => {
430
+ const roles = theme.modes[mode];
431
+
432
+ return roles === undefined ? [] : [[mode, hexRoles(roles)]];
433
+ }),
434
+ );
435
+
436
+ const data = (themes: readonly ResolvedTheme[]) => ({
437
+ themes: themes.map((theme) => ({
438
+ id: theme.id,
439
+ name: theme.name,
440
+ description: theme.description,
441
+ optional: theme.optional,
442
+ family: theme.family ?? null,
443
+ examples: theme.examples,
444
+ style: theme.style,
445
+ properties: styleProperties(theme.style),
446
+ modes: hexModes(theme),
447
+ })),
448
+ });
449
+
450
+ export const json = (themes: readonly ResolvedTheme[]) =>
451
+ `${JSON.stringify(data(themes), null, 2)}\n`;
452
+
453
+ export const typescript = (themes: readonly ResolvedTheme[]) =>
454
+ [
455
+ `// ${header}`,
456
+ "",
457
+ `export const themes = ${JSON.stringify(data(themes).themes, null, 2)} as const;`,
458
+ "",
459
+ 'export type ThemeId = (typeof themes)[number]["id"];',
460
+ "",
461
+ ].join("\n");
462
+
463
+ export const qml = (themes: readonly ResolvedTheme[]) =>
464
+ [
465
+ `// ${header}`,
466
+ "pragma Singleton",
467
+ "import QtQuick",
468
+ "",
469
+ "QtObject {",
470
+ ` readonly property var themes: (${JSON.stringify(
471
+ Object.fromEntries(themes.map((theme) => [theme.id, hexModes(theme)])),
472
+ )})`,
473
+ "}",
474
+ "",
475
+ ].join("\n");
476
+
477
+ /** Resolved colors for OpenTUI, which takes hex strings. */
478
+ export const opentui = (themes: readonly ResolvedTheme[]) =>
479
+ [
480
+ `// ${header}`,
481
+ "",
482
+ `export const opentuiThemes = ${JSON.stringify(
483
+ Object.fromEntries(themes.map((theme) => [theme.id, hexModes(theme)])),
484
+ null,
485
+ 2,
486
+ )} as const;`,
487
+ "",
488
+ ].join("\n");
489
+
490
+ /**
491
+ * Plain CLI output follows the terminal: roles map onto the 16 ANSI colors,
492
+ * so the terminal's own theme decides how they look.
493
+ */
494
+ export const ansi = () =>
495
+ `${JSON.stringify(
496
+ {
497
+ $comment: header,
498
+ roles: {
499
+ "text-primary": { name: "default", index: null },
500
+ "text-strong": { name: "default", index: null, bold: true },
501
+ "text-secondary": { name: "brightBlack", index: 8 },
502
+ accent: { name: "cyan", index: 6 },
503
+ info: { name: "blue", index: 4 },
504
+ success: { name: "green", index: 2 },
505
+ important: { name: "magenta", index: 5 },
506
+ warning: { name: "yellow", index: 3 },
507
+ danger: { name: "red", index: 1 },
508
+ },
509
+ noColor: "Plain text when NO_COLOR is set or stdout isn't a terminal.",
510
+ },
511
+ null,
512
+ 2,
513
+ )}\n`;
514
+
515
+ const modeLabel: Record<Mode, string> = {
516
+ light: "Light",
517
+ dark: "Dark",
518
+ darker: "Darker",
519
+ };
520
+
521
+ const roleLabel = (role: string) =>
522
+ statuses.some((status) => status === role) ? `Status: ${role}` : role;
523
+
524
+ /** The contrast report as a docs page, one table per theme. */
525
+ export const contrastPage = (themes: readonly ResolvedTheme[]) => {
526
+ const sections = themes.map((theme) => {
527
+ const results = check(theme);
528
+
529
+ const rows = [...new Set(results.map((result) => result.role))].map(
530
+ (role) => {
531
+ const cells = modes.map((mode) => {
532
+ const result = results.find(
533
+ (item: Check) => item.role === role && item.mode === mode,
534
+ );
535
+
536
+ return result === undefined
537
+ ? "n/a"
538
+ : `${result.ratio.toFixed(2)}${result.passes ? "" : " ✗"}`;
539
+ });
540
+
541
+ const target = results.find((item) => item.role === role)?.target ?? 0;
542
+
543
+ return `| ${roleLabel(role)} | ${target} | ${cells.join(" | ")} |`;
544
+ },
545
+ );
546
+
547
+ return [
548
+ `## ${theme.name}`,
549
+ "",
550
+ theme.optional
551
+ ? "Optional. Mapped from its own palette, so it's reported rather than held to the targets."
552
+ : "",
553
+ "",
554
+ `| Role | Target | ${modes.map((mode) => modeLabel[mode]).join(" | ")} |`,
555
+ "| --- | --- | --- | --- | --- |",
556
+ ...rows,
557
+ ]
558
+ .filter((line, index, lines) => line !== "" || lines[index - 1] !== "")
559
+ .join("\n");
560
+ });
561
+
562
+ return [
563
+ "---",
564
+ "title: Contrast report",
565
+ "description: Every theme role measured against its contrast target on the raised surface, in light, dark and Darker.",
566
+ "---",
567
+ "",
568
+ `<!-- ${header} -->`,
569
+ "",
570
+ "Each role is measured with the WCAG 2 ratio against the raised surface, the lowest-contrast surface text sits on. `mise run check` fails when a core theme misses a target; ✗ marks a miss.",
571
+ "",
572
+ ...sections.flatMap((section) => [section, ""]),
573
+ ].join("\n");
574
+ };
575
+
576
+ const styleSummary = (style: Style) => [
577
+ `| Font | ${style.font}${style.figures === undefined ? "" : `, with ${style.figures} figures`} |`,
578
+ `| Code | ${style.mono} |`,
579
+ `| Radius | Panels ${style.radius.panel}px, controls ${style.radius.control === 999 ? "pill" : `${style.radius.control}px`}, chips ${style.radius.chip === 999 ? "pill" : `${style.radius.chip}px`} |`,
580
+ `| Borders | ${style.border.strength === "none" ? "None" : `${style.border.width}px, ${style.border.strength}`} |`,
581
+ `| Weights | ${style.weights.regular} and ${style.weights.strong} |`,
582
+ `| Density | ${style.density}${style.rows === "dense" ? ", lists as dense rows" : ""} |`,
583
+ `| Labels | ${style.capitals ? "Capitals" : "Sentence case"} |`,
584
+ `| Links | ${style.link.color === "accent" ? "Accent" : "Strong text"}, ${style.link.underline} underline |`,
585
+ `| Surface accent | ${style.surfaceAccent} |`,
586
+ ];
587
+
588
+ /** A docs page for one core theme, rendering it live in every mode. */
589
+ export const themePage = (theme: ResolvedTheme) =>
590
+ [
591
+ "---",
592
+ `title: ${theme.name}`,
593
+ `description: ${theme.description}`,
594
+ "---",
595
+ "",
596
+ `{/* ${header} */}`,
597
+ "",
598
+ `<ThemePreview theme="${theme.id}" />`,
599
+ "",
600
+ ...(theme.examples.length === 0
601
+ ? []
602
+ : [
603
+ `Example users: ${theme.examples.join(", ")}. Any app can pick any theme.`,
604
+ "",
605
+ ]),
606
+ "## Style",
607
+ "",
608
+ "| | |",
609
+ "| --- | --- |",
610
+ ...styleSummary(theme.style),
611
+ "",
612
+ "## Roles",
613
+ "",
614
+ `<ThemeSwatches theme="${theme.id}" />`,
615
+ "",
616
+ `The source is [\`packages/tokens/themes/${theme.id}.json\`](https://github.com/timmo001/groundwork/blob/main/packages/tokens/themes/${theme.id}.json).`,
617
+ "",
618
+ ].join("\n");
619
+
620
+ /** One page for the optional themes, mapped from their own palettes. */
621
+ export const optionalPage = (themes: readonly ResolvedTheme[]) => {
622
+ const optional = themes.filter((theme) => theme.optional);
623
+ const families = [...new Set(optional.map((theme) => theme.family ?? ""))];
624
+
625
+ return [
626
+ "---",
627
+ "title: Optional themes",
628
+ "description: Catppuccin and Tokyo Night, mapped onto the roles so they stay faithful to their palettes. Apps opt in.",
629
+ "---",
630
+ "",
631
+ `{/* ${header} */}`,
632
+ "",
633
+ "Each flavor is one mode of its palette, mapped straight onto the roles rather than solved, so it looks the way the palette's authors intended. They take Base's style. The [contrast report](/themes/contrast) shows where a palette sits below the core targets.",
634
+ "",
635
+ ...families.flatMap((family) => [
636
+ `## ${family}`,
637
+ "",
638
+ ...optional
639
+ .filter((theme) => theme.family === family)
640
+ .flatMap((theme) => [
641
+ `### ${theme.name}`,
642
+ "",
643
+ `<ThemePreview theme="${theme.id}" />`,
644
+ "",
645
+ ]),
646
+ ]),
647
+ ].join("\n");
648
+ };
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Design tokens: the theme schema, the OKLCH color helpers, and the solver
3
+ * that turns a theme into every role in every mode.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+ export * from "./color.ts";
8
+
9
+ export * from "./schema.ts";
10
+
11
+ export * from "./solve.ts";
package/src/runtime.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Themes at runtime: decodes a theme document a user uploaded or an agent
3
+ * wrote, solves it and returns its CSS and contrast checks. It needs no file
4
+ * system, so it runs in a browser.
5
+ *
6
+ * @module
7
+ */
8
+ import { Schema } from "effect";
9
+ import baseDocument from "../themes/base.json" with { type: "json" };
10
+ import { themeCss } from "./generate.ts";
11
+ import { SolvedTheme, Theme } from "./schema.ts";
12
+ import { check, resolve, type Check, type ResolvedTheme } from "./solve.ts";
13
+
14
+ const base = Schema.decodeUnknownSync(SolvedTheme)(baseDocument);
15
+
16
+ /** A theme solved at runtime, with its CSS and every contrast check. */
17
+ export interface RuntimeTheme {
18
+ readonly theme: ResolvedTheme;
19
+ readonly css: string;
20
+ readonly checks: readonly Check[];
21
+ }
22
+
23
+ /** Decodes a theme document from its JSON text, as a `Result`. */
24
+ export const parseTheme = Schema.decodeUnknownResult(
25
+ Schema.fromJsonString(Theme),
26
+ );
27
+
28
+ /**
29
+ * Solves a theme into light, dark and Darker. Its CSS applies under
30
+ * `data-gw-theme` set to `id`, which defaults to the theme's own.
31
+ */
32
+ export const runtimeTheme = (theme: Theme, id = theme.id): RuntimeTheme => {
33
+ const resolved = resolve({ ...theme, id }, base.style);
34
+
35
+ return { theme: resolved, css: themeCss(resolved), checks: check(resolved) };
36
+ };