@bismawy/pi-arnative 0.2.12 → 0.3.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.
@@ -1,272 +1,349 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Generates the arnative theme variants.
3
+ * Theme generator — the single source of truth for every `themes/*.json`.
4
4
  *
5
- * Structure source of truth = `themes/arnative.json` (also the default theme,
6
- * never rewritten here). Palette source of truth = the PALETTES table below.
7
- * Variants are text substitutions on the base, so its original formatting
8
- * (tabs + grouping blank lines) is preserved.
5
+ * Each theme is a handful of parameters (accent hue, chroma, canvas lightness,
6
+ * neutral tint) plus optional hue overrides for the strong palettes. The
7
+ * renderer derives every color from a shared OKLCH ramp, so lightness and
8
+ * saturation stay consistent across themes and no two roles collapse to the
9
+ * same value by accident.
9
10
  *
10
- * node themes/gen-themes.mjs rewrite all variants
11
- * node themes/gen-themes.mjs --check fail when a file differs from the generated output
12
- *
13
- * Color validation (contrast, required keys) lives in the self-check in
14
- * `extensions/ui-render-tweaks.ts` to keep one source of rules.
11
+ * Usage: node themes/gen-themes.mjs [--check]
15
12
  */
16
- import { readFileSync, writeFileSync } from "node:fs";
13
+ import { readFile, writeFile } from "node:fs/promises";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { hexToOklch, oklchToRgb } from "../lib/color.ts";
17
+ import { assert } from "../lib/check.ts";
17
18
 
18
- const baseUrl = new URL("arnative.json", import.meta.url);
19
- const baseText = readFileSync(baseUrl, "utf8");
20
- const base = JSON.parse(baseText);
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const SCHEMA =
21
+ "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json";
21
22
 
22
- // Every theme must set ALL of these, so no color silently inherits the base.
23
- const REQUIRED_VARS = [
24
- "accent", "cyan", "blue", "green", "red", "yellow", "text", "gray", "dimGray", "darkGray",
25
- "softCyan", "searchBg", "selectedBg", "userMsgBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg", "customMsgBg",
26
- ];
27
- const REQUIRED_COLORS = [
28
- "customMessageLabel", "mdHeading", "mdLink",
29
- "syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable", "syntaxString",
30
- "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation",
31
- "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh",
32
- ];
33
- const REQUIRED_EXPORT = ["pageBg", "cardBg", "infoBg"];
23
+ // --- color helpers ---------------------------------------------------------
24
+
25
+ const hue = (x) => ((x % 360) + 360) % 360;
26
+ const round3 = (x) => +x.toFixed(3);
27
+
28
+ /** Format OKLCH after gamut-reducing chroma, so the written value renders as-is. */
29
+ function oklch(L, C, H) {
30
+ const { chroma } = oklchToRgb(L, C, H);
31
+ return `oklch(${+(L * 100).toFixed(1)}% ${round3(chroma)} ${Math.round(hue(H))})`;
32
+ }
34
33
 
35
- /** Per-theme palette. `vars.cyan`/`vars.blue` = borderAccent/border, `vars.softCyan` = tint, `vars.green/red/yellow` = success/error/warning + diff. */
36
- const PALETTES = {
37
- // --- base hue variants -------------------------------------------------
34
+ // --- shared ramps ----------------------------------------------------------
35
+
36
+ const NEUTRAL = { fg: 0.88, fgMuted: 0.72, fgDim: 0.61, fgFaint: 0.49 };
37
+ const THINKING = { off: 0.4, minimal: 0.47, low: 0.55, medium: 0.63, high: 0.71, xhigh: 0.79 };
38
+ const SEMANTIC = { success: 145, error: 25, warning: 85 };
39
+ const SYNTAX_OFFSET = { keyword: 0, func: 45, variable: -45, string: 120, number: 180, type: 90 };
40
+
41
+ /** Default hue for a role used outside `syntax*` (heading/link/label). */
42
+ const ACCENT_OFFSET = { heading: 0, link: 0, label: 60 };
43
+
44
+ // --- theme parameters ------------------------------------------------------
45
+
46
+ /**
47
+ * `accent` seeds only the hue; `chroma` is the normalized accent chroma.
48
+ * `hues` overrides the derived hue per role (keyword/func/… /heading/link/label)
49
+ * to keep the strong palettes recognisable.
50
+ */
51
+ const THEMES = {
52
+ arnative: {
53
+ accent: "#00d7ff",
54
+ chroma: 0.13,
55
+ canvasL: 0.27,
56
+ neutralHue: 232,
57
+ neutralChroma: 0.012,
58
+ hues: { heading: 75, label: 282 },
59
+ },
38
60
  "arnative-sun": {
39
- vars: {
40
- accent: "#ffb454", cyan: "#ffd166", blue: "#da9b2f", green: "#b5bd68", red: "#d07272",
41
- yellow: "#ffff00", text: "#d4d4d4", gray: "#8d8d8d", dimGray: "#666666", darkGray: "#505050",
42
- searchBg: "#634f35", softCyan: "#ceb07e", selectedBg: "#484032", userMsgBg: "#312d25", toolPendingBg: "#29251f",
43
- toolSuccessBg: "#202828", toolErrorBg: "#342222", customMsgBg: "#2e281f",
44
- },
45
- colors: {
46
- customMessageLabel: "#9575cd", mdHeading: "#f0c674", mdLink: "#81a2be",
47
- syntaxComment: "#6A9955", syntaxKeyword: "#569CD6", syntaxFunction: "#DCDCAA",
48
- syntaxVariable: "#9CDCFE", syntaxString: "#CE9178", syntaxNumber: "#B5CEA8",
49
- syntaxType: "#4EC9B0", syntaxOperator: "#D4D4D4", syntaxPunctuation: "#D4D4D4",
50
- thinkingMinimal: "#5f4e30", thinkingLow: "#80673c", thinkingMedium: "#aa8950",
51
- thinkingHigh: "#e1a741", thinkingXhigh: "#f2b54a",
52
- },
53
- export: { pageBg: "#18181e", cardBg: "#1e1e24", infoBg: "#3c3728" },
61
+ accent: "#ffb454",
62
+ chroma: 0.14,
63
+ canvasL: 0.27,
64
+ neutralHue: 70,
65
+ neutralChroma: 0.012,
66
+ hues: { heading: 60, label: 330 },
54
67
  },
55
68
  "arnative-zinc": {
56
- vars: {
57
- accent: "#a1a1aa", cyan: "#c4c4cc", blue: "#6b6b75", green: "#b5bd68", red: "#d07272",
58
- yellow: "#ffff00", text: "#d4d4d4", gray: "#8d8d8d", dimGray: "#666666", darkGray: "#505050",
59
- searchBg: "#4a4a4f", softCyan: "#a2a0ab", selectedBg: "#3b3a40", userMsgBg: "#2a292e", toolPendingBg: "#232225",
60
- toolSuccessBg: "#202828", toolErrorBg: "#342222", customMsgBg: "#23222a",
61
- },
62
- colors: {
63
- customMessageLabel: "#9575cd", mdHeading: "#f0c674", mdLink: "#81a2be",
64
- syntaxComment: "#6A9955", syntaxKeyword: "#569CD6", syntaxFunction: "#DCDCAA",
65
- syntaxVariable: "#9CDCFE", syntaxString: "#CE9178", syntaxNumber: "#B5CEA8",
66
- syntaxType: "#4EC9B0", syntaxOperator: "#D4D4D4", syntaxPunctuation: "#D4D4D4",
67
- thinkingMinimal: "#3d3d44", thinkingLow: "#595962", thinkingMedium: "#6e6e78",
68
- thinkingHigh: "#96969f", thinkingXhigh: "#b8b8c2",
69
- },
70
- export: { pageBg: "#18181e", cardBg: "#1e1e24", infoBg: "#3c3728" },
69
+ accent: "#a1a1aa",
70
+ chroma: 0.02,
71
+ canvasL: 0.26,
72
+ neutralHue: 285,
73
+ neutralChroma: 0.005,
74
+ hues: { heading: 285, label: 285 },
71
75
  },
72
76
  "arnative-violet": {
73
- vars: {
74
- accent: "#a78bfa", cyan: "#c4b5fd", blue: "#8b6ef5", green: "#b5bd68", red: "#d07272",
75
- yellow: "#ffff00", text: "#d4d4d4", gray: "#8d8d8d", dimGray: "#666666", darkGray: "#505050",
76
- searchBg: "#413563", softCyan: "#a99ad6", selectedBg: "#393248", userMsgBg: "#292531", toolPendingBg: "#221f29",
77
- toolSuccessBg: "#202828", toolErrorBg: "#342222", customMsgBg: "#292240",
78
- },
79
- colors: {
80
- customMessageLabel: "#9575cd", mdHeading: "#f0c674", mdLink: "#81a2be",
81
- syntaxComment: "#6A9955", syntaxKeyword: "#569CD6", syntaxFunction: "#DCDCAA",
82
- syntaxVariable: "#9CDCFE", syntaxString: "#CE9178", syntaxNumber: "#B5CEA8",
83
- syntaxType: "#4EC9B0", syntaxOperator: "#D4D4D4", syntaxPunctuation: "#D4D4D4",
84
- thinkingMinimal: "#413364", thinkingLow: "#614899", thinkingMedium: "#765cb2",
85
- thinkingHigh: "#8f6ef0", thinkingXhigh: "#a98bfa",
86
- },
87
- export: { pageBg: "#18181e", cardBg: "#1e1e24", infoBg: "#3c3728" },
77
+ accent: "#a78bfa",
78
+ chroma: 0.14,
79
+ canvasL: 0.27,
80
+ neutralHue: 295,
81
+ neutralChroma: 0.012,
82
+ hues: { heading: 295, label: 330 },
88
83
  },
89
84
  "arnative-emerald": {
90
- vars: {
91
- accent: "#6ee7b7", cyan: "#a7f3d0", blue: "#34b98a", green: "#b5bd68", red: "#d07272",
92
- yellow: "#ffff00", text: "#d4d4d4", gray: "#8d8d8d", dimGray: "#666666", darkGray: "#505050",
93
- searchBg: "#356351", softCyan: "#7eceaf", selectedBg: "#324840", userMsgBg: "#25312d", toolPendingBg: "#1f2925",
94
- toolSuccessBg: "#202828", toolErrorBg: "#342222", customMsgBg: "#1f3028",
95
- },
96
- colors: {
97
- customMessageLabel: "#9575cd", mdHeading: "#f0c674", mdLink: "#81a2be",
98
- syntaxComment: "#6A9955", syntaxKeyword: "#569CD6", syntaxFunction: "#DCDCAA",
99
- syntaxVariable: "#9CDCFE", syntaxString: "#CE9178", syntaxNumber: "#B5CEA8",
100
- syntaxType: "#4EC9B0", syntaxOperator: "#D4D4D4", syntaxPunctuation: "#D4D4D4",
101
- thinkingMinimal: "#305f4d", thinkingLow: "#3c8066", thinkingMedium: "#50aa87",
102
- thinkingHigh: "#41e1a4", thinkingXhigh: "#4af2b2",
103
- },
104
- export: { pageBg: "#18181e", cardBg: "#1e1e24", infoBg: "#3c3728" },
85
+ accent: "#6ee7b7",
86
+ chroma: 0.12,
87
+ canvasL: 0.27,
88
+ neutralHue: 165,
89
+ neutralChroma: 0.012,
90
+ hues: { heading: 165, label: 200 },
105
91
  },
106
-
107
- // --- strongly themed ----------------------------------------------------
108
- // Monochrome phosphor green on a near-black canvas.
109
92
  "arnative-matrix": {
110
- vars: {
111
- accent: "#00ff41", cyan: "#8dffab", blue: "#1f8f4f", green: "#47ff7a", red: "#ff5555",
112
- yellow: "#ffe066", text: "#ccffd8", gray: "#6f9f7f", dimGray: "#4f7f5f", darkGray: "#2f4f3a",
113
- searchBg: "#284b31", softCyan: "#58e07a", selectedBg: "#10452a", userMsgBg: "#0a1410", toolPendingBg: "#060f0b",
114
- toolSuccessBg: "#0b2413", toolErrorBg: "#3b1212", customMsgBg: "#08160f",
93
+ accent: "#00ff41",
94
+ chroma: 0.22,
95
+ canvasL: 0.15,
96
+ neutralHue: 145,
97
+ neutralChroma: 0.02,
98
+ hues: {
99
+ heading: 145, link: 145, label: 145,
100
+ keyword: 145, func: 145, variable: 145, string: 145, number: 145, type: 145,
115
101
  },
116
- colors: {
117
- customMessageLabel: "#8dffab", mdHeading: "#00ff41", mdLink: "#8dffab",
118
- syntaxComment: "#4f8f5f", syntaxKeyword: "#00ff41", syntaxFunction: "#9dffb4",
119
- syntaxVariable: "#d6ffdf", syntaxString: "#2fbf5f", syntaxNumber: "#7dffa0",
120
- syntaxType: "#3fe07a", syntaxOperator: "#b6ffc4", syntaxPunctuation: "#9dffb4",
121
- thinkingMinimal: "#1f4a2c", thinkingLow: "#2c7a45", thinkingMedium: "#3fae63",
122
- thinkingHigh: "#59d97f", thinkingXhigh: "#00ff41",
123
- },
124
- export: { pageBg: "#050a06", cardBg: "#0a1410", infoBg: "#123018" },
125
102
  },
126
- // Neon magenta/cyan/yellow on a deep indigo canvas.
127
103
  "arnative-cyberpunk": {
128
- vars: {
129
- accent: "#ff2e97", cyan: "#00f0ff", blue: "#2a7fd4", green: "#00ff9f", red: "#ff4d6d",
130
- yellow: "#ffe600", text: "#eaeaf5", gray: "#8b88a8", dimGray: "#6f6a9a", darkGray: "#4a4763",
131
- searchBg: "#5f3349", softCyan: "#7fd8f0", selectedBg: "#2b2350", userMsgBg: "#16131f", toolPendingBg: "#0d0b16",
132
- toolSuccessBg: "#0d2430", toolErrorBg: "#3a1524", customMsgBg: "#1a1329",
133
- },
134
- colors: {
135
- customMessageLabel: "#00f0ff", mdHeading: "#ffe600", mdLink: "#00f0ff",
136
- syntaxComment: "#6f6a9a", syntaxKeyword: "#00f0ff", syntaxFunction: "#ffe600",
137
- syntaxVariable: "#ffd9f2", syntaxString: "#ff2e97", syntaxNumber: "#ff9a3c",
138
- syntaxType: "#00ff9f", syntaxOperator: "#c8c8e8", syntaxPunctuation: "#9a96c0",
139
- thinkingMinimal: "#33285c", thinkingLow: "#4d3a94", thinkingMedium: "#7a4fd6",
140
- thinkingHigh: "#b84fe8", thinkingXhigh: "#ff4fc3",
104
+ accent: "#ff2e97",
105
+ chroma: 0.2,
106
+ canvasL: 0.16,
107
+ neutralHue: 275,
108
+ neutralChroma: 0.02,
109
+ hues: {
110
+ heading: 60, link: 190, label: 190,
111
+ keyword: 190, func: 60, variable: 320, string: 320, number: 30, type: 150,
141
112
  },
142
- export: { pageBg: "#0d0b16", cardBg: "#16131f", infoBg: "#3a2a10" },
143
113
  },
144
- // 80s retro: deep purple, neon pink, cyan.
145
114
  "arnative-synthwave": {
146
- vars: {
147
- accent: "#ff7edb", cyan: "#36f9f6", blue: "#7a5fd6", green: "#72f1b8", red: "#ff5f7e",
148
- yellow: "#fede5d", text: "#f2eafb", gray: "#9b8fb8", dimGray: "#848bbd", darkGray: "#55496e",
149
- searchBg: "#7a426a", softCyan: "#c3b1f0", selectedBg: "#443a63", userMsgBg: "#2a2138", toolPendingBg: "#1f1830",
150
- toolSuccessBg: "#1f2e29", toolErrorBg: "#402134", customMsgBg: "#2e1f3d",
115
+ accent: "#ff7edb",
116
+ chroma: 0.17,
117
+ canvasL: 0.22,
118
+ neutralHue: 300,
119
+ neutralChroma: 0.015,
120
+ hues: {
121
+ heading: 55, link: 180, label: 180,
122
+ keyword: 55, func: 180, variable: 280, string: 30, number: 350, type: 320,
151
123
  },
152
- colors: {
153
- customMessageLabel: "#36f9f6", mdHeading: "#fede5d", mdLink: "#36f9f6",
154
- syntaxComment: "#848bbd", syntaxKeyword: "#fede5d", syntaxFunction: "#36f9f6",
155
- syntaxVariable: "#f2eafb", syntaxString: "#ff8b39", syntaxNumber: "#f97e72",
156
- syntaxType: "#ff7edb", syntaxOperator: "#f2eafb", syntaxPunctuation: "#b8a6e8",
157
- thinkingMinimal: "#4a3a6b", thinkingLow: "#6b4f9e", thinkingMedium: "#9166d6",
158
- thinkingHigh: "#d47ee8", thinkingXhigh: "#ff7edb",
159
- },
160
- export: { pageBg: "#1f1830", cardBg: "#2a2138", infoBg: "#443a63" },
161
124
  },
162
- // Warm retro: brownish grey canvas, gold + aqua + brick.
163
125
  "arnative-gruvbox": {
164
- vars: {
165
- accent: "#fabd2f", cyan: "#8ec07c", blue: "#83a598", green: "#b8bb26", red: "#fc6654",
166
- yellow: "#fabd2f", text: "#ebdbb2", gray: "#a89984", dimGray: "#928374", darkGray: "#665c54",
167
- searchBg: "#6c5d3a", softCyan: "#d5c4a1", selectedBg: "#504945", userMsgBg: "#32302f", toolPendingBg: "#1d2021",
168
- toolSuccessBg: "#2a3225", toolErrorBg: "#3c2c27", customMsgBg: "#3c3836",
169
- },
170
- colors: {
171
- customMessageLabel: "#d3869b", mdHeading: "#fabd2f", mdLink: "#83a598",
172
- syntaxComment: "#928374", syntaxKeyword: "#fb4934", syntaxFunction: "#b8bb26",
173
- syntaxVariable: "#83a598", syntaxString: "#b8bb26", syntaxNumber: "#d3869b",
174
- syntaxType: "#fabd2f", syntaxOperator: "#ebdbb2", syntaxPunctuation: "#d5c4a1",
175
- thinkingMinimal: "#665c54", thinkingLow: "#a89984", thinkingMedium: "#d5c4a1",
176
- thinkingHigh: "#fabd2f", thinkingXhigh: "#fe8019",
126
+ accent: "#fabd2f",
127
+ chroma: 0.14,
128
+ canvasL: 0.24,
129
+ neutralHue: 70,
130
+ neutralChroma: 0.015,
131
+ hues: {
132
+ heading: 45, link: 200, label: 330,
133
+ keyword: 25, func: 70, variable: 200, string: 70, number: 330, type: 45,
177
134
  },
178
- export: { pageBg: "#1d2021", cardBg: "#282828", infoBg: "#3c3836" },
179
135
  },
180
- // Cool and calm Nord: frost blue + aurora.
181
136
  "arnative-nord": {
182
- vars: {
183
- accent: "#88c0d0", cyan: "#8fbcbb", blue: "#5e81ac", green: "#a3be8c", red: "#d4979d",
184
- yellow: "#ebcb8b", text: "#eceff4", gray: "#9da8b7", dimGray: "#7b869b", darkGray: "#4c566a",
185
- searchBg: "#476e79", softCyan: "#bcd3e0", selectedBg: "#434c5e", userMsgBg: "#3b4252", toolPendingBg: "#2e3440",
186
- toolSuccessBg: "#333f38", toolErrorBg: "#4a383c", customMsgBg: "#38404f",
137
+ accent: "#88c0d0",
138
+ chroma: 0.07,
139
+ canvasL: 0.3,
140
+ neutralHue: 240,
141
+ neutralChroma: 0.01,
142
+ hues: {
143
+ heading: 40, link: 195, label: 310,
144
+ keyword: 220, func: 195, variable: 220, string: 95, number: 310, type: 195,
187
145
  },
188
- colors: {
189
- customMessageLabel: "#b48ead", mdHeading: "#ebcb8b", mdLink: "#88c0d0",
190
- syntaxComment: "#64728c", syntaxKeyword: "#81a1c1", syntaxFunction: "#88c0d0",
191
- syntaxVariable: "#d8dee9", syntaxString: "#a3be8c", syntaxNumber: "#b48ead",
192
- syntaxType: "#8fbcbb", syntaxOperator: "#81a1c1", syntaxPunctuation: "#eceff4",
193
- thinkingMinimal: "#4c566a", thinkingLow: "#5e81ac", thinkingMedium: "#81a1c1",
194
- thinkingHigh: "#88c0d0", thinkingXhigh: "#8fbcbb",
195
- },
196
- export: { pageBg: "#272c36", cardBg: "#2e3440", infoBg: "#3b4252" },
197
146
  },
198
- // Classic dark purple: pink + cyan + mint.
199
147
  "arnative-dracula": {
200
- vars: {
201
- accent: "#bd93f9", cyan: "#8be9fd", blue: "#6272a4", green: "#50fa7b", red: "#ff6363",
202
- yellow: "#f1fa8c", text: "#f8f8f2", gray: "#8e96ba", dimGray: "#6272a4", darkGray: "#4a4e63",
203
- searchBg: "#59427b", softCyan: "#c9b8f2", selectedBg: "#44475a", userMsgBg: "#343746", toolPendingBg: "#21222c",
204
- toolSuccessBg: "#26332e", toolErrorBg: "#412936", customMsgBg: "#383a52",
205
- },
206
- colors: {
207
- customMessageLabel: "#ff79c6", mdHeading: "#f1fa8c", mdLink: "#8be9fd",
208
- syntaxComment: "#6272a4", syntaxKeyword: "#ff79c6", syntaxFunction: "#50fa7b",
209
- syntaxVariable: "#8be9fd", syntaxString: "#f1fa8c", syntaxNumber: "#bd93f9",
210
- syntaxType: "#8be9fd", syntaxOperator: "#ff79c6", syntaxPunctuation: "#f8f8f2",
211
- thinkingMinimal: "#464a5d", thinkingLow: "#6272a4", thinkingMedium: "#7a6fd0",
212
- thinkingHigh: "#bd93f9", thinkingXhigh: "#ff79c6",
148
+ accent: "#bd93f9",
149
+ chroma: 0.14,
150
+ canvasL: 0.29,
151
+ neutralHue: 285,
152
+ neutralChroma: 0.012,
153
+ hues: {
154
+ heading: 60, link: 190, label: 330,
155
+ keyword: 330, func: 135, variable: 190, string: 60, number: 265, type: 190,
213
156
  },
214
- export: { pageBg: "#21222c", cardBg: "#282a36", infoBg: "#44475a" },
215
157
  },
216
158
  };
217
159
 
218
- /** Text substitution on the base; each target must appear exactly once. */
219
- function render(name, palette) {
220
- let out = baseText;
221
- const subst = (oldS, newS, what) => {
222
- const n = out.split(oldS).length - 1;
223
- if (n !== 1) throw new Error(`${name}: ${what} "${oldS}" appears ${n}x (must be 1)`);
224
- out = out.replace(oldS, newS);
160
+ // --- role order (matches the pi theme schema) ------------------------------
161
+
162
+ const ROLE_ORDER = [
163
+ "accent", "accentSoft", "border", "borderAccent", "borderMuted", "success", "error", "warning",
164
+ "muted", "dim", "text", "thinkingText",
165
+ "selectedBg", "scrollbarTrack", "scrollbarThumb", "searchMatchBg", "searchMatchText",
166
+ "userMessageBg", "userMessageText", "customMessageBg", "customMessageText", "customMessageLabel",
167
+ "toolPendingBg", "toolSuccessBg", "toolErrorBg", "toolTitle", "toolOutput",
168
+ "mdHeading", "mdLink", "mdLinkUrl", "mdCode", "mdCodeBlock", "mdCodeBlockBorder", "mdQuote",
169
+ "mdQuoteBorder", "mdHr", "mdListBullet",
170
+ "toolDiffAdded", "toolDiffRemoved", "toolDiffContext",
171
+ "syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable", "syntaxString",
172
+ "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation",
173
+ "thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh",
174
+ "thinkingXhigh", "thinkingMax", "bashMode",
175
+ ];
176
+
177
+ // --- derivation ------------------------------------------------------------
178
+
179
+ function derive(spec) {
180
+ const h = hexToOklch(spec.accent).H;
181
+ const c = spec.chroma;
182
+ const nh = spec.neutralHue;
183
+ const nc = spec.neutralChroma;
184
+ const base = spec.canvasL;
185
+ const H = { ...SYNTAX_OFFSET, ...ACCENT_OFFSET, ...(spec.hues ?? {}) };
186
+ const roleHue = (role) => hue(H[role]);
187
+ const sem = (role) => hue(SEMANTIC[role]);
188
+
189
+ const vars = {
190
+ fg: oklch(NEUTRAL.fg, nc, nh),
191
+ fgMuted: oklch(NEUTRAL.fgMuted, nc, nh),
192
+ fgDim: oklch(NEUTRAL.fgDim, nc, nh),
193
+ fgFaint: oklch(NEUTRAL.fgFaint, nc, nh),
194
+ accent: oklch(0.78, c, h),
195
+ accentSoft: oklch(0.82, c * 0.18, h),
196
+ accentBorder: oklch(0.82, c * 0.6, h),
197
+ success: oklch(0.78, Math.min(c, 0.15), sem("success")),
198
+ error: oklch(0.76, Math.min(c, 0.15), sem("error")),
199
+ warning: oklch(0.86, Math.min(c, 0.14), sem("warning")),
200
+ surfaceMsg: oklch(base, nc * 0.5, nh),
225
201
  };
226
202
 
227
- subst(`"name": ${JSON.stringify(base.name)}`, `"name": ${JSON.stringify(name)}`, "name");
228
- for (const [section, keys, required] of [
229
- ["vars", palette.vars, REQUIRED_VARS],
230
- ["colors", palette.colors, REQUIRED_COLORS],
231
- ["export", palette.export, REQUIRED_EXPORT],
232
- ]) {
233
- const missing = required.filter((k) => !(k in keys));
234
- const extra = Object.keys(keys).filter((k) => !required.includes(k));
235
- if (missing.length || extra.length) {
236
- throw new Error(`${name}/${section}: missing [${missing}] extra [${extra}]`);
237
- }
238
- for (const k of required) {
239
- const old = base[section][k];
240
- if (old === undefined) throw new Error(`${name}: base.${section}.${k} missing`);
241
- subst(`"${k}": ${JSON.stringify(old)}`, `"${k}": ${JSON.stringify(keys[k])}`, `${section}.${k}`);
242
- }
203
+ const accentLiteral = oklch(0.8, c * 0.7, h);
204
+ const roles = {
205
+ accent: "accent",
206
+ accentSoft: "accentSoft",
207
+ border: oklch(0.55, c * 0.35, h),
208
+ borderAccent: "accentBorder",
209
+ borderMuted: "fgFaint",
210
+ success: "success",
211
+ error: "error",
212
+ warning: "warning",
213
+ muted: "fgMuted",
214
+ dim: "fgDim",
215
+ text: "fg",
216
+ thinkingText: "fgMuted",
217
+
218
+ selectedBg: oklch(base + 0.07, c * 0.25, h),
219
+ scrollbarTrack: "fgFaint",
220
+ scrollbarThumb: "accentSoft",
221
+ searchMatchBg: oklch(base + 0.14, c * 0.45, h),
222
+ searchMatchText: "fg",
223
+ userMessageBg: "surfaceMsg",
224
+ userMessageText: "fg",
225
+ customMessageBg: oklch(base + 0.01, c * 0.4, roleHue("label")),
226
+ customMessageText: "fg",
227
+ customMessageLabel: oklch(0.78, c * 0.7, roleHue("label")),
228
+
229
+ toolPendingBg: oklch(base - 0.02, c * 0.3, h),
230
+ toolSuccessBg: oklch(base - 0.03, c * 0.4, sem("success")),
231
+ toolErrorBg: oklch(base - 0.03, c * 0.4, sem("error")),
232
+ toolTitle: "fg",
233
+ toolOutput: "fgMuted",
234
+
235
+ mdHeading: oklch(0.88, c * 0.6, roleHue("heading")),
236
+ mdLink: oklch(0.8, c * 0.7, roleHue("link")),
237
+ mdLinkUrl: "fgDim",
238
+ mdCode: accentLiteral,
239
+ mdCodeBlock: "success",
240
+ mdCodeBlockBorder: "fgMuted",
241
+ mdQuote: "fgMuted",
242
+ mdQuoteBorder: "fgMuted",
243
+ mdHr: "fgFaint",
244
+ mdListBullet: accentLiteral,
245
+
246
+ toolDiffAdded: "success",
247
+ toolDiffRemoved: "error",
248
+ toolDiffContext: "fgMuted",
249
+
250
+ syntaxComment: "fgDim",
251
+ syntaxKeyword: accentLiteral,
252
+ syntaxFunction: oklch(0.86, c * 0.8, roleHue("func")),
253
+ syntaxVariable: oklch(0.84, c * 0.55, roleHue("variable")),
254
+ syntaxString: oklch(0.82, c * 0.7, roleHue("string")),
255
+ syntaxNumber: oklch(0.8, c * 0.7, roleHue("number")),
256
+ syntaxType: oklch(0.8, c * 0.8, roleHue("type")),
257
+ syntaxOperator: "fg",
258
+ syntaxPunctuation: "fgMuted",
259
+
260
+ thinkingOff: oklch(THINKING.off, nc, nh),
261
+ thinkingMinimal: oklch(THINKING.minimal, c * 0.25, h),
262
+ thinkingLow: oklch(THINKING.low, c * 0.4, h),
263
+ thinkingMedium: oklch(THINKING.medium, c * 0.6, h),
264
+ thinkingHigh: oklch(THINKING.high, c * 0.8, h),
265
+ thinkingXhigh: oklch(THINKING.xhigh, c, h),
266
+ thinkingMax: "accent",
267
+
268
+ bashMode: "success",
269
+ };
270
+
271
+ const exp = {
272
+ pageBg: oklch(base - 0.06, nc, nh),
273
+ cardBg: oklch(base - 0.03, nc, nh),
274
+ infoBg: oklch(base + 0.05, c * 0.25, h),
275
+ };
276
+
277
+ // Roles must match ROLE_ORDER exactly and each value must be a var name or a literal.
278
+ const missing = ROLE_ORDER.filter((n) => !(n in roles));
279
+ const extra = Object.keys(roles).filter((n) => !ROLE_ORDER.includes(n));
280
+ assert(missing.length === 0 && extra.length === 0, `roles vs ROLE_ORDER: missing [${missing}] extra [${extra}]`);
281
+ for (const [name, value] of Object.entries(roles)) {
282
+ assert(/^oklch\(/.test(value) || value in vars, `role ${name} is neither a var nor an oklch literal: ${value}`);
243
283
  }
244
- return out;
284
+
285
+ // Distinct vars must stay visually distinct (roles may alias on purpose).
286
+ const seen = new Map();
287
+ for (const [name, value] of Object.entries(vars)) {
288
+ const { r, g, b } = oklchToRgb(...parseOklch(value));
289
+ const rgb = `${r},${g},${b}`;
290
+ if (seen.has(rgb)) throw new Error(`vars ${seen.get(rgb)} and ${name} resolve to the same color`);
291
+ seen.set(rgb, name);
292
+ }
293
+
294
+ return { vars, roles, exp };
245
295
  }
246
296
 
297
+ function parseOklch(value) {
298
+ const m = /\(([\d.]+)% ([\d.]+) ([\d.]+)\)/.exec(value);
299
+ return [Number(m[1]) / 100, Number(m[2]), Number(m[3])];
300
+ }
301
+
302
+ // --- rendering -------------------------------------------------------------
303
+
304
+ function render(name, { vars, roles, exp }) {
305
+ const lines = [
306
+ "{",
307
+ `\t"$schema": ${JSON.stringify(SCHEMA)},`,
308
+ `\t"name": ${JSON.stringify(name)},`,
309
+ `\t"appearance": "dark",`,
310
+ `\t"vars": {`,
311
+ ];
312
+ const entries = Object.entries(vars);
313
+ entries.forEach(([k, v], i) => lines.push(`\t\t${JSON.stringify(k)}: ${JSON.stringify(v)}${i < entries.length - 1 ? "," : ""}`));
314
+ lines.push("\t},", `\t"colors": {`);
315
+ const roleEntries = ROLE_ORDER.map((r) => [r, roles[r]]);
316
+ const blank = new Set(["thinkingText", "toolOutput", "mdListBullet", "syntaxPunctuation"]);
317
+ roleEntries.forEach(([k, v], i) => {
318
+ if (blank.has(k) && i > 0) lines.push("");
319
+ lines.push(`\t\t${JSON.stringify(k)}: ${JSON.stringify(v)}${i < roleEntries.length - 1 ? "," : ""}`);
320
+ });
321
+ lines.push("\t},", `\t"export": {`);
322
+ const expEntries = Object.entries(exp);
323
+ expEntries.forEach(([k, v], i) => lines.push(`\t\t${JSON.stringify(k)}: ${JSON.stringify(v)}${i < expEntries.length - 1 ? "," : ""}`));
324
+ lines.push("\t}", "}");
325
+ return lines.join("\n") + "\n";
326
+ }
327
+
328
+ // --- main ------------------------------------------------------------------
329
+
247
330
  const check = process.argv.includes("--check");
248
- let failed = 0;
249
- for (const name of Object.keys(PALETTES)) {
250
- const content = render(name, PALETTES[name]);
251
- const file = new URL(`${name}.json`, import.meta.url);
331
+ let drifted = 0;
332
+ for (const [name, spec] of Object.entries(THEMES)) {
333
+ const content = render(name, derive({ ...spec, name }));
334
+ const file = join(HERE, `${name}.json`);
252
335
  if (check) {
253
- let old = null;
254
- try {
255
- old = readFileSync(file, "utf8");
256
- } catch {}
257
- if (old !== content) {
258
- console.error(`DIFFERS: themes/${name}.json`);
259
- failed++;
336
+ const current = await readFile(file, "utf8").catch(() => "");
337
+ if (current !== content) {
338
+ console.error(`✗ ${name}.json is stale — run: node themes/gen-themes.mjs`);
339
+ drifted++;
260
340
  }
261
341
  } else {
262
- writeFileSync(file, content);
263
- console.log(`wrote themes/${name}.json`);
342
+ await writeFile(file, content);
343
+ console.log(`✓ ${name}.json`);
264
344
  }
265
345
  }
266
346
  if (check) {
267
- if (failed) {
268
- console.error(`${failed} themes out of sync with the generator (run: node themes/gen-themes.mjs)`);
269
- process.exit(1);
270
- }
271
- console.log(`OK: ${Object.keys(PALETTES).length} themes in sync with the generator`);
272
- }
347
+ if (drifted) process.exit(1);
348
+ console.log(`✓ ${Object.keys(THEMES).length} themes in sync with gen-themes.mjs`);
349
+ }