@timmo001/groundwork-tokens 0.9.37 → 0.9.39

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.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Theme translation: reads another app's color theme, such as a Ghostty,
3
+ * kitty, foot or Alacritty theme or an older Omarchy theme, and maps it onto
4
+ * a mapped theme's palette, so an app can offer the themes a
5
+ * machine already has. It parses each format itself, so it runs in a
6
+ * browser; `@timmo001/groundwork-tokens/desktop-themes` finds them on disk.
7
+ *
8
+ * @module
9
+ */
10
+ import { converter, formatHex, interpolate, parse, wcagContrast } from "culori";
11
+
12
+ import { distance, fromHex, shift, toHex } from "./color.ts";
13
+ import type { MappedTheme, Palette } from "./schema.ts";
14
+
15
+ /**
16
+ * A terminal's colors: the background and foreground, the 16 ANSI colors in
17
+ * order, and the accent where the format names one.
18
+ */
19
+ export interface TerminalPalette {
20
+ readonly background: string;
21
+ readonly foreground: string;
22
+ /** The 16 ANSI colors, black to bright white. Missing ones are `undefined`. */
23
+ readonly ansi: readonly (string | undefined)[];
24
+ readonly accent?: string | undefined;
25
+ /** A darker surface, such as a side bar, where the format has one. */
26
+ readonly lower?: string | undefined;
27
+ /** A raised surface, such as a widget's, where the format has one. */
28
+ readonly raised?: string | undefined;
29
+ readonly border?: string | undefined;
30
+ readonly muted?: string | undefined;
31
+ readonly mode?: "light" | "dark" | undefined;
32
+ }
33
+
34
+ const toRgb = converter("rgb");
35
+
36
+ /** A CSS color as six-digit hex, dropping any alpha, or `undefined`. */
37
+ export const normalizeHex = (value: string | undefined) => {
38
+ if (value === undefined) return undefined;
39
+
40
+ const trimmed = value.trim().replace(/^0x/i, "#");
41
+
42
+ const color = parse(/^[0-9a-f]{6}$/i.test(trimmed) ? `#${trimmed}` : trimmed);
43
+
44
+ return color === undefined ? undefined : formatHex(toRgb(color));
45
+ };
46
+
47
+ const mix = (a: string, b: string, amount: number) =>
48
+ formatHex(toRgb(interpolate([a, b], "oklch")(amount)));
49
+
50
+ /** Whether a background reads as light. */
51
+ const isLight = (background: string) =>
52
+ wcagContrast(background, "#000000") > wcagContrast(background, "#ffffff");
53
+
54
+ /**
55
+ * Maps a terminal palette onto a mapped theme. Surfaces the format doesn't
56
+ * name are stepped from the background, and the status colors come from
57
+ * the ANSI colors: blue for info, green, magenta, yellow and red.
58
+ */
59
+ export const translatePalette = (
60
+ colors: TerminalPalette,
61
+ options: {
62
+ readonly id: string;
63
+ readonly name: string;
64
+ readonly family: string;
65
+ },
66
+ ): MappedTheme | undefined => {
67
+ const background = normalizeHex(colors.background);
68
+ const text = normalizeHex(colors.foreground);
69
+
70
+ if (background === undefined || text === undefined) return undefined;
71
+
72
+ const ansi = (index: number) => normalizeHex(colors.ansi[index]);
73
+ const mode = colors.mode ?? (isLight(background) ? "light" : "dark");
74
+ const base = fromHex(background);
75
+
76
+ const blue = ansi(4) ?? ansi(12);
77
+ const green = ansi(2) ?? ansi(10);
78
+ const magenta = ansi(5) ?? ansi(13);
79
+ const yellow = ansi(3) ?? ansi(11);
80
+ const red = ansi(1) ?? ansi(9);
81
+
82
+ if (
83
+ blue === undefined ||
84
+ green === undefined ||
85
+ magenta === undefined ||
86
+ yellow === undefined ||
87
+ red === undefined
88
+ ) {
89
+ return undefined;
90
+ }
91
+
92
+ const palette: Palette = {
93
+ lower: normalizeHex(colors.lower) ?? toHex(shift(base, -0.025)),
94
+ background,
95
+ raised:
96
+ normalizeHex(colors.raised) ??
97
+ toHex(shift(base, mode === "dark" ? 0.05 : 0.02)),
98
+ border: normalizeHex(colors.border) ?? mix(background, text, 0.2),
99
+ text,
100
+ muted: normalizeHex(colors.muted) ?? mix(text, background, 0.3),
101
+ accent: normalizeHex(colors.accent) ?? blue,
102
+ info: blue,
103
+ success: green,
104
+ important: magenta,
105
+ warning: yellow,
106
+ danger: red,
107
+ };
108
+
109
+ return {
110
+ kind: "mapped",
111
+ id: options.id,
112
+ name: options.name,
113
+ family: options.family,
114
+ mode,
115
+ palette,
116
+ };
117
+ };
118
+
119
+ /** The `key = value` pairs of a flat config file, skipping comments. */
120
+ const pairs = (text: string, separator: RegExp) => {
121
+ const found = new Map<string, string>();
122
+
123
+ for (const line of text.split(/\r?\n/)) {
124
+ const trimmed = line.trim();
125
+
126
+ if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith(";")) {
127
+ continue;
128
+ }
129
+
130
+ const match = separator.exec(trimmed);
131
+
132
+ if (match?.[1] !== undefined && match[2] !== undefined) {
133
+ found.set(
134
+ match[1].toLowerCase(),
135
+ match[2].trim().replace(/^["']|["']$/g, ""),
136
+ );
137
+ }
138
+ }
139
+
140
+ return found;
141
+ };
142
+
143
+ /** A Ghostty theme or config: `palette = N=#hex`, `background`, `foreground`. */
144
+ export const ghosttyPalette = (text: string): TerminalPalette => {
145
+ const ansi: (string | undefined)[] = [];
146
+ let background: string | undefined;
147
+ let foreground: string | undefined;
148
+
149
+ for (const line of text.split(/\r?\n/)) {
150
+ const palette = /^\s*palette\s*=\s*(\d+)\s*=\s*(\S+)/.exec(line);
151
+
152
+ if (palette?.[1] !== undefined && palette[2] !== undefined) {
153
+ ansi[Number(palette[1])] = palette[2];
154
+ continue;
155
+ }
156
+
157
+ const pair = /^\s*(background|foreground)\s*=\s*(\S+)/.exec(line);
158
+
159
+ if (pair?.[1] === "background") background = pair[2];
160
+
161
+ if (pair?.[1] === "foreground") foreground = pair[2];
162
+ }
163
+
164
+ return { background: background ?? "", foreground: foreground ?? "", ansi };
165
+ };
166
+
167
+ /** A kitty theme or config: `background`, `foreground`, `colorN`. */
168
+ export const kittyPalette = (text: string): TerminalPalette => {
169
+ const found = pairs(text, /^([\w-]+)\s+(\S+)/);
170
+
171
+ return {
172
+ background: found.get("background") ?? "",
173
+ foreground: found.get("foreground") ?? "",
174
+ ansi: Array.from({ length: 16 }, (_, index) => found.get(`color${index}`)),
175
+ accent: found.get("active_border_color"),
176
+ };
177
+ };
178
+
179
+ /** A foot config's `[colors]` (or `[colors-dark]`): `regularN`, `brightN`. */
180
+ export const footPalette = (text: string): TerminalPalette => {
181
+ const found = pairs(text, /^([\w-]+)\s*=\s*(\S+)/);
182
+
183
+ return {
184
+ background: found.get("background") ?? "",
185
+ foreground: found.get("foreground") ?? "",
186
+ ansi: [
187
+ ...Array.from({ length: 8 }, (_, index) => found.get(`regular${index}`)),
188
+ ...Array.from({ length: 8 }, (_, index) => found.get(`bright${index}`)),
189
+ ],
190
+ };
191
+ };
192
+
193
+ const ansiNames = [
194
+ "black",
195
+ "red",
196
+ "green",
197
+ "yellow",
198
+ "blue",
199
+ "magenta",
200
+ "cyan",
201
+ "white",
202
+ ] as const;
203
+
204
+ /** An Alacritty theme or config: `[colors.primary]`, `[colors.normal]`, `[colors.bright]`. */
205
+ export const alacrittyPalette = (text: string): TerminalPalette => {
206
+ const sections = new Map<string, Map<string, string>>();
207
+ let section = "";
208
+
209
+ for (const line of text.split(/\r?\n/)) {
210
+ const header = /^\s*\[([^\]]+)\]/.exec(line);
211
+
212
+ if (header?.[1] !== undefined) {
213
+ section = header[1].trim();
214
+ continue;
215
+ }
216
+
217
+ const pair = /^\s*(\w+)\s*=\s*["']?(#?[0-9a-fA-Fx]+)["']?/.exec(line);
218
+
219
+ if (pair?.[1] !== undefined && pair[2] !== undefined) {
220
+ const entries = sections.get(section) ?? new Map<string, string>();
221
+
222
+ entries.set(pair[1], pair[2]);
223
+ sections.set(section, entries);
224
+ }
225
+ }
226
+
227
+ const get = (name: string, key: string) => sections.get(name)?.get(key);
228
+
229
+ return {
230
+ background: get("colors.primary", "background") ?? "",
231
+ foreground: get("colors.primary", "foreground") ?? "",
232
+ ansi: [
233
+ ...ansiNames.map((name) => get("colors.normal", name)),
234
+ ...ansiNames.map((name) => get("colors.bright", name)),
235
+ ],
236
+ };
237
+ };
238
+
239
+ /**
240
+ * A flat `colors.toml` written with the ANSI colors as `color0` to
241
+ * `color15`, as older Omarchy themes have it; `omarchyTheme` reads the
242
+ * newer one with named colors.
243
+ */
244
+ export const colorsTomlPalette = (text: string): TerminalPalette => {
245
+ const found = pairs(text, /^([\w-]+)\s*=\s*(.+)$/);
246
+
247
+ return {
248
+ background: found.get("background") ?? "",
249
+ foreground: found.get("foreground") ?? "",
250
+ ansi: Array.from({ length: 16 }, (_, index) => found.get(`color${index}`)),
251
+ accent: found.get("accent"),
252
+ };
253
+ };
254
+
255
+ /** A name to compare by: `Nord (Omarchy)` reads as `nord`. */
256
+ const nameKey = (name: string) =>
257
+ name
258
+ .replace(/\s*\([^)]*\)\s*$/, "")
259
+ .toLowerCase()
260
+ .replace(/[^a-z0-9]/g, "");
261
+
262
+ const close = (a: string, b: string) => distance(a, b) < 0.03;
263
+
264
+ /**
265
+ * Whether two themes are the same one: the same name, or the same
266
+ * background with most of the same status colors. Text isn't compared, as a
267
+ * terminal's foreground is often a step off the brightest text its theme
268
+ * names.
269
+ */
270
+ export const sameTheme = (
271
+ a: Pick<MappedTheme, "name" | "palette">,
272
+ b: Pick<MappedTheme, "name" | "palette">,
273
+ ) =>
274
+ nameKey(a.name) === nameKey(b.name) ||
275
+ (close(a.palette.background, b.palette.background) &&
276
+ (["info", "success", "warning", "danger", "important"] as const).filter(
277
+ (role) => close(a.palette[role], b.palette[role]),
278
+ ).length >= 3);
279
+
280
+ /**
281
+ * Drops each candidate that's the same theme as one in `existing` or one
282
+ * kept before it, so a terminal following the desktop's theme, or a theme
283
+ * Groundwork already ships, shows once.
284
+ */
285
+ export const distinctThemes = (
286
+ candidates: readonly MappedTheme[],
287
+ existing: readonly Pick<MappedTheme, "name" | "palette">[],
288
+ ) => {
289
+ const kept: MappedTheme[] = [];
290
+
291
+ for (const candidate of candidates) {
292
+ if (
293
+ !existing.some((theme) => sameTheme(theme, candidate)) &&
294
+ !kept.some((theme) => sameTheme(theme, candidate))
295
+ ) {
296
+ kept.push(candidate);
297
+ }
298
+ }
299
+
300
+ return kept;
301
+ };
@@ -0,0 +1,35 @@
1
+ {
2
+ "kind": "mapped",
3
+ "id": "github-dimmed",
4
+ "name": "GitHub Dimmed",
5
+ "family": "GitHub",
6
+ "mode": "light",
7
+ "palette": {
8
+ "lower": "#f6f8fa",
9
+ "background": "#ffffff",
10
+ "raised": "#f6f8fa",
11
+ "border": "#d0d7de",
12
+ "text": "#1f2328",
13
+ "muted": "#656d76",
14
+ "accent": "#0969da",
15
+ "info": "#0969da",
16
+ "success": "#1a7f37",
17
+ "important": "#8250df",
18
+ "warning": "#9a6700",
19
+ "danger": "#cf222e"
20
+ },
21
+ "otherPalette": {
22
+ "lower": "#1c2128",
23
+ "background": "#22272e",
24
+ "raised": "#2d333b",
25
+ "border": "#444c56",
26
+ "text": "#adbac7",
27
+ "muted": "#768390",
28
+ "accent": "#539bf5",
29
+ "info": "#539bf5",
30
+ "success": "#57ab5a",
31
+ "important": "#b083f0",
32
+ "warning": "#c69026",
33
+ "danger": "#e5534b"
34
+ }
35
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "kind": "mapped",
3
+ "id": "github-high-contrast",
4
+ "name": "GitHub High Contrast",
5
+ "family": "GitHub",
6
+ "mode": "light",
7
+ "palette": {
8
+ "lower": "#ffffff",
9
+ "background": "#ffffff",
10
+ "raised": "#ffffff",
11
+ "border": "#20252c",
12
+ "text": "#0e1116",
13
+ "muted": "#0e1116",
14
+ "accent": "#0349b4",
15
+ "info": "#0349b4",
16
+ "success": "#055d20",
17
+ "important": "#622cbc",
18
+ "warning": "#744500",
19
+ "danger": "#a0111f"
20
+ },
21
+ "otherPalette": {
22
+ "lower": "#010409",
23
+ "background": "#0a0c10",
24
+ "raised": "#272b33",
25
+ "border": "#7a828e",
26
+ "text": "#f0f3f6",
27
+ "muted": "#f0f3f6",
28
+ "accent": "#71b7ff",
29
+ "info": "#71b7ff",
30
+ "success": "#26cd4d",
31
+ "important": "#cb9eff",
32
+ "warning": "#f0b72f",
33
+ "danger": "#ff6a69"
34
+ }
35
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "kind": "mapped",
3
+ "id": "github",
4
+ "name": "GitHub",
5
+ "family": "GitHub",
6
+ "mode": "light",
7
+ "palette": {
8
+ "lower": "#f6f8fa",
9
+ "background": "#ffffff",
10
+ "raised": "#f6f8fa",
11
+ "border": "#d0d7de",
12
+ "text": "#1f2328",
13
+ "muted": "#656d76",
14
+ "accent": "#0969da",
15
+ "info": "#0969da",
16
+ "success": "#1a7f37",
17
+ "important": "#8250df",
18
+ "warning": "#9a6700",
19
+ "danger": "#cf222e"
20
+ },
21
+ "otherPalette": {
22
+ "lower": "#010409",
23
+ "background": "#0d1117",
24
+ "raised": "#161b22",
25
+ "border": "#30363d",
26
+ "text": "#e6edf3",
27
+ "muted": "#7d8590",
28
+ "accent": "#2f81f7",
29
+ "info": "#2f81f7",
30
+ "success": "#3fb950",
31
+ "important": "#bc8cff",
32
+ "warning": "#d29922",
33
+ "danger": "#f85149"
34
+ }
35
+ }