@timmo001/groundwork-tokens 0.9.15 → 0.9.17

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/page.ts +106 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-tokens",
3
- "version": "0.9.15",
3
+ "version": "0.9.17",
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",
@@ -45,6 +45,7 @@
45
45
  "format:check": "prettier --check ."
46
46
  },
47
47
  "dependencies": {
48
+ "@types/culori": "4.0.1",
48
49
  "culori": "4.0.2",
49
50
  "effect": "4.1.1"
50
51
  },
@@ -53,7 +54,6 @@
53
54
  "@oxlint/plugins": "1.90.0",
54
55
  "@timmo001/oxlint-rules": "0.6.3",
55
56
  "@types/bun": "1.4.4",
56
- "@types/culori": "4.0.1",
57
57
  "oxlint": "1.90.0",
58
58
  "oxlint-tsgolint": "7.0.2005",
59
59
  "prettier": "3.9.10",
package/src/page.ts CHANGED
@@ -30,8 +30,13 @@ export const customThemeStyleId = "gw-custom-theme";
30
30
 
31
31
  const modes: readonly ThemeMode[] = ["auto", "light", "dark", "darker"];
32
32
 
33
+ /** Light or dark: the side of a mode a theme has to cover. Darker is dark. */
34
+ export type ThemeScheme = "light" | "dark";
35
+
33
36
  export interface PageThemeState {
34
37
  readonly mode: ThemeMode;
38
+ /** The scheme in use, with `auto` resolved from the system. */
39
+ readonly scheme: ThemeScheme;
35
40
  /** The theme id in use: a built-in one, or `custom`. */
36
41
  readonly theme: string;
37
42
  /** The visitor's own theme's name, when they've imported one. */
@@ -47,12 +52,21 @@ export type ImportResult =
47
52
  }
48
53
  | { readonly ok: false; readonly error: string };
49
54
 
50
- /** A theme to offer, by id and name. A `Theme` document is one. */
55
+ /**
56
+ * A theme to offer, by id and name. A `Theme` document is one. `mode` marks
57
+ * a theme with only one scheme, such as Catppuccin Latte: it's offered and
58
+ * kept for that scheme alone, beside a pick for the other.
59
+ */
51
60
  export interface ThemeChoice {
52
61
  readonly id: string;
53
62
  readonly name: string;
63
+ readonly mode?: ThemeScheme | undefined;
54
64
  }
55
65
 
66
+ /** Whether `choice` can show in `scheme`. */
67
+ export const themeCovers = (choice: ThemeChoice, scheme: ThemeScheme) =>
68
+ choice.mode === undefined || choice.mode === scheme;
69
+
56
70
  /** The core themes by id and name, in the order the docs list them. */
57
71
  export const coreThemeChoices: readonly ThemeChoice[] = [
58
72
  base,
@@ -102,6 +116,7 @@ export interface PageTheme {
102
116
  const keys = (key: string) => ({
103
117
  mode: `${key}:mode`,
104
118
  theme: `${key}:id`,
119
+ dark: `${key}:id-dark`,
105
120
  document: `${key}:custom`,
106
121
  css: `${key}:custom-css`,
107
122
  name: `${key}:custom-name`,
@@ -142,7 +157,7 @@ export const themeBootScript = (
142
157
  style: customThemeStyleId,
143
158
  });
144
159
 
145
- 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)})();`;
160
+ 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)})();`;
146
161
  };
147
162
 
148
163
  /** Reads a theme from JSON text or an Omarchy `colors.toml`. */
@@ -171,21 +186,64 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
171
186
  const known = (id: string) => options.themes.some((theme) => theme.id === id);
172
187
 
173
188
  const storedMode = read(stored.mode);
174
- const storedTheme = read(stored.theme);
175
189
  const customName = read(stored.name);
176
190
  const customCss = read(stored.css);
177
191
 
178
- let state: PageThemeState = {
179
- mode: modes.find((mode) => mode === storedMode) ?? defaultMode,
180
- theme:
181
- storedTheme !== null &&
182
- (known(storedTheme) ||
183
- (storedTheme === customThemeId && customCss !== null))
184
- ? storedTheme
185
- : options.defaultTheme,
186
- custom: customCss === null ? null : customName,
192
+ const system = matchMedia("(prefers-color-scheme: dark)");
193
+
194
+ const schemeOf = (mode: ThemeMode): ThemeScheme =>
195
+ mode === "light"
196
+ ? "light"
197
+ : mode === "auto" && !system.matches
198
+ ? "light"
199
+ : "dark";
200
+
201
+ /** Whether `id` can be the pick for `scheme`. */
202
+ const fits = (id: string | null, scheme: ThemeScheme, custom: boolean) =>
203
+ id !== null &&
204
+ ((id === customThemeId && custom) ||
205
+ options.themes.some(
206
+ (theme) => theme.id === id && themeCovers(theme, scheme),
207
+ ));
208
+
209
+ const fallback = (scheme: ThemeScheme) =>
210
+ options.themes.some(
211
+ (theme) =>
212
+ theme.id === options.defaultTheme && themeCovers(theme, scheme),
213
+ )
214
+ ? options.defaultTheme
215
+ : (options.themes.find((theme) => themeCovers(theme, scheme))?.id ??
216
+ options.defaultTheme);
217
+
218
+ const storedLight = read(stored.theme);
219
+ const storedDark = read(stored.dark) ?? storedLight;
220
+
221
+ /** The theme kept for each scheme. */
222
+ let picks: Record<ThemeScheme, string> = {
223
+ light: fits(storedLight, "light", customCss !== null)
224
+ ? (storedLight ?? options.defaultTheme)
225
+ : fallback("light"),
226
+ dark: fits(storedDark, "dark", customCss !== null)
227
+ ? (storedDark ?? options.defaultTheme)
228
+ : fallback("dark"),
229
+ };
230
+
231
+ const savePicks = () => {
232
+ write(stored.theme, picks.light);
233
+ write(stored.dark, picks.dark);
187
234
  };
188
235
 
236
+ const resolve = (mode: ThemeMode, custom: string | null): PageThemeState => {
237
+ const scheme = schemeOf(mode);
238
+
239
+ return { mode, scheme, theme: picks[scheme], custom };
240
+ };
241
+
242
+ let state: PageThemeState = resolve(
243
+ modes.find((mode) => mode === storedMode) ?? defaultMode,
244
+ customCss === null ? null : customName,
245
+ );
246
+
189
247
  const setStyle = (css: string | null) => {
190
248
  let style = document.getElementById(customThemeStyleId);
191
249
 
@@ -228,8 +286,9 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
228
286
  write(stored.document, text);
229
287
  write(stored.css, css);
230
288
  write(stored.name, document.name);
231
- write(stored.theme, customThemeId);
232
- commit({ ...state, theme: customThemeId, custom: document.name });
289
+ picks = { light: customThemeId, dark: customThemeId };
290
+ savePicks();
291
+ commit(resolve(state.mode, document.name));
233
292
 
234
293
  return runtime.checks
235
294
  .filter((result) => !result.passes)
@@ -240,17 +299,44 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
240
299
  };
241
300
 
242
301
  const setTheme = (id: string) => {
243
- if (!known(id) && !(id === customThemeId && state.custom !== null)) return;
302
+ if (id === customThemeId) {
303
+ if (state.custom === null) return;
244
304
 
245
- write(stored.theme, id);
246
- commit({ ...state, theme: id });
305
+ picks = { light: id, dark: id };
306
+ } else {
307
+ const choice = options.themes.find((theme) => theme.id === id);
308
+
309
+ if (choice === undefined) return;
310
+
311
+ picks =
312
+ choice.mode === undefined
313
+ ? { light: id, dark: id }
314
+ : { ...picks, [choice.mode]: id };
315
+
316
+ // Picking a light-only theme in dark, or the reverse, shows it
317
+ if (choice.mode !== undefined && choice.mode !== state.scheme) {
318
+ savePicks();
319
+ write(stored.mode, choice.mode);
320
+ commit(resolve(choice.mode, state.custom));
321
+
322
+ return;
323
+ }
324
+ }
325
+
326
+ savePicks();
327
+ commit(resolve(state.mode, state.custom));
247
328
  };
248
329
 
330
+ // A system change moves `auto` between the light and dark picks
331
+ system.addEventListener("change", () => {
332
+ if (state.mode === "auto") commit(resolve(state.mode, state.custom));
333
+ });
334
+
249
335
  const page: PageTheme = {
250
336
  state: () => state,
251
337
  setMode: (mode) => {
252
338
  write(stored.mode, mode);
253
- commit({ ...state, mode });
339
+ commit(resolve(mode, state.custom));
254
340
  },
255
341
  setTheme,
256
342
  importTheme: async (text) => {
@@ -273,7 +359,8 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
273
359
 
274
360
  for (const key of Object.values(stored)) write(key, null);
275
361
 
276
- commit({ mode: defaultMode, theme: options.defaultTheme, custom: null });
362
+ picks = { light: fallback("light"), dark: fallback("dark") };
363
+ commit(resolve(defaultMode, null));
277
364
  },
278
365
  subscribe: (listener) => {
279
366
  listeners.add(listener);