@timmo001/groundwork-tokens 0.9.39 → 0.10.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/src/page.ts CHANGED
@@ -9,55 +9,57 @@
9
9
  *
10
10
  * @module
11
11
  */
12
- import base from "../themes/base.json" with { type: "json" };
13
- import bridge from "../themes/bridge.json" with { type: "json" };
14
- import catppuccinFrappe from "../themes/catppuccin-frappe.json" with { type: "json" };
15
- import catppuccinLatte from "../themes/catppuccin-latte.json" with { type: "json" };
16
- import catppuccinMacchiato from "../themes/catppuccin-macchiato.json" with { type: "json" };
17
- import catppuccinMocha from "../themes/catppuccin-mocha.json" with { type: "json" };
18
- import githubDimmed from "../themes/github-dimmed.json" with { type: "json" };
19
- import githubHighContrast from "../themes/github-high-contrast.json" with { type: "json" };
20
- import github from "../themes/github.json" with { type: "json" };
21
- import glazing from "../themes/glazing.json" with { type: "json" };
22
- import slate from "../themes/slate.json" with { type: "json" };
23
- import snag from "../themes/snag.json" with { type: "json" };
24
- import survey from "../themes/survey.json" with { type: "json" };
25
- import tokyoNightDay from "../themes/tokyo-night-day.json" with { type: "json" };
26
- import tokyoNightMoon from "../themes/tokyo-night-moon.json" with { type: "json" };
27
- import tokyoNightStorm from "../themes/tokyo-night-storm.json" with { type: "json" };
28
- import tokyoNight from "../themes/tokyo-night.json" with { type: "json" };
12
+ import {
13
+ customThemeId,
14
+ customThemeStyleId,
15
+ omarchyStorageKeys,
16
+ omarchyThemeId,
17
+ omarchyThemeStyleId,
18
+ themeAllows,
19
+ themeStorageKeys,
20
+ type ThemeBootOptions,
21
+ type ThemeChoice,
22
+ type ThemeMode,
23
+ type ThemeScheme,
24
+ } from "./choices.ts";
29
25
  import type { OmarchyCurrentTheme } from "./omarchy.ts";
30
26
  import type { ThemeStore } from "./runtime.ts";
31
27
  import type { Theme } from "./schema.ts";
32
28
  // Only for `document.modelContext`'s type; the tools themselves load lazily
33
29
  import type {} from "./webmcp.ts";
34
30
 
35
- /** How light the page is: `auto` follows the system. */
36
- export type ThemeMode = "auto" | "light" | "dark" | "darker";
37
-
38
- /** The theme id a visitor's own theme is solved under. */
39
- export const customThemeId = "custom";
40
-
41
- /** The id of the `<style>` holding a custom theme's CSS. */
42
- export const customThemeStyleId = "gw-custom-theme";
43
-
44
- /** The theme id the desktop's Omarchy theme is solved under. */
45
- export const omarchyThemeId = "omarchy";
46
-
47
- /** The id of the `<style>` holding the Omarchy theme's CSS. */
48
- export const omarchyThemeStyleId = "gw-omarchy-theme";
31
+ export {
32
+ coreThemeChoices,
33
+ customThemeId,
34
+ customThemeStyleId,
35
+ omarchyThemeId,
36
+ omarchyThemeStyleId,
37
+ optionalThemeChoices,
38
+ optionalThemeDocuments,
39
+ themeAllows,
40
+ themeBootScript,
41
+ themeCovers,
42
+ themeOptions,
43
+ type ThemeBootOptions,
44
+ type ThemeChoice,
45
+ type ThemeMode,
46
+ type ThemeScheme,
47
+ } from "./choices.ts";
49
48
 
50
49
  const modes: readonly ThemeMode[] = ["auto", "light", "dark", "darker"];
51
50
 
52
- /** Light or dark: the side of a mode a theme has to cover. Darker is dark. */
53
- export type ThemeScheme = "light" | "dark";
54
-
55
51
  export interface PageThemeState {
56
52
  readonly mode: ThemeMode;
57
53
  /** The scheme in use, with `auto` resolved from the system. */
58
54
  readonly scheme: ThemeScheme;
59
- /** The theme id in use: a built-in one, or `custom`. */
55
+ /** The theme id in use: a built-in one, `omarchy` or `custom`. */
60
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;
61
63
  /** The visitor's own theme's name, when they've imported one. */
62
64
  readonly custom: string | null;
63
65
  /** The desktop's Omarchy theme as a choice, when the app passed one. */
@@ -73,73 +75,7 @@ export type ImportResult =
73
75
  }
74
76
  | { readonly ok: false; readonly error: string };
75
77
 
76
- /**
77
- * A theme to offer, by id and name. A `Theme` document is one. `mode` marks
78
- * a theme with only one scheme, such as Catppuccin Latte: it's offered and
79
- * kept for that scheme alone, beside a pick for the other.
80
- */
81
- export interface ThemeChoice {
82
- readonly id: string;
83
- readonly name: string;
84
- readonly mode?: ThemeScheme | undefined;
85
- /** The palette an optional theme comes from, such as `Catppuccin`. */
86
- readonly family?: string | undefined;
87
- }
88
-
89
- /** Whether `choice` can show in `scheme`. */
90
- export const themeCovers = (choice: ThemeChoice, scheme: ThemeScheme) =>
91
- choice.mode === undefined || choice.mode === scheme;
92
-
93
- /** The core themes by id and name, in the order the docs list them. */
94
- export const coreThemeChoices: readonly ThemeChoice[] = [
95
- base,
96
- glazing,
97
- survey,
98
- slate,
99
- bridge,
100
- snag,
101
- ].map(({ id, name }) => ({ id, name }));
102
-
103
- /**
104
- * The optional themes' documents, by family and then from light to dark.
105
- * They're bundled with the module, so an app compiled to one binary has them
106
- * without reading `node_modules`. Decode one with the `MappedTheme` Schema.
107
- */
108
- export const optionalThemeDocuments = [
109
- catppuccinLatte,
110
- catppuccinFrappe,
111
- catppuccinMacchiato,
112
- catppuccinMocha,
113
- tokyoNightDay,
114
- tokyoNight,
115
- tokyoNightStorm,
116
- tokyoNightMoon,
117
- github,
118
- githubDimmed,
119
- githubHighContrast,
120
- ] as const;
121
-
122
- /**
123
- * The optional themes, mapped from other palettes, by family and then from
124
- * light to dark. One with a single mode is offered for that scheme alone;
125
- * one with both, such as GitHub's, for either.
126
- */
127
- export const optionalThemeChoices: readonly ThemeChoice[] =
128
- optionalThemeDocuments.map((theme) => ({
129
- id: theme.id,
130
- name: theme.name,
131
- family: theme.family,
132
- mode:
133
- "otherPalette" in theme
134
- ? undefined
135
- : theme.mode === "light"
136
- ? "light"
137
- : "dark",
138
- }));
139
-
140
- export interface PageThemeOptions {
141
- /** The local storage key prefix, such as `my-site:theme`. */
142
- readonly key: string;
78
+ export interface PageThemeOptions extends ThemeBootOptions {
143
79
  /** The built-in themes to offer, their CSS already on the page. */
144
80
  readonly themes: readonly ThemeChoice[];
145
81
  /**
@@ -147,16 +83,6 @@ export interface PageThemeOptions {
147
83
  * register. Defaults to the core themes.
148
84
  */
149
85
  readonly documents?: () => Promise<readonly Theme[]>;
150
- /** The theme id to start with. */
151
- readonly defaultTheme: string;
152
- /** The mode to start with. Defaults to `auto`. */
153
- readonly defaultMode?: ThemeMode;
154
- /**
155
- * Whether the Omarchy theme takes over while the visitor hasn't picked
156
- * one. With `false` it's only offered, and `defaultTheme` stays in use.
157
- * Defaults to `true`.
158
- */
159
- readonly followOmarchy?: boolean;
160
86
  /** The element the attributes go on. Defaults to `<html>`. */
161
87
  readonly root?: HTMLElement;
162
88
  /**
@@ -176,7 +102,9 @@ export interface PageThemeOptions {
176
102
 
177
103
  export interface PageTheme {
178
104
  readonly state: () => PageThemeState;
105
+ /** Sets the mode, unless the theme in use doesn't come in it. */
179
106
  readonly setMode: (mode: ThemeMode) => void;
107
+ /** Picks a theme, moving to its scheme when it has only one. */
180
108
  readonly setTheme: (id: string) => void;
181
109
  /** Solves a theme document's JSON or an Omarchy `colors.toml` and applies it. */
182
110
  readonly importTheme: (text: string) => Promise<ImportResult>;
@@ -184,9 +112,9 @@ export interface PageTheme {
184
112
  readonly reset: () => void;
185
113
  /**
186
114
  * Offers the Omarchy theme the app runs under, or drops it with `null`.
187
- * It's used for its scheme until the visitor picks another, and a theme
188
- * switch on the desktop follows through while it's picked. Apps that can
189
- * 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.
190
118
  */
191
119
  readonly setOmarchyTheme: (
192
120
  theme: OmarchyCurrentTheme | null,
@@ -195,22 +123,6 @@ export interface PageTheme {
195
123
  readonly subscribe: (listener: (state: PageThemeState) => void) => () => void;
196
124
  }
197
125
 
198
- const keys = (key: string) => ({
199
- mode: `${key}:mode`,
200
- theme: `${key}:id`,
201
- dark: `${key}:id-dark`,
202
- document: `${key}:custom`,
203
- css: `${key}:custom-css`,
204
- name: `${key}:custom-name`,
205
- });
206
-
207
- /** The Omarchy theme's cache, kept apart so a reset doesn't drop it. */
208
- const omarchyKeys = (key: string) => ({
209
- css: `${key}:omarchy-css`,
210
- name: `${key}:omarchy-name`,
211
- mode: `${key}:omarchy-mode`,
212
- });
213
-
214
126
  const read = (key: string) => {
215
127
  try {
216
128
  return localStorage.getItem(key);
@@ -228,34 +140,6 @@ const write = (key: string, value: string | null) => {
228
140
  }
229
141
  };
230
142
 
231
- /**
232
- * An inline script that applies the stored mode, theme and any custom or
233
- * Omarchy theme's cached CSS before the first paint. Takes the same key and
234
- * defaults as `pageTheme`.
235
- */
236
- export const themeBootScript = (
237
- options: Pick<
238
- PageThemeOptions,
239
- "key" | "defaultTheme" | "defaultMode" | "followOmarchy"
240
- >,
241
- ) => {
242
- const config = JSON.stringify({
243
- keys: keys(options.key),
244
- omarchy: omarchyKeys(options.key),
245
- theme: options.defaultTheme,
246
- mode: options.defaultMode ?? "auto",
247
- follow: options.followOmarchy !== false,
248
- custom: customThemeId,
249
- style: customThemeStyleId,
250
- omarchyId: omarchyThemeId,
251
- omarchyStyle: omarchyThemeStyleId,
252
- });
253
-
254
- // With nothing picked, a cached Omarchy theme for the scheme is the
255
- // default, unless it's only offered
256
- 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;const a=(id,css)=>{const e=document.createElement("style");e.id=id;e.textContent=css;document.head.append(e)};const m=g(c.keys.mode)??c.mode;const d=m==="dark"||m==="darker"||(m==="auto"&&matchMedia("(prefers-color-scheme: dark)").matches);const o=g(c.omarchy.css);let t=(d?g(c.keys.dark):null)??g(c.keys.theme)??(c.follow&&o&&g(c.omarchy.mode)===(d?"dark":"light")?c.omarchyId:c.theme);if(t===c.custom){const css=g(c.keys.css);if(css)a(c.style,css);else t=c.theme}else if(t===c.omarchyId){if(o)a(c.omarchyStyle,o);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)})();`;
257
- };
258
-
259
143
  /** Reads a theme from JSON text or an Omarchy `colors.toml`. */
260
144
  const readTheme = async (text: string) => {
261
145
  const { Result } = await import("effect");
@@ -269,23 +153,27 @@ const readTheme = async (text: string) => {
269
153
  return omarchyTheme(text);
270
154
  };
271
155
 
156
+ const schemeValue = (value: string | null): ThemeScheme | undefined =>
157
+ value === "light" || value === "dark" ? value : undefined;
158
+
272
159
  /**
273
160
  * Sets up the page's theme from what's stored, falling back to the defaults,
274
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`.
275
167
  */
276
168
  export const pageTheme = (options: PageThemeOptions): PageTheme => {
277
- const stored = keys(options.key);
278
- const storedOmarchy = omarchyKeys(options.key);
169
+ const stored = themeStorageKeys(options.key);
170
+ const storedOmarchy = omarchyStorageKeys(options.key);
279
171
  const root = options.root ?? document.documentElement;
280
172
  const defaultMode = options.defaultMode ?? "auto";
281
173
  const listeners = new Set<(state: PageThemeState) => void>();
282
174
 
283
175
  const known = (id: string) => options.themes.some((theme) => theme.id === id);
284
176
 
285
- const storedMode = read(stored.mode);
286
- const customName = read(stored.name);
287
- const customCss = read(stored.css);
288
-
289
177
  const setStyle = (id: string, css: string | null) => {
290
178
  let style = document.getElementById(id);
291
179
 
@@ -310,13 +198,9 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
310
198
  {
311
199
  const css = read(storedOmarchy.css);
312
200
  const name = read(storedOmarchy.name);
313
- const mode = read(storedOmarchy.mode);
201
+ const mode = schemeValue(read(storedOmarchy.mode));
314
202
 
315
- if (
316
- css !== null &&
317
- name !== null &&
318
- (mode === "light" || mode === "dark")
319
- ) {
203
+ if (css !== null && name !== null && mode !== undefined) {
320
204
  omarchy = { id: omarchyThemeId, name, mode };
321
205
 
322
206
  // The boot script may have put it on already
@@ -326,8 +210,24 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
326
210
  }
327
211
  }
328
212
 
329
- const choices = () =>
330
- 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);
331
231
 
332
232
  const system = matchMedia("(prefers-color-scheme: dark)");
333
233
 
@@ -338,59 +238,46 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
338
238
  ? "light"
339
239
  : "dark";
340
240
 
341
- /** Whether `id` can be the pick for `scheme`. */
342
- const fits = (id: string | null, scheme: ThemeScheme, custom: boolean) =>
343
- id !== null &&
344
- ((id === customThemeId && custom) ||
345
- choices().some((theme) => theme.id === id && themeCovers(theme, scheme)));
346
-
347
- // The Omarchy theme comes first where it covers the scheme, so the app
348
- // matches the desktop until the visitor picks another, unless it's only
349
- // offered
350
- const fallback = (scheme: ThemeScheme) =>
351
- options.followOmarchy !== false &&
352
- omarchy !== null &&
353
- themeCovers(omarchy, scheme)
354
- ? omarchy.id
355
- : options.themes.some(
356
- (theme) =>
357
- theme.id === options.defaultTheme && themeCovers(theme, scheme),
358
- )
359
- ? options.defaultTheme
360
- : (options.themes.find((theme) => themeCovers(theme, scheme))?.id ??
361
- options.defaultTheme);
362
-
363
- const storedLight = read(stored.theme);
364
- const storedDark = read(stored.dark) ?? storedLight;
365
-
366
- /** The theme kept for each scheme. */
367
- let picks: Record<ThemeScheme, string> = {
368
- light: fits(storedLight, "light", customCss !== null)
369
- ? (storedLight ?? options.defaultTheme)
370
- : fallback("light"),
371
- dark: fits(storedDark, "dark", customCss !== null)
372
- ? (storedDark ?? options.defaultTheme)
373
- : 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
+ };
374
259
  };
375
260
 
376
- const savePicks = () => {
377
- write(stored.theme, picks.light);
378
- write(stored.dark, picks.dark);
379
- };
380
-
381
- const resolve = (mode: ThemeMode, custom: string | null): PageThemeState => {
382
- const scheme = schemeOf(mode);
261
+ /** The default, as `reset` and a vanished pick go back to. */
262
+ const fallback = () => resolve(options.defaultTheme, defaultMode);
383
263
 
384
- return { mode, scheme, theme: picks[scheme], custom, omarchy };
385
- };
264
+ let state: PageThemeState = (() => {
265
+ const theme = read(stored.theme);
266
+ const mode = modes.find((entry) => entry === read(stored.mode));
386
267
 
387
- let state: PageThemeState = resolve(
388
- modes.find((mode) => mode === storedMode) ?? defaultMode,
389
- customCss === null ? null : customName,
390
- );
268
+ return theme !== null && choiceOf(theme) !== undefined
269
+ ? resolve(theme, mode ?? defaultMode)
270
+ : resolve(options.defaultTheme, mode ?? defaultMode);
271
+ })();
391
272
 
392
- const commit = (next: PageThemeState) => {
273
+ const commit = (next: PageThemeState, save = true) => {
393
274
  state = next;
275
+
276
+ if (save) {
277
+ write(stored.theme, state.theme);
278
+ write(stored.mode, state.mode);
279
+ }
280
+
394
281
  root.setAttribute("data-gw-theme", state.theme);
395
282
 
396
283
  if (state.mode === "auto") root.removeAttribute("data-gw-mode");
@@ -399,8 +286,15 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
399
286
  for (const listener of listeners) listener(state);
400
287
  };
401
288
 
402
- /** Solves a document under the custom id, applies it and keeps it. */
403
- 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
+ ) => {
404
298
  const { appTonesCss, runtimeTheme } = await import("./runtime.ts");
405
299
  const runtime = runtimeTheme(document, customThemeId);
406
300
 
@@ -409,13 +303,26 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
409
303
  ? runtime.css
410
304
  : `${runtime.css}\n\n${appTonesCss(options.tones, runtime.theme)}`;
411
305
 
306
+ const scheme =
307
+ runtime.theme.modes.light === undefined
308
+ ? "dark"
309
+ : runtime.theme.modes.dark === undefined
310
+ ? "light"
311
+ : undefined;
312
+
412
313
  setStyle(customThemeStyleId, css);
413
314
  write(stored.document, text);
414
315
  write(stored.css, css);
415
316
  write(stored.name, document.name);
416
- picks = { light: customThemeId, dark: customThemeId };
417
- savePicks();
418
- 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
+ );
419
326
 
420
327
  if (keep) await options.store?.save(text).catch(() => undefined);
421
328
 
@@ -427,45 +334,41 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
427
334
  );
428
335
  };
429
336
 
430
- const setTheme = (id: string) => {
431
- if (id === customThemeId) {
432
- if (state.custom === null) return;
433
-
434
- picks = { light: id, dark: id };
435
- } else {
436
- const choice = choices().find((theme) => theme.id === id);
437
-
438
- if (choice === undefined) return;
439
-
440
- picks =
441
- choice.mode === undefined
442
- ? { light: id, dark: id }
443
- : { ...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
+ }
444
349
 
445
- // Picking a light-only theme in dark, or the reverse, shows it
446
- if (choice.mode !== undefined && choice.mode !== state.scheme) {
447
- savePicks();
448
- write(stored.mode, choice.mode);
449
- commit(resolve(choice.mode, state.custom));
350
+ custom = null;
351
+ };
450
352
 
451
- return;
452
- }
453
- }
353
+ const setTheme = (id: string) => {
354
+ if (choiceOf(id) === undefined) return;
454
355
 
455
- savePicks();
456
- commit(resolve(state.mode, state.custom));
356
+ commit(resolve(id, state.mode));
457
357
  };
458
358
 
459
- // A system change moves `auto` between the light and dark picks
359
+ // A system change moves `auto` between light and dark
460
360
  system.addEventListener("change", () => {
461
- if (state.mode === "auto") commit(resolve(state.mode, state.custom));
361
+ if (state.mode === "auto") commit(resolve(state.theme, state.mode), false);
462
362
  });
463
363
 
464
364
  const page: PageTheme = {
465
365
  state: () => state,
466
366
  setMode: (mode) => {
467
- write(stored.mode, mode);
468
- 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));
469
372
  },
470
373
  setTheme,
471
374
  importTheme: async (text) => {
@@ -479,32 +382,22 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
479
382
  };
480
383
  }
481
384
 
482
- const misses = await applyCustom(parsed.success, text);
385
+ const misses = await applyCustom(parsed.success, text, {
386
+ pick: true,
387
+ keep: true,
388
+ });
483
389
 
484
390
  return { ok: true, name: parsed.success.name, misses };
485
391
  },
486
392
  reset: () => {
487
- setStyle(customThemeStyleId, null);
488
-
489
- for (const key of Object.values(stored)) write(key, null);
490
-
491
- picks = { light: fallback("light"), dark: fallback("dark") };
492
- commit(resolve(defaultMode, null));
393
+ dropCustom();
394
+ write(stored.theme, null);
395
+ write(stored.mode, null);
396
+ commit(fallback(), false);
493
397
  void options.store?.clear().catch(() => undefined);
494
398
  },
495
399
  setOmarchyTheme: async (theme) => {
496
- // Stored as well as in use, so it's picked again when it comes back
497
- const followed = [
498
- picks.light,
499
- picks.dark,
500
- read(stored.theme),
501
- read(stored.dark),
502
- ].includes(omarchyThemeId);
503
-
504
- const unpicked =
505
- options.followOmarchy !== false && read(stored.theme) === null;
506
-
507
- let mode: ThemeScheme | undefined;
400
+ let next: ThemeChoice | null = null;
508
401
 
509
402
  if (theme !== null) {
510
403
  const { Result } = await import("effect");
@@ -523,42 +416,32 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
523
416
  ? runtime.css
524
417
  : `${runtime.css}\n\n${appTonesCss(options.tones, runtime.theme)}`;
525
418
 
526
- mode = parsed.success.mode;
527
419
  setStyle(omarchyThemeStyleId, css);
528
420
  write(storedOmarchy.css, css);
529
421
  write(storedOmarchy.name, name);
530
- write(storedOmarchy.mode, mode);
531
- omarchy = { id: omarchyThemeId, name, mode };
422
+ write(storedOmarchy.mode, parsed.success.mode);
423
+ next = { id: omarchyThemeId, name, mode: parsed.success.mode };
532
424
  }
533
425
  }
534
426
 
535
- if (mode === undefined) {
536
- // Gone, or a colors.toml that doesn't map: back off it, keeping the
537
- // pick so it comes back with the theme
427
+ if (next === null) {
428
+ // Gone, or a colors.toml that doesn't map
538
429
  setStyle(omarchyThemeStyleId, null);
539
430
 
540
431
  for (const key of Object.values(storedOmarchy)) write(key, null);
541
-
542
- omarchy = null;
543
- picks = {
544
- light:
545
- picks.light === omarchyThemeId ? fallback("light") : picks.light,
546
- dark: picks.dark === omarchyThemeId ? fallback("dark") : picks.dark,
547
- };
548
- } else if (followed || unpicked) {
549
- // A switch between a light and a dark Omarchy theme moves it across
550
- const keep = (scheme: ThemeScheme) =>
551
- picks[scheme] === omarchyThemeId ? fallback(scheme) : picks[scheme];
552
-
553
- picks =
554
- mode === "dark"
555
- ? { light: keep("light"), dark: omarchyThemeId }
556
- : { light: omarchyThemeId, dark: keep("dark") };
557
-
558
- if (!unpicked) savePicks();
559
432
  }
560
433
 
561
- 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
+ );
562
445
  },
563
446
  subscribe: (listener) => {
564
447
  listeners.add(listener);
@@ -569,10 +452,14 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
569
452
  },
570
453
  };
571
454
 
572
- 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);
573
459
 
574
- // The store's theme wins over this browser's: another client may have
575
- // 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
576
463
  if (options.store !== undefined) {
577
464
  void options.store
578
465
  .load()
@@ -580,19 +467,12 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
580
467
  if (text === read(stored.document)) return;
581
468
 
582
469
  if (text === null) {
583
- setStyle(customThemeStyleId, null);
584
-
585
- for (const key of [stored.document, stored.css, stored.name]) {
586
- write(key, null);
587
- }
588
-
589
- picks = {
590
- light:
591
- picks.light === customThemeId ? fallback("light") : picks.light,
592
- dark: picks.dark === customThemeId ? fallback("dark") : picks.dark,
593
- };
594
- savePicks();
595
- commit(resolve(state.mode, null));
470
+ dropCustom();
471
+ commit(
472
+ state.theme === customThemeId
473
+ ? fallback()
474
+ : resolve(state.theme, state.mode),
475
+ );
596
476
 
597
477
  return;
598
478
  }
@@ -601,7 +481,7 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
601
481
  const parsed = await readTheme(text);
602
482
 
603
483
  if (Result.isSuccess(parsed)) {
604
- await applyCustom(parsed.success, text, false);
484
+ await applyCustom(parsed.success, text, { pick: false, keep: false });
605
485
  }
606
486
  })
607
487
  .catch(() => undefined);
@@ -628,7 +508,7 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
628
508
  },
629
509
  apply: ({ document: theme, text }) => {
630
510
  if (documents.includes(theme)) setTheme(theme.id);
631
- else void applyCustom(theme, text);
511
+ else void applyCustom(theme, text, { pick: true, keep: true });
632
512
  },
633
513
  });
634
514
  },