@timmo001/groundwork-tokens 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-tokens",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -11,7 +11,8 @@
11
11
  "./webmcp": "./src/webmcp.ts",
12
12
  "./opentui": "./generated/opentui.ts",
13
13
  "./themes.css": "./generated/themes.css",
14
- "./themes.json": "./generated/themes.json"
14
+ "./themes.json": "./generated/themes.json",
15
+ "./themes/*.json": "./themes/*.json"
15
16
  },
16
17
  "files": [
17
18
  "src",
package/src/generate.ts CHANGED
@@ -151,40 +151,53 @@ const modeBlock = (roles: Roles, dark: boolean, indent = " ") =>
151
151
  declarations({ ...colorProperties(roles), ...shadows(dark) }, indent),
152
152
  ].join("\n");
153
153
 
154
- /** One theme's `--gw-*` rules under `[data-gw-theme]`, in each of its modes. */
155
- export const themeCss = (theme: ResolvedTheme) => {
154
+ /**
155
+ * A rule under `theme`'s selector for each of its modes, matching how
156
+ * `data-gw-mode` and the system's scheme pick one. `block` writes a mode's
157
+ * declarations at the indent it's given.
158
+ */
159
+ export const modeRules = (
160
+ theme: ResolvedTheme,
161
+ block: (roles: Roles, dark: boolean, indent: string) => string,
162
+ ) => {
156
163
  const selector = `[data-gw-theme="${theme.id}"]`;
157
- const style = `${selector} {\n${declarations(styleProperties(theme.style))}\n}`;
158
164
  const { light, dark, darker } = theme.modes;
159
- const blocks = [style];
165
+ const rules: string[] = [];
160
166
 
161
167
  if (light !== undefined) {
162
- blocks.push(`${selector} {\n${modeBlock(light, false)}\n}`);
168
+ rules.push(`${selector} {\n${block(light, false, " ")}\n}`);
163
169
  } else if (dark !== undefined) {
164
- blocks.push(`${selector} {\n${modeBlock(dark, true)}\n}`);
170
+ rules.push(`${selector} {\n${block(dark, true, " ")}\n}`);
165
171
  }
166
172
 
167
173
  if (dark !== undefined) {
168
- blocks.push(
169
- `${selector}[data-gw-mode="dark"] {\n${modeBlock(dark, true)}\n}`,
174
+ rules.push(
175
+ `${selector}[data-gw-mode="dark"] {\n${block(dark, true, " ")}\n}`,
170
176
  );
171
177
 
172
178
  if (light !== undefined) {
173
- blocks.push(
174
- `@media (prefers-color-scheme: dark) {\n ${selector}:not([data-gw-mode]) {\n${modeBlock(dark, true, " ")}\n }\n}`,
179
+ rules.push(
180
+ `@media (prefers-color-scheme: dark) {\n ${selector}:not([data-gw-mode]) {\n${block(dark, true, " ")}\n }\n}`,
175
181
  );
176
182
  }
177
183
  }
178
184
 
179
185
  if (darker !== undefined) {
180
- blocks.push(
181
- `${selector}[data-gw-mode="darker"] {\n${modeBlock(darker, true)}\n}`,
186
+ rules.push(
187
+ `${selector}[data-gw-mode="darker"] {\n${block(darker, true, " ")}\n}`,
182
188
  );
183
189
  }
184
190
 
185
- return blocks.join("\n\n");
191
+ return rules;
186
192
  };
187
193
 
194
+ /** One theme's `--gw-*` rules under `[data-gw-theme]`, in each of its modes. */
195
+ export const themeCss = (theme: ResolvedTheme) =>
196
+ [
197
+ `[data-gw-theme="${theme.id}"] {\n${declarations(styleProperties(theme.style))}\n}`,
198
+ ...modeRules(theme, modeBlock),
199
+ ].join("\n\n");
200
+
188
201
  /**
189
202
  * What each `gw-` element looks like before its script defines it: the
190
203
  * display and block size it will have, so nothing moves when it upgrades.
package/src/index.ts CHANGED
@@ -9,3 +9,5 @@ export * from "./color.ts";
9
9
  export * from "./schema.ts";
10
10
 
11
11
  export * from "./solve.ts";
12
+
13
+ export * from "./tones.ts";
package/src/page.ts CHANGED
@@ -79,6 +79,12 @@ export interface PageThemeOptions {
79
79
  readonly defaultMode?: ThemeMode;
80
80
  /** The element the attributes go on. Defaults to `<html>`. */
81
81
  readonly root?: HTMLElement;
82
+ /**
83
+ * The app's own tones, solved into an imported theme's CSS beside its
84
+ * roles. The built-in themes' tones come from `appTones` in the runtime
85
+ * entry, on the page with their CSS.
86
+ */
87
+ readonly tones?: Readonly<Record<string, string>>;
82
88
  }
83
89
 
84
90
  export interface PageTheme {
@@ -210,12 +216,17 @@ export const pageTheme = (options: PageThemeOptions): PageTheme => {
210
216
 
211
217
  /** Solves a document under the custom id, applies it and keeps it. */
212
218
  const applyCustom = async (document: Theme, text: string) => {
213
- const { runtimeTheme } = await import("./runtime.ts");
219
+ const { appTonesCss, runtimeTheme } = await import("./runtime.ts");
214
220
  const runtime = runtimeTheme(document, customThemeId);
215
221
 
216
- setStyle(runtime.css);
222
+ const css =
223
+ options.tones === undefined
224
+ ? runtime.css
225
+ : `${runtime.css}\n\n${appTonesCss(options.tones, runtime.theme)}`;
226
+
227
+ setStyle(css);
217
228
  write(stored.document, text);
218
- write(stored.css, runtime.css);
229
+ write(stored.css, css);
219
230
  write(stored.name, document.name);
220
231
  write(stored.theme, customThemeId);
221
232
  commit({ ...state, theme: customThemeId, custom: document.name });
package/src/runtime.ts CHANGED
@@ -10,9 +10,12 @@ import baseDocument from "../themes/base.json" with { type: "json" };
10
10
  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
+ import { appTonesCss, type AppTones } from "./tones.ts";
13
14
 
14
15
  export { omarchyTheme } from "./omarchy.ts";
15
16
 
17
+ export { appTonesCss, type AppTones } from "./tones.ts";
18
+
16
19
  const base = Schema.decodeUnknownSync(SolvedTheme)(baseDocument);
17
20
 
18
21
  /** A theme solved at runtime, with its CSS and every contrast check. */
@@ -37,6 +40,21 @@ export const runtimeTheme = (theme: Theme, id = theme.id): RuntimeTheme => {
37
40
  return { theme: resolved, css: themeCss(resolved), checks: check(resolved) };
38
41
  };
39
42
 
43
+ /**
44
+ * An app's own tones as CSS for each of `themes`: `--app-tone-<name>`,
45
+ * `-fill` and `-border`, solved against each theme's surfaces in every mode
46
+ * so they meet the status colors' contrast. Put it beside the themes' CSS,
47
+ * such as at build time, then point a component's tone at one:
48
+ *
49
+ * ```css
50
+ * .regressed { --gw-tone: var(--app-tone-regressed); }
51
+ * ```
52
+ */
53
+ export const appTones = (tones: AppTones, themes: readonly Theme[]) =>
54
+ themes
55
+ .map((theme) => appTonesCss(tones, resolve(theme, base.style)))
56
+ .join("\n\n");
57
+
40
58
  /**
41
59
  * Where an app keeps a user's theme document. The document is stored, not
42
60
  * its CSS, so it's solved again when the solver changes. Local storage is
package/src/tones.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * App tones: an app's own state colors, such as an issue's "regressed" or
3
+ * "muted", solved against each theme's surfaces the way the status colors
4
+ * are, so they read in every theme and mode.
5
+ *
6
+ * @module
7
+ */
8
+ import {
9
+ contrast,
10
+ fromHex,
11
+ solveLightness,
12
+ toCss,
13
+ withAlpha,
14
+ type Oklch,
15
+ } from "./color.ts";
16
+ import { modeRules } from "./generate.ts";
17
+ import { targets, type ResolvedTheme, type Roles } from "./solve.ts";
18
+
19
+ /** An app's tones: each name and the hex color it starts from. */
20
+ export type AppTones = Readonly<Record<string, string>>;
21
+
22
+ const textSurfaces = [
23
+ "surface-lower",
24
+ "surface-low",
25
+ "surface-default",
26
+ "surface-raised",
27
+ "surface-overlay",
28
+ ] as const;
29
+
30
+ /**
31
+ * Each tone in one mode: its text color, kept as given where it already meets
32
+ * the status contrast target on every surface and moved lighter or darker
33
+ * until it does where not, with a quiet fill and a border from it.
34
+ */
35
+ export const appToneRoles = (
36
+ tones: AppTones,
37
+ roles: Roles,
38
+ dark: boolean,
39
+ ): Roles =>
40
+ Object.fromEntries(
41
+ Object.entries(tones).flatMap(([name, hex]) => {
42
+ const seed = fromHex(hex);
43
+
44
+ const surfaces = textSurfaces.flatMap((surface) => {
45
+ const color = roles[surface];
46
+
47
+ return color === undefined ? [] : [color];
48
+ });
49
+
50
+ // Where the tone reads worst
51
+ const ground = surfaces.reduce<Oklch | undefined>(
52
+ (worst, surface) =>
53
+ worst === undefined || contrast(seed, surface) < contrast(seed, worst)
54
+ ? surface
55
+ : worst,
56
+ undefined,
57
+ );
58
+
59
+ const text =
60
+ ground === undefined || contrast(seed, ground) >= targets.status
61
+ ? seed
62
+ : solveLightness(
63
+ seed,
64
+ ground,
65
+ targets.status,
66
+ dark ? "lighter" : "darker",
67
+ );
68
+
69
+ return [
70
+ [name, text],
71
+ [`${name}-fill`, withAlpha(text, 0.14)],
72
+ [`${name}-border`, withAlpha(text, 0.45)],
73
+ ];
74
+ }),
75
+ );
76
+
77
+ /**
78
+ * `--app-tone-<name>`, `-fill` and `-border` for each tone, under the same
79
+ * selectors as `theme`'s own CSS. Set a component's `--gw-tone`,
80
+ * `--gw-tone-fill` and `--gw-tone-border` from them.
81
+ */
82
+ export const appTonesCss = (tones: AppTones, theme: ResolvedTheme) =>
83
+ modeRules(theme, (roles, dark, indent) =>
84
+ Object.entries(appToneRoles(tones, roles, dark))
85
+ .map(([name, color]) => `${indent}--app-tone-${name}: ${toCss(color)};`)
86
+ .join("\n"),
87
+ ).join("\n\n");