@timmo001/groundwork-tokens 0.3.1 → 0.4.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.
- package/generated/themes.css +26 -0
- package/package.json +3 -1
- package/src/generate.ts +20 -0
- package/src/page.ts +307 -0
package/generated/themes.css
CHANGED
|
@@ -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
|
+
"version": "0.4.0",
|
|
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
|
+
};
|