@timmo001/groundwork-tokens 0.9.40 → 0.10.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.
package/src/page.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  omarchyStorageKeys,
16
16
  omarchyThemeId,
17
17
  omarchyThemeStyleId,
18
- themeCovers,
18
+ themeAllows,
19
19
  themeStorageKeys,
20
20
  type ThemeBootOptions,
21
21
  type ThemeChoice,
@@ -36,8 +36,10 @@ export {
36
36
  omarchyThemeStyleId,
37
37
  optionalThemeChoices,
38
38
  optionalThemeDocuments,
39
+ themeAllows,
39
40
  themeBootScript,
40
41
  themeCovers,
42
+ themeOptions,
41
43
  type ThemeBootOptions,
42
44
  type ThemeChoice,
43
45
  type ThemeMode,
@@ -50,8 +52,14 @@ export interface PageThemeState {
50
52
  readonly mode: ThemeMode;
51
53
  /** The scheme in use, with `auto` resolved from the system. */
52
54
  readonly scheme: ThemeScheme;
53
- /** The theme id in use: a built-in one, or `custom`. */
55
+ /** The theme id in use: a built-in one, `omarchy` or `custom`. */
54
56
  readonly theme: string;
57
+ /**
58
+ * The one scheme the theme in use covers, such as `light` for Catppuccin
59
+ * Latte, or `null` when it has both. Pass it to the picker's `scheme`, so
60
+ * the other modes can't be picked.
61
+ */
62
+ readonly only: ThemeScheme | null;
55
63
  /** The visitor's own theme's name, when they've imported one. */
56
64
  readonly custom: string | null;
57
65
  /** The desktop's Omarchy theme as a choice, when the app passed one. */
@@ -94,7 +102,9 @@ export interface PageThemeOptions extends ThemeBootOptions {
94
102
 
95
103
  export interface PageTheme {
96
104
  readonly state: () => PageThemeState;
105
+ /** Sets the mode, unless the theme in use doesn't come in it. */
97
106
  readonly setMode: (mode: ThemeMode) => void;
107
+ /** Picks a theme, moving to its scheme when it has only one. */
98
108
  readonly setTheme: (id: string) => void;
99
109
  /** Solves a theme document's JSON or an Omarchy `colors.toml` and applies it. */
100
110
  readonly importTheme: (text: string) => Promise<ImportResult>;
@@ -102,9 +112,9 @@ export interface PageTheme {
102
112
  readonly reset: () => void;
103
113
  /**
104
114
  * Offers the Omarchy theme the app runs under, or drops it with `null`.
105
- * It's used for its scheme until the visitor picks another, and a theme
106
- * switch on the desktop follows through while it's picked. Apps that can
107
- * read it, such as desktop apps, pass it on start and on every switch.
115
+ * It's only ever a choice, never the default; while it's picked, a theme
116
+ * switch on the desktop follows through. Apps that can read it, such as
117
+ * desktop apps, pass it on start and on every switch.
108
118
  */
109
119
  readonly setOmarchyTheme: (
110
120
  theme: OmarchyCurrentTheme | null,
@@ -143,9 +153,17 @@ const readTheme = async (text: string) => {
143
153
  return omarchyTheme(text);
144
154
  };
145
155
 
156
+ const schemeValue = (value: string | null): ThemeScheme | undefined =>
157
+ value === "light" || value === "dark" ? value : undefined;
158
+
146
159
  /**
147
160
  * Sets up the page's theme from what's stored, falling back to the defaults,
148
161
  * and registers the WebMCP theme tools where the browser offers them.
162
+ *
163
+ * There's one pick. A theme with one scheme keeps the page in it, so its
164
+ * other modes can't be set. An imported or Omarchy theme is offered once
165
+ * there is one, but is only used when it's picked: the default is always
166
+ * `defaultTheme`.
149
167
  */
150
168
  export const pageTheme = (options: PageThemeOptions): PageTheme => {
151
169
  const stored = themeStorageKeys(options.key);
@@ -156,10 +174,6 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
156
174
 
157
175
  const known = (id: string) => options.themes.some((theme) => theme.id === id);
158
176
 
159
- const storedMode = read(stored.mode);
160
- const customName = read(stored.name);
161
- const customCss = read(stored.css);
162
-
163
177
  const setStyle = (id: string, css: string | null) => {
164
178
  let style = document.getElementById(id);
165
179
 
@@ -184,13 +198,9 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
184
198
  {
185
199
  const css = read(storedOmarchy.css);
186
200
  const name = read(storedOmarchy.name);
187
- const mode = read(storedOmarchy.mode);
201
+ const mode = schemeValue(read(storedOmarchy.mode));
188
202
 
189
- if (
190
- css !== null &&
191
- name !== null &&
192
- (mode === "light" || mode === "dark")
193
- ) {
203
+ if (css !== null && name !== null && mode !== undefined) {
194
204
  omarchy = { id: omarchyThemeId, name, mode };
195
205
 
196
206
  // The boot script may have put it on already
@@ -200,8 +210,24 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
200
210
  }
201
211
  }
202
212
 
203
- const choices = () =>
204
- omarchy === null ? options.themes : [...options.themes, omarchy];
213
+ /** The visitor's own theme as a choice, when there's one. */
214
+ let custom: ThemeChoice | null =
215
+ read(stored.css) === null
216
+ ? null
217
+ : {
218
+ id: customThemeId,
219
+ name: read(stored.name) ?? "Custom",
220
+ mode: schemeValue(read(stored.scheme)),
221
+ };
222
+
223
+ const choiceOf = (id: string | null) =>
224
+ id === null
225
+ ? undefined
226
+ : [
227
+ ...options.themes,
228
+ ...(omarchy === null ? [] : [omarchy]),
229
+ ...(custom === null ? [] : [custom]),
230
+ ].find((theme) => theme.id === id);
205
231
 
206
232
  const system = matchMedia("(prefers-color-scheme: dark)");
207
233
 
@@ -212,59 +238,46 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
212
238
  ? "light"
213
239
  : "dark";
214
240
 
215
- /** Whether `id` can be the pick for `scheme`. */
216
- const fits = (id: string | null, scheme: ThemeScheme, custom: boolean) =>
217
- id !== null &&
218
- ((id === customThemeId && custom) ||
219
- choices().some((theme) => theme.id === id && themeCovers(theme, scheme)));
220
-
221
- // The Omarchy theme comes first where it covers the scheme, so the app
222
- // matches the desktop until the visitor picks another, unless it's only
223
- // offered
224
- const fallback = (scheme: ThemeScheme) =>
225
- options.followOmarchy !== false &&
226
- omarchy !== null &&
227
- themeCovers(omarchy, scheme)
228
- ? omarchy.id
229
- : options.themes.some(
230
- (theme) =>
231
- theme.id === options.defaultTheme && themeCovers(theme, scheme),
232
- )
233
- ? options.defaultTheme
234
- : (options.themes.find((theme) => themeCovers(theme, scheme))?.id ??
235
- options.defaultTheme);
236
-
237
- const storedLight = read(stored.theme);
238
- const storedDark = read(stored.dark) ?? storedLight;
239
-
240
- /** The theme kept for each scheme. */
241
- let picks: Record<ThemeScheme, string> = {
242
- light: fits(storedLight, "light", customCss !== null)
243
- ? (storedLight ?? options.defaultTheme)
244
- : fallback("light"),
245
- dark: fits(storedDark, "dark", customCss !== null)
246
- ? (storedDark ?? options.defaultTheme)
247
- : fallback("dark"),
241
+ /** `mode`, or the theme's own scheme when it doesn't come in `mode`. */
242
+ const fit = (choice: ThemeChoice | undefined, mode: ThemeMode): ThemeMode =>
243
+ choice === undefined || themeAllows(choice, mode)
244
+ ? mode
245
+ : (choice.mode ?? mode);
246
+
247
+ const resolve = (theme: string, mode: ThemeMode): PageThemeState => {
248
+ const choice = choiceOf(theme);
249
+ const fitted = fit(choice, mode);
250
+
251
+ return {
252
+ mode: fitted,
253
+ scheme: schemeOf(fitted),
254
+ theme,
255
+ only: choice?.mode ?? null,
256
+ custom: custom?.name ?? null,
257
+ omarchy,
258
+ };
248
259
  };
249
260
 
250
- const savePicks = () => {
251
- write(stored.theme, picks.light);
252
- write(stored.dark, picks.dark);
253
- };
261
+ /** The default, as `reset` and a vanished pick go back to. */
262
+ const fallback = () => resolve(options.defaultTheme, defaultMode);
254
263
 
255
- const resolve = (mode: ThemeMode, custom: string | null): PageThemeState => {
256
- const scheme = schemeOf(mode);
257
-
258
- return { mode, scheme, theme: picks[scheme], custom, omarchy };
259
- };
264
+ let state: PageThemeState = (() => {
265
+ const theme = read(stored.theme);
266
+ const mode = modes.find((entry) => entry === read(stored.mode));
260
267
 
261
- let state: PageThemeState = resolve(
262
- modes.find((mode) => mode === storedMode) ?? defaultMode,
263
- customCss === null ? null : customName,
264
- );
268
+ return theme !== null && choiceOf(theme) !== undefined
269
+ ? resolve(theme, mode ?? defaultMode)
270
+ : resolve(options.defaultTheme, mode ?? defaultMode);
271
+ })();
265
272
 
266
- const commit = (next: PageThemeState) => {
273
+ const commit = (next: PageThemeState, save = true) => {
267
274
  state = next;
275
+
276
+ if (save) {
277
+ write(stored.theme, state.theme);
278
+ write(stored.mode, state.mode);
279
+ }
280
+
268
281
  root.setAttribute("data-gw-theme", state.theme);
269
282
 
270
283
  if (state.mode === "auto") root.removeAttribute("data-gw-mode");
@@ -273,8 +286,15 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
273
286
  for (const listener of listeners) listener(state);
274
287
  };
275
288
 
276
- /** Solves a document under the custom id, applies it and keeps it. */
277
- const applyCustom = async (document: Theme, text: string, keep = true) => {
289
+ /**
290
+ * Solves a document under the custom id and keeps it. `pick` uses it,
291
+ * as an import does; a theme from the store is only offered.
292
+ */
293
+ const applyCustom = async (
294
+ document: Theme,
295
+ text: string,
296
+ { pick, keep }: { pick: boolean; keep: boolean },
297
+ ) => {
278
298
  const { appTonesCss, runtimeTheme } = await import("./runtime.ts");
279
299
  const runtime = runtimeTheme(document, customThemeId);
280
300
 
@@ -283,13 +303,26 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
283
303
  ? runtime.css
284
304
  : `${runtime.css}\n\n${appTonesCss(options.tones, runtime.theme)}`;
285
305
 
306
+ const scheme =
307
+ runtime.theme.modes.light === undefined
308
+ ? "dark"
309
+ : runtime.theme.modes.dark === undefined
310
+ ? "light"
311
+ : undefined;
312
+
286
313
  setStyle(customThemeStyleId, css);
287
314
  write(stored.document, text);
288
315
  write(stored.css, css);
289
316
  write(stored.name, document.name);
290
- picks = { light: customThemeId, dark: customThemeId };
291
- savePicks();
292
- commit(resolve(state.mode, document.name));
317
+ write(stored.scheme, scheme ?? null);
318
+ custom = { id: customThemeId, name: document.name, mode: scheme };
319
+
320
+ commit(
321
+ resolve(
322
+ pick || state.theme === customThemeId ? customThemeId : state.theme,
323
+ state.mode,
324
+ ),
325
+ );
293
326
 
294
327
  if (keep) await options.store?.save(text).catch(() => undefined);
295
328
 
@@ -301,45 +334,41 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
301
334
  );
302
335
  };
303
336
 
304
- const setTheme = (id: string) => {
305
- if (id === customThemeId) {
306
- if (state.custom === null) return;
307
-
308
- picks = { light: id, dark: id };
309
- } else {
310
- const choice = choices().find((theme) => theme.id === id);
311
-
312
- if (choice === undefined) return;
313
-
314
- picks =
315
- choice.mode === undefined
316
- ? { light: id, dark: id }
317
- : { ...picks, [choice.mode]: id };
337
+ /** Drops the visitor's own theme, leaving it if it was in use. */
338
+ const dropCustom = () => {
339
+ setStyle(customThemeStyleId, null);
340
+
341
+ for (const key of [
342
+ stored.document,
343
+ stored.css,
344
+ stored.name,
345
+ stored.scheme,
346
+ ]) {
347
+ write(key, null);
348
+ }
318
349
 
319
- // Picking a light-only theme in dark, or the reverse, shows it
320
- if (choice.mode !== undefined && choice.mode !== state.scheme) {
321
- savePicks();
322
- write(stored.mode, choice.mode);
323
- commit(resolve(choice.mode, state.custom));
350
+ custom = null;
351
+ };
324
352
 
325
- return;
326
- }
327
- }
353
+ const setTheme = (id: string) => {
354
+ if (choiceOf(id) === undefined) return;
328
355
 
329
- savePicks();
330
- commit(resolve(state.mode, state.custom));
356
+ commit(resolve(id, state.mode));
331
357
  };
332
358
 
333
- // A system change moves `auto` between the light and dark picks
359
+ // A system change moves `auto` between light and dark
334
360
  system.addEventListener("change", () => {
335
- if (state.mode === "auto") commit(resolve(state.mode, state.custom));
361
+ if (state.mode === "auto") commit(resolve(state.theme, state.mode), false);
336
362
  });
337
363
 
338
364
  const page: PageTheme = {
339
365
  state: () => state,
340
366
  setMode: (mode) => {
341
- write(stored.mode, mode);
342
- commit(resolve(mode, state.custom));
367
+ const choice = choiceOf(state.theme);
368
+
369
+ if (choice !== undefined && !themeAllows(choice, mode)) return;
370
+
371
+ commit(resolve(state.theme, mode));
343
372
  },
344
373
  setTheme,
345
374
  importTheme: async (text) => {
@@ -353,32 +382,22 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
353
382
  };
354
383
  }
355
384
 
356
- const misses = await applyCustom(parsed.success, text);
385
+ const misses = await applyCustom(parsed.success, text, {
386
+ pick: true,
387
+ keep: true,
388
+ });
357
389
 
358
390
  return { ok: true, name: parsed.success.name, misses };
359
391
  },
360
392
  reset: () => {
361
- setStyle(customThemeStyleId, null);
362
-
363
- for (const key of Object.values(stored)) write(key, null);
364
-
365
- picks = { light: fallback("light"), dark: fallback("dark") };
366
- commit(resolve(defaultMode, null));
393
+ dropCustom();
394
+ write(stored.theme, null);
395
+ write(stored.mode, null);
396
+ commit(fallback(), false);
367
397
  void options.store?.clear().catch(() => undefined);
368
398
  },
369
399
  setOmarchyTheme: async (theme) => {
370
- // Stored as well as in use, so it's picked again when it comes back
371
- const followed = [
372
- picks.light,
373
- picks.dark,
374
- read(stored.theme),
375
- read(stored.dark),
376
- ].includes(omarchyThemeId);
377
-
378
- const unpicked =
379
- options.followOmarchy !== false && read(stored.theme) === null;
380
-
381
- let mode: ThemeScheme | undefined;
400
+ let next: ThemeChoice | null = null;
382
401
 
383
402
  if (theme !== null) {
384
403
  const { Result } = await import("effect");
@@ -397,42 +416,32 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
397
416
  ? runtime.css
398
417
  : `${runtime.css}\n\n${appTonesCss(options.tones, runtime.theme)}`;
399
418
 
400
- mode = parsed.success.mode;
401
419
  setStyle(omarchyThemeStyleId, css);
402
420
  write(storedOmarchy.css, css);
403
421
  write(storedOmarchy.name, name);
404
- write(storedOmarchy.mode, mode);
405
- omarchy = { id: omarchyThemeId, name, mode };
422
+ write(storedOmarchy.mode, parsed.success.mode);
423
+ next = { id: omarchyThemeId, name, mode: parsed.success.mode };
406
424
  }
407
425
  }
408
426
 
409
- if (mode === undefined) {
410
- // Gone, or a colors.toml that doesn't map: back off it, keeping the
411
- // pick so it comes back with the theme
427
+ if (next === null) {
428
+ // Gone, or a colors.toml that doesn't map
412
429
  setStyle(omarchyThemeStyleId, null);
413
430
 
414
431
  for (const key of Object.values(storedOmarchy)) write(key, null);
415
-
416
- omarchy = null;
417
- picks = {
418
- light:
419
- picks.light === omarchyThemeId ? fallback("light") : picks.light,
420
- dark: picks.dark === omarchyThemeId ? fallback("dark") : picks.dark,
421
- };
422
- } else if (followed || unpicked) {
423
- // A switch between a light and a dark Omarchy theme moves it across
424
- const keep = (scheme: ThemeScheme) =>
425
- picks[scheme] === omarchyThemeId ? fallback(scheme) : picks[scheme];
426
-
427
- picks =
428
- mode === "dark"
429
- ? { light: keep("light"), dark: omarchyThemeId }
430
- : { light: omarchyThemeId, dark: keep("dark") };
431
-
432
- if (!unpicked) savePicks();
433
432
  }
434
433
 
435
- commit(resolve(state.mode, state.custom));
434
+ omarchy = next;
435
+
436
+ // While it's picked, a switch between a light and a dark Omarchy
437
+ // theme moves the page across; once it's gone, the default takes over
438
+ commit(
439
+ state.theme !== omarchyThemeId
440
+ ? resolve(state.theme, state.mode)
441
+ : next === null
442
+ ? fallback()
443
+ : resolve(omarchyThemeId, state.mode),
444
+ );
436
445
  },
437
446
  subscribe: (listener) => {
438
447
  listeners.add(listener);
@@ -443,10 +452,14 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
443
452
  },
444
453
  };
445
454
 
446
- commit(state);
455
+ // A stored pick is saved back, in case its mode had to move to fit the
456
+ // theme; with nothing stored, the defaults stay unsaved so a new default
457
+ // reaches the visitor
458
+ commit(state, read(stored.theme) !== null);
447
459
 
448
- // The store's theme wins over this browser's: another client may have
449
- // changed or dropped it
460
+ // The store's theme wins over this browser's, since another client may
461
+ // have changed or dropped it. It's offered, and used only when this
462
+ // browser had the visitor's own theme picked
450
463
  if (options.store !== undefined) {
451
464
  void options.store
452
465
  .load()
@@ -454,19 +467,12 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
454
467
  if (text === read(stored.document)) return;
455
468
 
456
469
  if (text === null) {
457
- setStyle(customThemeStyleId, null);
458
-
459
- for (const key of [stored.document, stored.css, stored.name]) {
460
- write(key, null);
461
- }
462
-
463
- picks = {
464
- light:
465
- picks.light === customThemeId ? fallback("light") : picks.light,
466
- dark: picks.dark === customThemeId ? fallback("dark") : picks.dark,
467
- };
468
- savePicks();
469
- commit(resolve(state.mode, null));
470
+ dropCustom();
471
+ commit(
472
+ state.theme === customThemeId
473
+ ? fallback()
474
+ : resolve(state.theme, state.mode),
475
+ );
470
476
 
471
477
  return;
472
478
  }
@@ -475,7 +481,7 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
475
481
  const parsed = await readTheme(text);
476
482
 
477
483
  if (Result.isSuccess(parsed)) {
478
- await applyCustom(parsed.success, text, false);
484
+ await applyCustom(parsed.success, text, { pick: false, keep: false });
479
485
  }
480
486
  })
481
487
  .catch(() => undefined);
@@ -502,7 +508,7 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
502
508
  },
503
509
  apply: ({ document: theme, text }) => {
504
510
  if (documents.includes(theme)) setTheme(theme.id);
505
- else void applyCustom(theme, text);
511
+ else void applyCustom(theme, text, { pick: true, keep: true });
506
512
  },
507
513
  });
508
514
  },
package/src/schema.ts CHANGED
@@ -108,8 +108,9 @@ export interface Palette extends Schema.Schema.Type<typeof Palette> {}
108
108
 
109
109
  /**
110
110
  * An optional theme mapped from an existing palette, such as Catppuccin, so it
111
- * stays faithful. It takes Base's style. Most flavors are one mode; one that
112
- * comes in both, such as GitHub's, also gives the other mode's palette.
111
+ * stays faithful. It takes Base's style. Some flavors are one mode, such as
112
+ * Catppuccin's; one that comes in both, such as GitHub's or Tokyo Night's,
113
+ * also gives the other mode's palette.
113
114
  */
114
115
  export const MappedTheme = Schema.Struct({
115
116
  kind: Schema.tag("mapped"),
@@ -17,5 +17,19 @@
17
17
  "important": "#c099ff",
18
18
  "warning": "#ffc777",
19
19
  "danger": "#ff757f"
20
+ },
21
+ "otherPalette": {
22
+ "lower": "#d0d5e3",
23
+ "background": "#e1e2e7",
24
+ "raised": "#e9e9ed",
25
+ "border": "#c4c8da",
26
+ "text": "#3760bf",
27
+ "muted": "#6172b0",
28
+ "accent": "#2e7de9",
29
+ "info": "#2e7de9",
30
+ "success": "#587539",
31
+ "important": "#9854f1",
32
+ "warning": "#8c6c3e",
33
+ "danger": "#f52a65"
20
34
  }
21
35
  }
@@ -17,5 +17,19 @@
17
17
  "important": "#bb9af7",
18
18
  "warning": "#e0af68",
19
19
  "danger": "#f7768e"
20
+ },
21
+ "otherPalette": {
22
+ "lower": "#d0d5e3",
23
+ "background": "#e1e2e7",
24
+ "raised": "#e9e9ed",
25
+ "border": "#c4c8da",
26
+ "text": "#3760bf",
27
+ "muted": "#6172b0",
28
+ "accent": "#2e7de9",
29
+ "info": "#2e7de9",
30
+ "success": "#587539",
31
+ "important": "#9854f1",
32
+ "warning": "#8c6c3e",
33
+ "danger": "#f52a65"
20
34
  }
21
35
  }
@@ -17,5 +17,19 @@
17
17
  "important": "#bb9af7",
18
18
  "warning": "#e0af68",
19
19
  "danger": "#f7768e"
20
+ },
21
+ "otherPalette": {
22
+ "lower": "#d0d5e3",
23
+ "background": "#e1e2e7",
24
+ "raised": "#e9e9ed",
25
+ "border": "#c4c8da",
26
+ "text": "#3760bf",
27
+ "muted": "#6172b0",
28
+ "accent": "#2e7de9",
29
+ "info": "#2e7de9",
30
+ "success": "#587539",
31
+ "important": "#9854f1",
32
+ "warning": "#8c6c3e",
33
+ "danger": "#f52a65"
20
34
  }
21
35
  }
@@ -1,21 +0,0 @@
1
- {
2
- "kind": "mapped",
3
- "id": "tokyo-night-day",
4
- "name": "Tokyo Night Day",
5
- "family": "Tokyo Night",
6
- "mode": "light",
7
- "palette": {
8
- "lower": "#d0d5e3",
9
- "background": "#e1e2e7",
10
- "raised": "#e9e9ed",
11
- "border": "#c4c8da",
12
- "text": "#3760bf",
13
- "muted": "#6172b0",
14
- "accent": "#2e7de9",
15
- "info": "#2e7de9",
16
- "success": "#587539",
17
- "important": "#9854f1",
18
- "warning": "#8c6c3e",
19
- "danger": "#f52a65"
20
- }
21
- }