@timmo001/groundwork-tokens 0.9.31 → 0.9.33

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/README.md CHANGED
@@ -31,5 +31,16 @@ their own:
31
31
  import { parseTheme, runtimeTheme } from "@timmo001/groundwork-tokens/runtime";
32
32
  ```
33
33
 
34
+ The `omarchy-current` export reads and watches the Omarchy theme a machine
35
+ is using, for apps outside a browser, such as a desktop app's main process or
36
+ a terminal UI. `pageTheme`'s `setOmarchyTheme` offers it in the view:
37
+
38
+ ```ts
39
+ import {
40
+ readOmarchyTheme,
41
+ watchOmarchyTheme,
42
+ } from "@timmo001/groundwork-tokens/omarchy-current";
43
+ ```
44
+
34
45
  The themes, their roles and contrast targets are documented at
35
46
  [groundwork.timmo.dev/themes](https://groundwork.timmo.dev/themes).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-tokens",
3
- "version": "0.9.31",
3
+ "version": "0.9.33",
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",
@@ -9,6 +9,7 @@
9
9
  "./runtime": "./src/runtime.ts",
10
10
  "./page": "./src/page.ts",
11
11
  "./webmcp": "./src/webmcp.ts",
12
+ "./omarchy-current": "./src/omarchy-current.ts",
12
13
  "./opentui": "./generated/opentui.ts",
13
14
  "./themes.css": "./generated/themes.css",
14
15
  "./themes.json": "./generated/themes.json",
@@ -47,10 +48,10 @@
47
48
  "dependencies": {
48
49
  "@types/culori": "4.0.1",
49
50
  "culori": "4.0.2",
50
- "effect": "4.1.1"
51
+ "effect": "4.1.2"
51
52
  },
52
53
  "devDependencies": {
53
- "@effect/platform-node": "4.1.1",
54
+ "@effect/platform-node": "4.1.2",
54
55
  "@oxlint/plugins": "1.90.0",
55
56
  "@timmo001/oxlint-rules": "0.6.3",
56
57
  "@types/bun": "1.4.4",
package/src/generate.ts CHANGED
@@ -554,7 +554,8 @@ const hexRoles = (roles: Roles) =>
554
554
  Object.entries(roles).map(([role, color]) => [role, toHex(color)]),
555
555
  );
556
556
 
557
- const hexModes = (theme: ResolvedTheme) =>
557
+ /** A solved theme's roles as hex in each mode it has, as terminals take them. */
558
+ export const hexModes = (theme: ResolvedTheme) =>
558
559
  Object.fromEntries(
559
560
  modes.flatMap((mode) => {
560
561
  const roles = theme.modes[mode];
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The Omarchy theme a machine is using, for apps that run outside a browser,
3
+ * such as a desktop app's main process or a terminal UI. It reads the theme's
4
+ * name and `colors.toml` from Omarchy's state folder and watches for a
5
+ * switch. It needs a file system, so it's an entry of its own; map the colors
6
+ * with `omarchyTheme` from the runtime entry.
7
+ *
8
+ * @module
9
+ */
10
+ import { watch, type FSWatcher } from "node:fs";
11
+ import { readFile } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import type { OmarchyCurrentTheme } from "./omarchy.ts";
16
+
17
+ export type { OmarchyCurrentTheme } from "./omarchy.ts";
18
+
19
+ /** Omarchy's `current` folder, in `$XDG_STATE_HOME`, by default `~/.local/state`. */
20
+ export const omarchyCurrentPath = () =>
21
+ join(
22
+ process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"),
23
+ "omarchy",
24
+ "current",
25
+ );
26
+
27
+ // `tokyo-night` reads as `Tokyo Night`, as `omarchy theme current` shows it
28
+ const displayName = (slug: string) =>
29
+ slug
30
+ .trim()
31
+ .split("-")
32
+ .filter((word) => word !== "")
33
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
34
+ .join(" ");
35
+
36
+ /** The Omarchy theme in use, or `null` where there's no Omarchy. */
37
+ export const readOmarchyTheme =
38
+ async (): Promise<OmarchyCurrentTheme | null> => {
39
+ const current = omarchyCurrentPath();
40
+
41
+ try {
42
+ const colors = await readFile(
43
+ join(current, "theme", "colors.toml"),
44
+ "utf8",
45
+ );
46
+
47
+ const slug = await readFile(join(current, "theme.name"), "utf8").catch(
48
+ () => "",
49
+ );
50
+
51
+ return { name: displayName(slug) || "Omarchy", colors };
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
56
+
57
+ const watchFolder = (path: string, onEvent: () => void) => {
58
+ try {
59
+ return watch(path, onEvent).on("error", () => undefined);
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ };
64
+
65
+ /**
66
+ * Calls `onChange` with the theme each time Omarchy switches it, or with
67
+ * `null` when it goes. Returns a function that stops watching.
68
+ */
69
+ export const watchOmarchyTheme = (
70
+ onChange: (theme: OmarchyCurrentTheme | null) => void,
71
+ ): (() => void) => {
72
+ const current = omarchyCurrentPath();
73
+ let last: string | undefined;
74
+ let timer: ReturnType<typeof setTimeout> | undefined;
75
+ let themeWatcher: FSWatcher | undefined;
76
+
77
+ void readOmarchyTheme().then((theme) => {
78
+ last ??= JSON.stringify(theme);
79
+ });
80
+
81
+ // A switch replaces the theme folder, so its watcher is armed again each time
82
+ const armTheme = () => {
83
+ themeWatcher?.close();
84
+ themeWatcher = watchFolder(join(current, "theme"), changed);
85
+ };
86
+
87
+ // A switch writes several files; read once they've settled
88
+ function changed() {
89
+ clearTimeout(timer);
90
+ timer = setTimeout(async () => {
91
+ armTheme();
92
+ const theme = await readOmarchyTheme();
93
+ const key = JSON.stringify(theme);
94
+
95
+ if (key === last) return;
96
+ last = key;
97
+ onChange(theme);
98
+ }, 250);
99
+ }
100
+
101
+ const currentWatcher = watchFolder(current, changed);
102
+
103
+ armTheme();
104
+
105
+ return () => {
106
+ clearTimeout(timer);
107
+ currentWatcher?.close();
108
+ themeWatcher?.close();
109
+ };
110
+ };
package/src/omarchy.ts CHANGED
@@ -8,6 +8,15 @@
8
8
  import { Schema } from "effect";
9
9
  import { MappedTheme } from "./schema.ts";
10
10
 
11
+ /**
12
+ * The Omarchy theme a machine is using: its name, such as `Tokyo Night`,
13
+ * and the text of its `colors.toml`.
14
+ */
15
+ export interface OmarchyCurrentTheme {
16
+ readonly name: string;
17
+ readonly colors: string;
18
+ }
19
+
11
20
  /** The `key = "value"` pairs of a flat TOML file, ignoring comments. */
12
21
  const readPairs = (text: string): ReadonlyMap<string, string> => {
13
22
  const pairs = new Map<string, string>();
package/src/page.ts CHANGED
@@ -15,6 +15,7 @@ import glazing from "../themes/glazing.json" with { type: "json" };
15
15
  import slate from "../themes/slate.json" with { type: "json" };
16
16
  import snag from "../themes/snag.json" with { type: "json" };
17
17
  import survey from "../themes/survey.json" with { type: "json" };
18
+ import type { OmarchyCurrentTheme } from "./omarchy.ts";
18
19
  import type { ThemeStore } from "./runtime.ts";
19
20
  import type { Theme } from "./schema.ts";
20
21
  // Only for `document.modelContext`'s type; the tools themselves load lazily
@@ -29,6 +30,12 @@ export const customThemeId = "custom";
29
30
  /** The id of the `<style>` holding a custom theme's CSS. */
30
31
  export const customThemeStyleId = "gw-custom-theme";
31
32
 
33
+ /** The theme id the desktop's Omarchy theme is solved under. */
34
+ export const omarchyThemeId = "omarchy";
35
+
36
+ /** The id of the `<style>` holding the Omarchy theme's CSS. */
37
+ export const omarchyThemeStyleId = "gw-omarchy-theme";
38
+
32
39
  const modes: readonly ThemeMode[] = ["auto", "light", "dark", "darker"];
33
40
 
34
41
  /** Light or dark: the side of a mode a theme has to cover. Darker is dark. */
@@ -42,6 +49,8 @@ export interface PageThemeState {
42
49
  readonly theme: string;
43
50
  /** The visitor's own theme's name, when they've imported one. */
44
51
  readonly custom: string | null;
52
+ /** The desktop's Omarchy theme as a choice, when the app passed one. */
53
+ readonly omarchy: ThemeChoice | null;
45
54
  }
46
55
 
47
56
  /** What `importTheme` reports: the theme's name and any contrast misses, or why it failed. */
@@ -117,6 +126,15 @@ export interface PageTheme {
117
126
  readonly importTheme: (text: string) => Promise<ImportResult>;
118
127
  /** Drops the visitor's own theme and goes back to the defaults. */
119
128
  readonly reset: () => void;
129
+ /**
130
+ * Offers the Omarchy theme the app runs under, or drops it with `null`.
131
+ * It's used for its scheme until the visitor picks another, and a theme
132
+ * switch on the desktop follows through while it's picked. Apps that can
133
+ * read it, such as desktop apps, pass it on start and on every switch.
134
+ */
135
+ readonly setOmarchyTheme: (
136
+ theme: OmarchyCurrentTheme | null,
137
+ ) => Promise<void>;
120
138
  /** Calls `listener` on every change; returns a function that stops it. */
121
139
  readonly subscribe: (listener: (state: PageThemeState) => void) => () => void;
122
140
  }
@@ -130,6 +148,13 @@ const keys = (key: string) => ({
130
148
  name: `${key}:custom-name`,
131
149
  });
132
150
 
151
+ /** The Omarchy theme's cache, kept apart so a reset doesn't drop it. */
152
+ const omarchyKeys = (key: string) => ({
153
+ css: `${key}:omarchy-css`,
154
+ name: `${key}:omarchy-name`,
155
+ mode: `${key}:omarchy-mode`,
156
+ });
157
+
133
158
  const read = (key: string) => {
134
159
  try {
135
160
  return localStorage.getItem(key);
@@ -148,24 +173,26 @@ const write = (key: string, value: string | null) => {
148
173
  };
149
174
 
150
175
  /**
151
- * An inline script that applies the stored mode, theme and any custom theme's
152
- * cached CSS before the first paint. Takes the same key and defaults as
153
- * `pageTheme`.
176
+ * An inline script that applies the stored mode, theme and any custom or
177
+ * Omarchy theme's cached CSS before the first paint. Takes the same key and
178
+ * defaults as `pageTheme`.
154
179
  */
155
180
  export const themeBootScript = (
156
181
  options: Pick<PageThemeOptions, "key" | "defaultTheme" | "defaultMode">,
157
182
  ) => {
158
- const stored = keys(options.key);
159
-
160
183
  const config = JSON.stringify({
161
- keys: stored,
184
+ keys: keys(options.key),
185
+ omarchy: omarchyKeys(options.key),
162
186
  theme: options.defaultTheme,
163
187
  mode: options.defaultMode ?? "auto",
164
188
  custom: customThemeId,
165
189
  style: customThemeStyleId,
190
+ omarchyId: omarchyThemeId,
191
+ omarchyStyle: omarchyThemeStyleId,
166
192
  });
167
193
 
168
- 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 m=g(c.keys.mode)??c.mode;const d=m==="dark"||m==="darker"||(m==="auto"&&matchMedia("(prefers-color-scheme: dark)").matches);let t=(d?g(c.keys.dark):null)??g(c.keys.theme)??c.theme;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)})();`;
194
+ // With nothing picked, a cached Omarchy theme for the scheme is the default
195
+ 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)??(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)})();`;
169
196
  };
170
197
 
171
198
  /** Reads a theme from JSON text or an Omarchy `colors.toml`. */
@@ -187,6 +214,7 @@ const readTheme = async (text: string) => {
187
214
  */
188
215
  export const pageTheme = (options: PageThemeOptions): PageTheme => {
189
216
  const stored = keys(options.key);
217
+ const storedOmarchy = omarchyKeys(options.key);
190
218
  const root = options.root ?? document.documentElement;
191
219
  const defaultMode = options.defaultMode ?? "auto";
192
220
  const listeners = new Set<(state: PageThemeState) => void>();
@@ -197,6 +225,49 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
197
225
  const customName = read(stored.name);
198
226
  const customCss = read(stored.css);
199
227
 
228
+ const setStyle = (id: string, css: string | null) => {
229
+ let style = document.getElementById(id);
230
+
231
+ if (css === null) {
232
+ style?.remove();
233
+
234
+ return;
235
+ }
236
+
237
+ if (style === null) {
238
+ style = document.createElement("style");
239
+ style.id = id;
240
+ document.head.append(style);
241
+ }
242
+
243
+ style.textContent = css;
244
+ };
245
+
246
+ /** The Omarchy theme as a choice, from the last one the app passed. */
247
+ let omarchy: ThemeChoice | null = null;
248
+
249
+ {
250
+ const css = read(storedOmarchy.css);
251
+ const name = read(storedOmarchy.name);
252
+ const mode = read(storedOmarchy.mode);
253
+
254
+ if (
255
+ css !== null &&
256
+ name !== null &&
257
+ (mode === "light" || mode === "dark")
258
+ ) {
259
+ omarchy = { id: omarchyThemeId, name, mode };
260
+
261
+ // The boot script may have put it on already
262
+ if (document.getElementById(omarchyThemeStyleId) === null) {
263
+ setStyle(omarchyThemeStyleId, css);
264
+ }
265
+ }
266
+ }
267
+
268
+ const choices = () =>
269
+ omarchy === null ? options.themes : [...options.themes, omarchy];
270
+
200
271
  const system = matchMedia("(prefers-color-scheme: dark)");
201
272
 
202
273
  const schemeOf = (mode: ThemeMode): ThemeScheme =>
@@ -210,18 +281,20 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
210
281
  const fits = (id: string | null, scheme: ThemeScheme, custom: boolean) =>
211
282
  id !== null &&
212
283
  ((id === customThemeId && custom) ||
213
- options.themes.some(
214
- (theme) => theme.id === id && themeCovers(theme, scheme),
215
- ));
284
+ choices().some((theme) => theme.id === id && themeCovers(theme, scheme)));
216
285
 
286
+ // The Omarchy theme comes first where it covers the scheme, so the app
287
+ // matches the desktop until the visitor picks another
217
288
  const fallback = (scheme: ThemeScheme) =>
218
- options.themes.some(
219
- (theme) =>
220
- theme.id === options.defaultTheme && themeCovers(theme, scheme),
221
- )
222
- ? options.defaultTheme
223
- : (options.themes.find((theme) => themeCovers(theme, scheme))?.id ??
224
- options.defaultTheme);
289
+ omarchy !== null && themeCovers(omarchy, scheme)
290
+ ? omarchy.id
291
+ : options.themes.some(
292
+ (theme) =>
293
+ theme.id === options.defaultTheme && themeCovers(theme, scheme),
294
+ )
295
+ ? options.defaultTheme
296
+ : (options.themes.find((theme) => themeCovers(theme, scheme))?.id ??
297
+ options.defaultTheme);
225
298
 
226
299
  const storedLight = read(stored.theme);
227
300
  const storedDark = read(stored.dark) ?? storedLight;
@@ -244,7 +317,7 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
244
317
  const resolve = (mode: ThemeMode, custom: string | null): PageThemeState => {
245
318
  const scheme = schemeOf(mode);
246
319
 
247
- return { mode, scheme, theme: picks[scheme], custom };
320
+ return { mode, scheme, theme: picks[scheme], custom, omarchy };
248
321
  };
249
322
 
250
323
  let state: PageThemeState = resolve(
@@ -252,24 +325,6 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
252
325
  customCss === null ? null : customName,
253
326
  );
254
327
 
255
- const setStyle = (css: string | null) => {
256
- let style = document.getElementById(customThemeStyleId);
257
-
258
- if (css === null) {
259
- style?.remove();
260
-
261
- return;
262
- }
263
-
264
- if (style === null) {
265
- style = document.createElement("style");
266
- style.id = customThemeStyleId;
267
- document.head.append(style);
268
- }
269
-
270
- style.textContent = css;
271
- };
272
-
273
328
  const commit = (next: PageThemeState) => {
274
329
  state = next;
275
330
  root.setAttribute("data-gw-theme", state.theme);
@@ -290,7 +345,7 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
290
345
  ? runtime.css
291
346
  : `${runtime.css}\n\n${appTonesCss(options.tones, runtime.theme)}`;
292
347
 
293
- setStyle(css);
348
+ setStyle(customThemeStyleId, css);
294
349
  write(stored.document, text);
295
350
  write(stored.css, css);
296
351
  write(stored.name, document.name);
@@ -314,7 +369,7 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
314
369
 
315
370
  picks = { light: id, dark: id };
316
371
  } else {
317
- const choice = options.themes.find((theme) => theme.id === id);
372
+ const choice = choices().find((theme) => theme.id === id);
318
373
 
319
374
  if (choice === undefined) return;
320
375
 
@@ -365,7 +420,7 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
365
420
  return { ok: true, name: parsed.success.name, misses };
366
421
  },
367
422
  reset: () => {
368
- setStyle(null);
423
+ setStyle(customThemeStyleId, null);
369
424
 
370
425
  for (const key of Object.values(stored)) write(key, null);
371
426
 
@@ -373,6 +428,72 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
373
428
  commit(resolve(defaultMode, null));
374
429
  void options.store?.clear().catch(() => undefined);
375
430
  },
431
+ setOmarchyTheme: async (theme) => {
432
+ // Stored as well as in use, so it's picked again when it comes back
433
+ const followed = [
434
+ picks.light,
435
+ picks.dark,
436
+ read(stored.theme),
437
+ read(stored.dark),
438
+ ].includes(omarchyThemeId);
439
+
440
+ const unpicked = read(stored.theme) === null;
441
+ let mode: ThemeScheme | undefined;
442
+
443
+ if (theme !== null) {
444
+ const { Result } = await import("effect");
445
+
446
+ const { appTonesCss, omarchyTheme, runtimeTheme } =
447
+ await import("./runtime.ts");
448
+
449
+ const name = `Omarchy (${theme.name})`;
450
+ const parsed = omarchyTheme(theme.colors, name, omarchyThemeId);
451
+
452
+ if (Result.isSuccess(parsed)) {
453
+ const runtime = runtimeTheme(parsed.success);
454
+
455
+ const css =
456
+ options.tones === undefined
457
+ ? runtime.css
458
+ : `${runtime.css}\n\n${appTonesCss(options.tones, runtime.theme)}`;
459
+
460
+ mode = parsed.success.mode;
461
+ setStyle(omarchyThemeStyleId, css);
462
+ write(storedOmarchy.css, css);
463
+ write(storedOmarchy.name, name);
464
+ write(storedOmarchy.mode, mode);
465
+ omarchy = { id: omarchyThemeId, name, mode };
466
+ }
467
+ }
468
+
469
+ if (mode === undefined) {
470
+ // Gone, or a colors.toml that doesn't map: back off it, keeping the
471
+ // pick so it comes back with the theme
472
+ setStyle(omarchyThemeStyleId, null);
473
+
474
+ for (const key of Object.values(storedOmarchy)) write(key, null);
475
+
476
+ omarchy = null;
477
+ picks = {
478
+ light:
479
+ picks.light === omarchyThemeId ? fallback("light") : picks.light,
480
+ dark: picks.dark === omarchyThemeId ? fallback("dark") : picks.dark,
481
+ };
482
+ } else if (followed || unpicked) {
483
+ // A switch between a light and a dark Omarchy theme moves it across
484
+ const keep = (scheme: ThemeScheme) =>
485
+ picks[scheme] === omarchyThemeId ? fallback(scheme) : picks[scheme];
486
+
487
+ picks =
488
+ mode === "dark"
489
+ ? { light: keep("light"), dark: omarchyThemeId }
490
+ : { light: omarchyThemeId, dark: keep("dark") };
491
+
492
+ if (!unpicked) savePicks();
493
+ }
494
+
495
+ commit(resolve(state.mode, state.custom));
496
+ },
376
497
  subscribe: (listener) => {
377
498
  listeners.add(listener);
378
499
 
@@ -393,7 +514,7 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
393
514
  if (text === read(stored.document)) return;
394
515
 
395
516
  if (text === null) {
396
- setStyle(null);
517
+ setStyle(customThemeStyleId, null);
397
518
 
398
519
  for (const key of [stored.document, stored.css, stored.name]) {
399
520
  write(key, null);
package/src/runtime.ts CHANGED
@@ -12,7 +12,9 @@ import { SolvedTheme, Theme } from "./schema.ts";
12
12
  import { check, resolve, type Check, type ResolvedTheme } from "./solve.ts";
13
13
  import { appTonesCss, type AppTones } from "./tones.ts";
14
14
 
15
- export { omarchyTheme } from "./omarchy.ts";
15
+ export { omarchyTheme, type OmarchyCurrentTheme } from "./omarchy.ts";
16
+
17
+ export { hexModes } from "./generate.ts";
16
18
 
17
19
  export { appTonesCss, type AppTones } from "./tones.ts";
18
20