@timmo001/groundwork-tokens 0.3.1 → 0.4.1

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.
@@ -125,6 +125,32 @@ gw-section-title:not(:defined) {
125
125
  align-items: center;
126
126
  }
127
127
 
128
+ gw-app-bar:not(:defined) {
129
+ position: sticky;
130
+ inset-block-start: 0;
131
+ z-index: 50;
132
+ display: flex;
133
+ gap: var(--gw-space-3);
134
+ align-items: center;
135
+ min-block-size: calc(3.5rem + 1px);
136
+ padding-inline: max(var(--gw-space-3), (100% - var(--gw-content-width)) / 2);
137
+ }
138
+
139
+ gw-app-bar[overlay]:not(:defined) {
140
+ position: fixed;
141
+ inset-inline: 0;
142
+ }
143
+
144
+ gw-app-bar[reveal-after]:not(:defined) > [slot="brand"] {
145
+ visibility: hidden;
146
+ }
147
+
148
+ gw-theme-picker:not(:defined) {
149
+ display: inline-block;
150
+ min-block-size: 2.5rem;
151
+ vertical-align: middle;
152
+ }
153
+
128
154
  gw-button[icon]:not(:defined) {
129
155
  padding-inline-start: calc(var(--gw-space-4) + var(--gw-space-2) + 1.125rem + 1px);
130
156
  }
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-tokens",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Groundwork's design tokens: the theme schema, the OKLCH solver and every theme as CSS and JSON",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./runtime": "./src/runtime.ts",
10
+ "./page": "./src/page.ts",
10
11
  "./webmcp": "./src/webmcp.ts",
12
+ "./opentui": "./generated/opentui.ts",
11
13
  "./themes.css": "./generated/themes.css",
12
14
  "./themes.json": "./generated/themes.json"
13
15
  },
package/src/generate.ts CHANGED
@@ -233,6 +233,26 @@ const preUpgrade = {
233
233
  "gw-page-header": { display: "flex", "min-block-size": "3rem" },
234
234
  "gw-page-header[description]": { "min-block-size": "3.6875rem" },
235
235
  "gw-section-title": { display: "flex", "align-items": "center" },
236
+ "gw-app-bar": {
237
+ position: "sticky",
238
+ "inset-block-start": "0",
239
+ "z-index": "50",
240
+ display: "flex",
241
+ gap: "var(--gw-space-3)",
242
+ "align-items": "center",
243
+ "min-block-size": "calc(3.5rem + 1px)",
244
+ "padding-inline":
245
+ "max(var(--gw-space-3), (100% - var(--gw-content-width)) / 2)",
246
+ },
247
+ "gw-app-bar[overlay]": { position: "fixed", "inset-inline": "0" },
248
+ 'gw-app-bar[reveal-after]:not(:defined) > [slot="brand"]': {
249
+ visibility: "hidden",
250
+ },
251
+ "gw-theme-picker": {
252
+ display: "inline-block",
253
+ "min-block-size": "2.5rem",
254
+ "vertical-align": "middle",
255
+ },
236
256
  "gw-button[icon]": {
237
257
  "padding-inline-start":
238
258
  "calc(var(--gw-space-4) + var(--gw-space-2) + 1.125rem + 1px)",
package/src/page.ts ADDED
@@ -0,0 +1,307 @@
1
+ /**
2
+ * A page's theme and mode: keeps the visitor's pick across visits, applies
3
+ * it to `<html>`, and lets them bring their own theme. The solver and the
4
+ * WebMCP tools load only when a custom theme or `document.modelContext`
5
+ * needs them, so a page that only switches built-in themes stays small.
6
+ *
7
+ * Put `themeBootScript()` in an inline `<script>` in `<head>` so the stored
8
+ * pick applies before the first paint.
9
+ *
10
+ * @module
11
+ */
12
+ import base from "../themes/base.json" with { type: "json" };
13
+ import bridge from "../themes/bridge.json" with { type: "json" };
14
+ import glazing from "../themes/glazing.json" with { type: "json" };
15
+ import slate from "../themes/slate.json" with { type: "json" };
16
+ import snag from "../themes/snag.json" with { type: "json" };
17
+ import survey from "../themes/survey.json" with { type: "json" };
18
+ import type { Theme } from "./schema.ts";
19
+ // Only for `document.modelContext`'s type; the tools themselves load lazily
20
+ import type {} from "./webmcp.ts";
21
+
22
+ /** How light the page is: `auto` follows the system. */
23
+ export type ThemeMode = "auto" | "light" | "dark" | "darker";
24
+
25
+ /** The theme id a visitor's own theme is solved under. */
26
+ export const customThemeId = "custom";
27
+
28
+ /** The id of the `<style>` holding a custom theme's CSS. */
29
+ export const customThemeStyleId = "gw-custom-theme";
30
+
31
+ const modes: readonly ThemeMode[] = ["auto", "light", "dark", "darker"];
32
+
33
+ export interface PageThemeState {
34
+ readonly mode: ThemeMode;
35
+ /** The theme id in use: a built-in one, or `custom`. */
36
+ readonly theme: string;
37
+ /** The visitor's own theme's name, when they've imported one. */
38
+ readonly custom: string | null;
39
+ }
40
+
41
+ /** What `importTheme` reports: the theme's name and any contrast misses, or why it failed. */
42
+ export type ImportResult =
43
+ | {
44
+ readonly ok: true;
45
+ readonly name: string;
46
+ readonly misses: readonly string[];
47
+ }
48
+ | { readonly ok: false; readonly error: string };
49
+
50
+ /** A theme to offer, by id and name. A `Theme` document is one. */
51
+ export interface ThemeChoice {
52
+ readonly id: string;
53
+ readonly name: string;
54
+ }
55
+
56
+ /** The core themes by id and name, in the order the docs list them. */
57
+ export const coreThemeChoices: readonly ThemeChoice[] = [
58
+ base,
59
+ glazing,
60
+ survey,
61
+ slate,
62
+ bridge,
63
+ snag,
64
+ ].map(({ id, name }) => ({ id, name }));
65
+
66
+ export interface PageThemeOptions {
67
+ /** The local storage key prefix, such as `my-site:theme`. */
68
+ readonly key: string;
69
+ /** The built-in themes to offer, their CSS already on the page. */
70
+ readonly themes: readonly ThemeChoice[];
71
+ /**
72
+ * The full documents of `themes`, loaded only when the WebMCP tools
73
+ * register. Defaults to the core themes.
74
+ */
75
+ readonly documents?: () => Promise<readonly Theme[]>;
76
+ /** The theme id to start with. */
77
+ readonly defaultTheme: string;
78
+ /** The mode to start with. Defaults to `auto`. */
79
+ readonly defaultMode?: ThemeMode;
80
+ /** The element the attributes go on. Defaults to `<html>`. */
81
+ readonly root?: HTMLElement;
82
+ }
83
+
84
+ export interface PageTheme {
85
+ readonly state: () => PageThemeState;
86
+ readonly setMode: (mode: ThemeMode) => void;
87
+ readonly setTheme: (id: string) => void;
88
+ /** Solves a theme document's JSON or an Omarchy `colors.toml` and applies it. */
89
+ readonly importTheme: (text: string) => Promise<ImportResult>;
90
+ /** Drops the visitor's own theme and goes back to the defaults. */
91
+ readonly reset: () => void;
92
+ /** Calls `listener` on every change; returns a function that stops it. */
93
+ readonly subscribe: (listener: (state: PageThemeState) => void) => () => void;
94
+ }
95
+
96
+ const keys = (key: string) => ({
97
+ mode: `${key}:mode`,
98
+ theme: `${key}:id`,
99
+ document: `${key}:custom`,
100
+ css: `${key}:custom-css`,
101
+ name: `${key}:custom-name`,
102
+ });
103
+
104
+ const read = (key: string) => {
105
+ try {
106
+ return localStorage.getItem(key);
107
+ } catch {
108
+ return null;
109
+ }
110
+ };
111
+
112
+ const write = (key: string, value: string | null) => {
113
+ try {
114
+ if (value === null) localStorage.removeItem(key);
115
+ else localStorage.setItem(key, value);
116
+ } catch {
117
+ // Blocked or full: the pick still applies until the page closes
118
+ }
119
+ };
120
+
121
+ /**
122
+ * An inline script that applies the stored mode, theme and any custom theme's
123
+ * cached CSS before the first paint. Takes the same key and defaults as
124
+ * `pageTheme`.
125
+ */
126
+ export const themeBootScript = (
127
+ options: Pick<PageThemeOptions, "key" | "defaultTheme" | "defaultMode">,
128
+ ) => {
129
+ const stored = keys(options.key);
130
+
131
+ const config = JSON.stringify({
132
+ keys: stored,
133
+ theme: options.defaultTheme,
134
+ mode: options.defaultMode ?? "auto",
135
+ custom: customThemeId,
136
+ style: customThemeStyleId,
137
+ });
138
+
139
+ return `(()=>{const c=${config};let s;try{s=localStorage}catch{}const g=(k)=>{try{return s?.getItem(k)??null}catch{return null}};const r=document.documentElement;let t=g(c.keys.theme)??c.theme;const m=g(c.keys.mode)??c.mode;if(t===c.custom){const css=g(c.keys.css);if(css){const e=document.createElement("style");e.id=c.style;e.textContent=css;document.head.append(e)}else t=c.theme}r.setAttribute("data-gw-theme",t);if(m==="auto")r.removeAttribute("data-gw-mode");else r.setAttribute("data-gw-mode",m)})();`;
140
+ };
141
+
142
+ /** Reads a theme from JSON text or an Omarchy `colors.toml`. */
143
+ const readTheme = async (text: string) => {
144
+ const { Result } = await import("effect");
145
+ const { omarchyTheme, parseTheme } = await import("./runtime.ts");
146
+ const parsed = parseTheme(text);
147
+
148
+ if (Result.isSuccess(parsed) || text.trimStart().startsWith("{")) {
149
+ return parsed;
150
+ }
151
+
152
+ return omarchyTheme(text);
153
+ };
154
+
155
+ /**
156
+ * Sets up the page's theme from what's stored, falling back to the defaults,
157
+ * and registers the WebMCP theme tools where the browser offers them.
158
+ */
159
+ export const pageTheme = (options: PageThemeOptions): PageTheme => {
160
+ const stored = keys(options.key);
161
+ const root = options.root ?? document.documentElement;
162
+ const defaultMode = options.defaultMode ?? "auto";
163
+ const listeners = new Set<(state: PageThemeState) => void>();
164
+
165
+ const known = (id: string) => options.themes.some((theme) => theme.id === id);
166
+
167
+ const storedMode = read(stored.mode);
168
+ const storedTheme = read(stored.theme);
169
+ const customName = read(stored.name);
170
+ const customCss = read(stored.css);
171
+
172
+ let state: PageThemeState = {
173
+ mode: modes.find((mode) => mode === storedMode) ?? defaultMode,
174
+ theme:
175
+ storedTheme !== null &&
176
+ (known(storedTheme) ||
177
+ (storedTheme === customThemeId && customCss !== null))
178
+ ? storedTheme
179
+ : options.defaultTheme,
180
+ custom: customCss === null ? null : customName,
181
+ };
182
+
183
+ const setStyle = (css: string | null) => {
184
+ let style = document.getElementById(customThemeStyleId);
185
+
186
+ if (css === null) {
187
+ style?.remove();
188
+
189
+ return;
190
+ }
191
+
192
+ if (style === null) {
193
+ style = document.createElement("style");
194
+ style.id = customThemeStyleId;
195
+ document.head.append(style);
196
+ }
197
+
198
+ style.textContent = css;
199
+ };
200
+
201
+ const commit = (next: PageThemeState) => {
202
+ state = next;
203
+ root.setAttribute("data-gw-theme", state.theme);
204
+
205
+ if (state.mode === "auto") root.removeAttribute("data-gw-mode");
206
+ else root.setAttribute("data-gw-mode", state.mode);
207
+
208
+ for (const listener of listeners) listener(state);
209
+ };
210
+
211
+ /** Solves a document under the custom id, applies it and keeps it. */
212
+ const applyCustom = async (document: Theme, text: string) => {
213
+ const { runtimeTheme } = await import("./runtime.ts");
214
+ const runtime = runtimeTheme(document, customThemeId);
215
+
216
+ setStyle(runtime.css);
217
+ write(stored.document, text);
218
+ write(stored.css, runtime.css);
219
+ write(stored.name, document.name);
220
+ write(stored.theme, customThemeId);
221
+ commit({ ...state, theme: customThemeId, custom: document.name });
222
+
223
+ return runtime.checks
224
+ .filter((result) => !result.passes)
225
+ .map(
226
+ (result) =>
227
+ `${result.role} in ${result.mode} (${result.ratio.toFixed(2)})`,
228
+ );
229
+ };
230
+
231
+ const setTheme = (id: string) => {
232
+ if (!known(id) && !(id === customThemeId && state.custom !== null)) return;
233
+
234
+ write(stored.theme, id);
235
+ commit({ ...state, theme: id });
236
+ };
237
+
238
+ const page: PageTheme = {
239
+ state: () => state,
240
+ setMode: (mode) => {
241
+ write(stored.mode, mode);
242
+ commit({ ...state, mode });
243
+ },
244
+ setTheme,
245
+ importTheme: async (text) => {
246
+ const { Result } = await import("effect");
247
+ const parsed = await readTheme(text);
248
+
249
+ if (Result.isFailure(parsed)) {
250
+ return {
251
+ ok: false,
252
+ error: `That isn't a theme document or an Omarchy colors.toml: ${String(parsed.failure).slice(0, 400)}`,
253
+ };
254
+ }
255
+
256
+ const misses = await applyCustom(parsed.success, text);
257
+
258
+ return { ok: true, name: parsed.success.name, misses };
259
+ },
260
+ reset: () => {
261
+ setStyle(null);
262
+
263
+ for (const key of Object.values(stored)) write(key, null);
264
+
265
+ commit({ mode: defaultMode, theme: options.defaultTheme, custom: null });
266
+ },
267
+ subscribe: (listener) => {
268
+ listeners.add(listener);
269
+
270
+ return () => {
271
+ listeners.delete(listener);
272
+ };
273
+ },
274
+ };
275
+
276
+ commit(state);
277
+
278
+ if (document.modelContext !== undefined) {
279
+ void import("./webmcp.ts").then(
280
+ async ({ coreThemes, registerThemeTools }) => {
281
+ const documents = ((await options.documents?.()) ?? coreThemes).filter(
282
+ (theme) => known(theme.id),
283
+ );
284
+
285
+ registerThemeTools({
286
+ themes: documents,
287
+ // The custom document is kept by `applyCustom`, so a built-in pick
288
+ // doesn't overwrite it
289
+ store: {
290
+ load: async () =>
291
+ state.custom === null ? null : read(stored.document),
292
+ save: async () => {},
293
+ clear: async () => {
294
+ page.reset();
295
+ },
296
+ },
297
+ apply: ({ document: theme, text }) => {
298
+ if (documents.includes(theme)) setTheme(theme.id);
299
+ else void applyCustom(theme, text);
300
+ },
301
+ });
302
+ },
303
+ );
304
+ }
305
+
306
+ return page;
307
+ };