@timmo001/groundwork-tokens 0.3.0 → 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.
@@ -880,7 +880,7 @@ export const themes = [
880
880
  {
881
881
  "id": "bridge",
882
882
  "name": "Bridge",
883
- "description": "A clear, bright blue on neutral charcoal. Medium radii, soft borders and pill chips.",
883
+ "description": "System Bridge's violet on neutral charcoal. Medium radii, soft borders and pill chips.",
884
884
  "optional": false,
885
885
  "family": null,
886
886
  "examples": [
@@ -977,15 +977,15 @@ export const themes = [
977
977
  "fill-primary-hovered": "#2e2e2f",
978
978
  "fill-primary-pressed": "#383839",
979
979
  "on-primary": "#fdfdff",
980
- "accent": "#1465b9",
981
- "fill-accent-resting": "#1465b9",
982
- "fill-accent-hovered": "#0057a8",
983
- "fill-accent-pressed": "#1f6dc1",
984
- "fill-accent-quiet": "#1465b929",
985
- "border-accent": "#1465b980",
980
+ "accent": "#7152bb",
981
+ "fill-accent-resting": "#7152bb",
982
+ "fill-accent-hovered": "#6343ab",
983
+ "fill-accent-pressed": "#785ac4",
984
+ "fill-accent-quiet": "#7152bb29",
985
+ "border-accent": "#7152bb80",
986
986
  "on-accent": "#ffffff",
987
- "focus": "#1465b9",
988
- "link": "#1465b9",
987
+ "focus": "#7152bb",
988
+ "link": "#7152bb",
989
989
  "info": "#276baa",
990
990
  "fill-info-quiet": "#276baa24",
991
991
  "border-info": "#276baa73",
@@ -1024,15 +1024,15 @@ export const themes = [
1024
1024
  "fill-primary-hovered": "#d4d4d6",
1025
1025
  "fill-primary-pressed": "#c7c7c9",
1026
1026
  "on-primary": "#17171b",
1027
- "accent": "#549ff7",
1028
- "fill-accent-resting": "#549ff7",
1029
- "fill-accent-hovered": "#6bafff",
1030
- "fill-accent-pressed": "#4c97ee",
1031
- "fill-accent-quiet": "#549ff729",
1032
- "border-accent": "#549ff780",
1027
+ "accent": "#a78bfa",
1028
+ "fill-accent-resting": "#a78bfa",
1029
+ "fill-accent-hovered": "#b69eff",
1030
+ "fill-accent-pressed": "#9f83f1",
1031
+ "fill-accent-quiet": "#a78bfa29",
1032
+ "border-accent": "#a78bfa80",
1033
1033
  "on-accent": "#0b0b0b",
1034
- "focus": "#549ff7",
1035
- "link": "#549ff7",
1034
+ "focus": "#a78bfa",
1035
+ "link": "#a78bfa",
1036
1036
  "info": "#5b9ee0",
1037
1037
  "fill-info-quiet": "#5b9ee024",
1038
1038
  "border-info": "#5b9ee073",
@@ -1071,15 +1071,15 @@ export const themes = [
1071
1071
  "fill-primary-hovered": "#d4d4d6",
1072
1072
  "fill-primary-pressed": "#c7c7c9",
1073
1073
  "on-primary": "#141418",
1074
- "accent": "#549ff7",
1075
- "fill-accent-resting": "#549ff7",
1076
- "fill-accent-hovered": "#6bafff",
1077
- "fill-accent-pressed": "#4c97ee",
1078
- "fill-accent-quiet": "#549ff729",
1079
- "border-accent": "#549ff780",
1074
+ "accent": "#a78bfa",
1075
+ "fill-accent-resting": "#a78bfa",
1076
+ "fill-accent-hovered": "#b69eff",
1077
+ "fill-accent-pressed": "#9f83f1",
1078
+ "fill-accent-quiet": "#a78bfa29",
1079
+ "border-accent": "#a78bfa80",
1080
1080
  "on-accent": "#0b0b0b",
1081
- "focus": "#549ff7",
1082
- "link": "#549ff7",
1081
+ "focus": "#a78bfa",
1082
+ "link": "#a78bfa",
1083
1083
  "info": "#5b9ee0",
1084
1084
  "fill-info-quiet": "#5b9ee024",
1085
1085
  "border-info": "#5b9ee073",
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-tokens",
3
- "version": "0.3.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",
11
+ "./webmcp": "./src/webmcp.ts",
12
+ "./opentui": "./generated/opentui.ts",
10
13
  "./themes.css": "./generated/themes.css",
11
14
  "./themes.json": "./generated/themes.json"
12
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)",
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Tests the Omarchy import against a real `colors.toml` shape, in both modes,
3
+ * and that a file missing a color fails rather than half-applies.
4
+ *
5
+ * @module
6
+ */
7
+ import { expect, test } from "bun:test";
8
+ import { Result } from "effect";
9
+ import { omarchyTheme } from "./omarchy.ts";
10
+
11
+ const dark = `mode = "dark"
12
+
13
+ accent = "#7aa2f7"
14
+ selection = "#292e42"
15
+ muted = "#414868"
16
+
17
+ background = "#1a1b26"
18
+ dark_background = "#13141c"
19
+ lighter_background = "#24283b"
20
+
21
+ foreground = "#a9b1d6"
22
+ light_foreground = "#b4bee6"
23
+ bright_foreground = "#c0caf5"
24
+
25
+ red = "#f7768e"
26
+ yellow = "#e0af68"
27
+ green = "#9ece6a"
28
+ blue = "#7aa2f7"
29
+ magenta = "#ad8ee6"
30
+ `;
31
+
32
+ test("maps a dark colors.toml onto the roles", () => {
33
+ const theme = Result.getOrThrow(omarchyTheme(dark, "Tokyo Night", "tokyo"));
34
+
35
+ expect(theme.mode).toBe("dark");
36
+ expect(theme.palette).toMatchObject({
37
+ lower: "#13141c",
38
+ background: "#1a1b26",
39
+ raised: "#24283b",
40
+ border: "#414868",
41
+ text: "#c0caf5",
42
+ muted: "#a9b1d6",
43
+ danger: "#f7768e",
44
+ });
45
+ });
46
+
47
+ test("uses the light foreground as muted when foreground is the text", () => {
48
+ const light = dark
49
+ .replace('mode = "dark"', 'mode = "light"')
50
+ .replace("#a9b1d6", "#C0CAF5")
51
+ .replace("#b4bee6", "#403e3c");
52
+
53
+ const theme = Result.getOrThrow(omarchyTheme(light));
54
+
55
+ expect(theme.mode).toBe("light");
56
+ expect(theme.palette.muted).toBe("#403e3c");
57
+ });
58
+
59
+ test("fails when a color is missing", () => {
60
+ expect(Result.isFailure(omarchyTheme(dark.replace(/^red = .*$/m, "")))).toBe(
61
+ true,
62
+ );
63
+ });
package/src/omarchy.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Omarchy theme import: reads an Omarchy theme's `colors.toml` and maps it
3
+ * onto a Groundwork mapped theme, so an app can follow the desktop's theme.
4
+ * It parses the flat `key = "value"` lines itself, so it runs in a browser.
5
+ *
6
+ * @module
7
+ */
8
+ import { Schema } from "effect";
9
+ import { MappedTheme } from "./schema.ts";
10
+
11
+ /** The `key = "value"` pairs of a flat TOML file, ignoring comments. */
12
+ const readPairs = (text: string): ReadonlyMap<string, string> => {
13
+ const pairs = new Map<string, string>();
14
+
15
+ for (const line of text.split(/\r?\n/)) {
16
+ const match = /^\s*([A-Za-z_][\w-]*)\s*=\s*"([^"]*)"/.exec(line);
17
+
18
+ if (match?.[1] !== undefined && match[2] !== undefined) {
19
+ pairs.set(match[1], match[2]);
20
+ }
21
+ }
22
+
23
+ return pairs;
24
+ };
25
+
26
+ /**
27
+ * Maps an Omarchy `colors.toml` onto a mapped theme's palette, then checks it
28
+ * against the Schema. Missing keys or bad colors come back as a `Failure`.
29
+ */
30
+ export const omarchyTheme = (
31
+ text: string,
32
+ name = "Omarchy",
33
+ id = "omarchy",
34
+ ) => {
35
+ const pairs = readPairs(text);
36
+ const color = (key: string) => pairs.get(key)?.toLowerCase();
37
+ const text_ = color("bright_foreground") ?? color("foreground");
38
+ const foreground = color("foreground");
39
+
40
+ return Schema.decodeUnknownResult(MappedTheme)({
41
+ kind: "mapped",
42
+ id,
43
+ name,
44
+ family: "Omarchy",
45
+ mode: pairs.get("mode") === "light" ? "light" : "dark",
46
+ palette: {
47
+ lower: color("dark_background"),
48
+ background: color("background"),
49
+ raised: color("lighter_background"),
50
+ border: color("muted") ?? color("selection"),
51
+ text: text_,
52
+ // Omarchy's foreground is the body text; when it's the same as the
53
+ // brightest text, its light foreground is the quieter one
54
+ muted:
55
+ foreground !== undefined && foreground !== text_
56
+ ? foreground
57
+ : color("light_foreground"),
58
+ accent: color("accent"),
59
+ info: color("blue"),
60
+ success: color("green"),
61
+ important: color("magenta"),
62
+ warning: color("yellow"),
63
+ danger: color("red"),
64
+ },
65
+ });
66
+ };
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
+ };
package/src/runtime.ts CHANGED
@@ -11,6 +11,8 @@ import { themeCss } from "./generate.ts";
11
11
  import { SolvedTheme, Theme } from "./schema.ts";
12
12
  import { check, resolve, type Check, type ResolvedTheme } from "./solve.ts";
13
13
 
14
+ export { omarchyTheme } from "./omarchy.ts";
15
+
14
16
  const base = Schema.decodeUnknownSync(SolvedTheme)(baseDocument);
15
17
 
16
18
  /** A theme solved at runtime, with its CSS and every contrast check. */
@@ -34,3 +36,44 @@ export const runtimeTheme = (theme: Theme, id = theme.id): RuntimeTheme => {
34
36
 
35
37
  return { theme: resolved, css: themeCss(resolved), checks: check(resolved) };
36
38
  };
39
+
40
+ /**
41
+ * Where an app keeps a user's theme document. The document is stored, not
42
+ * its CSS, so it's solved again when the solver changes. Local storage is
43
+ * the first tier; an app with a profile or a host, such as System Bridge,
44
+ * passes its own store with the same shape.
45
+ */
46
+ export interface ThemeStore {
47
+ /** The stored document's JSON text, or `null` when there's none. */
48
+ readonly load: () => Promise<string | null>;
49
+ readonly save: (text: string) => Promise<void>;
50
+ readonly clear: () => Promise<void>;
51
+ }
52
+
53
+ /**
54
+ * A store in the browser's local storage under `key`. It reads as empty and
55
+ * skips writes when storage is blocked, so a theme still applies for the visit.
56
+ */
57
+ export const localThemeStore = (key: string): ThemeStore => ({
58
+ load: async () => {
59
+ try {
60
+ return localStorage.getItem(key);
61
+ } catch {
62
+ return null;
63
+ }
64
+ },
65
+ save: async (text) => {
66
+ try {
67
+ localStorage.setItem(key, text);
68
+ } catch {
69
+ // Blocked or full: the theme still applies until the page closes
70
+ }
71
+ },
72
+ clear: async () => {
73
+ try {
74
+ localStorage.removeItem(key);
75
+ } catch {
76
+ // Nothing to clear when storage is blocked
77
+ }
78
+ },
79
+ });