@nurix/etna 0.18.18 → 0.18.20

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 (100) hide show
  1. package/data/harness-index.json +1 -1
  2. package/data/harnesses/code/harness.json +2 -2
  3. package/data/harnesses/code/rules/general.md +2 -1
  4. package/data/harnesses/code/skills/design/SKILL.md +65 -0
  5. package/data/harnesses/code/skills/design/data/catalog/rules.csv +132 -0
  6. package/data/harnesses/code/skills/design/data/design-md/SCHEMA.md +317 -0
  7. package/data/harnesses/code/skills/design/data/design-md/library/airbnb.md +235 -0
  8. package/data/harnesses/code/skills/design/data/design-md/library/airtable.md +232 -0
  9. package/data/harnesses/code/skills/design/data/design-md/library/anthropic-claude.md +220 -0
  10. package/data/harnesses/code/skills/design/data/design-md/library/apple.md +220 -0
  11. package/data/harnesses/code/skills/design/data/design-md/library/binance.md +223 -0
  12. package/data/harnesses/code/skills/design/data/design-md/library/bmw.md +219 -0
  13. package/data/harnesses/code/skills/design/data/design-md/library/cal.md +220 -0
  14. package/data/harnesses/code/skills/design/data/design-md/library/clay.md +220 -0
  15. package/data/harnesses/code/skills/design/data/design-md/library/clickhouse.md +220 -0
  16. package/data/harnesses/code/skills/design/data/design-md/library/cohere.md +227 -0
  17. package/data/harnesses/code/skills/design/data/design-md/library/coinbase.md +237 -0
  18. package/data/harnesses/code/skills/design/data/design-md/library/composio.md +220 -0
  19. package/data/harnesses/code/skills/design/data/design-md/library/cursor.md +220 -0
  20. package/data/harnesses/code/skills/design/data/design-md/library/elevenlabs.md +220 -0
  21. package/data/harnesses/code/skills/design/data/design-md/library/expo.md +228 -0
  22. package/data/harnesses/code/skills/design/data/design-md/library/figma.md +221 -0
  23. package/data/harnesses/code/skills/design/data/design-md/library/framer.md +236 -0
  24. package/data/harnesses/code/skills/design/data/design-md/library/hashicorp.md +220 -0
  25. package/data/harnesses/code/skills/design/data/design-md/library/ibm.md +234 -0
  26. package/data/harnesses/code/skills/design/data/design-md/library/intercom.md +240 -0
  27. package/data/harnesses/code/skills/design/data/design-md/library/kraken.md +236 -0
  28. package/data/harnesses/code/skills/design/data/design-md/library/linear-app.md +237 -0
  29. package/data/harnesses/code/skills/design/data/design-md/library/lovable.md +220 -0
  30. package/data/harnesses/code/skills/design/data/design-md/library/meta.md +220 -0
  31. package/data/harnesses/code/skills/design/data/design-md/library/minimax.md +220 -0
  32. package/data/harnesses/code/skills/design/data/design-md/library/mintlify.md +220 -0
  33. package/data/harnesses/code/skills/design/data/design-md/library/miro.md +233 -0
  34. package/data/harnesses/code/skills/design/data/design-md/library/mistral-ai.md +220 -0
  35. package/data/harnesses/code/skills/design/data/design-md/library/mongodb.md +236 -0
  36. package/data/harnesses/code/skills/design/data/design-md/library/notion.md +220 -0
  37. package/data/harnesses/code/skills/design/data/design-md/library/nvidia.md +222 -0
  38. package/data/harnesses/code/skills/design/data/design-md/library/ollama.md +226 -0
  39. package/data/harnesses/code/skills/design/data/design-md/library/opencode-ai.md +223 -0
  40. package/data/harnesses/code/skills/design/data/design-md/library/posthog.md +234 -0
  41. package/data/harnesses/code/skills/design/data/design-md/library/raycast.md +220 -0
  42. package/data/harnesses/code/skills/design/data/design-md/library/replicate.md +220 -0
  43. package/data/harnesses/code/skills/design/data/design-md/library/resend.md +285 -0
  44. package/data/harnesses/code/skills/design/data/design-md/library/revolut.md +225 -0
  45. package/data/harnesses/code/skills/design/data/design-md/library/runwayml.md +219 -0
  46. package/data/harnesses/code/skills/design/data/design-md/library/sanity.md +251 -0
  47. package/data/harnesses/code/skills/design/data/design-md/library/sentry.md +244 -0
  48. package/data/harnesses/code/skills/design/data/design-md/library/shopify.md +227 -0
  49. package/data/harnesses/code/skills/design/data/design-md/library/slack.md +240 -0
  50. package/data/harnesses/code/skills/design/data/design-md/library/spacex.md +252 -0
  51. package/data/harnesses/code/skills/design/data/design-md/library/spotify.md +248 -0
  52. package/data/harnesses/code/skills/design/data/design-md/library/stripe.md +241 -0
  53. package/data/harnesses/code/skills/design/data/design-md/library/supabase.md +255 -0
  54. package/data/harnesses/code/skills/design/data/design-md/library/superhuman.md +226 -0
  55. package/data/harnesses/code/skills/design/data/design-md/library/tesla.md +233 -0
  56. package/data/harnesses/code/skills/design/data/design-md/library/theverge.md +248 -0
  57. package/data/harnesses/code/skills/design/data/design-md/library/together-ai.md +220 -0
  58. package/data/harnesses/code/skills/design/data/design-md/library/uber.md +220 -0
  59. package/data/harnesses/code/skills/design/data/design-md/library/vercel.md +220 -0
  60. package/data/harnesses/code/skills/design/data/design-md/library/voltagent.md +245 -0
  61. package/data/harnesses/code/skills/design/data/design-md/library/warp.md +220 -0
  62. package/data/harnesses/code/skills/design/data/design-md/library/webflow.md +220 -0
  63. package/data/harnesses/code/skills/design/data/design-md/library/wired.md +220 -0
  64. package/data/harnesses/code/skills/design/data/design-md/library/wise.md +220 -0
  65. package/data/harnesses/code/skills/design/data/design-md/library/x-ai.md +220 -0
  66. package/data/harnesses/code/skills/design/data/design-md/library/zapier.md +238 -0
  67. package/data/harnesses/code/skills/design/data/design-md/nurix.md +217 -0
  68. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/command-center.tsx +249 -0
  69. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/create-edit.tsx +249 -0
  70. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/dashboard.tsx +248 -0
  71. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/entity-detail.tsx +245 -0
  72. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/list-detail.tsx +223 -0
  73. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/log-stream.tsx +249 -0
  74. package/data/harnesses/code/skills/design/data/operator-pack/fixtures.md +88 -0
  75. package/data/harnesses/code/skills/design/data/operator-pack/surface-catalog.md +132 -0
  76. package/data/harnesses/code/skills/design/data/operator-pack/trust-rubric.md +76 -0
  77. package/data/harnesses/code/skills/design/references/create.md +126 -0
  78. package/data/harnesses/code/skills/design/references/diagnosis.md +108 -0
  79. package/data/harnesses/code/skills/design/references/hard-rules.md +155 -0
  80. package/data/harnesses/code/skills/design/references/improve.md +85 -0
  81. package/data/harnesses/code/skills/design/references/interaction.md +77 -0
  82. package/data/harnesses/code/skills/design/references/introspect.md +100 -0
  83. package/data/harnesses/code/skills/design/references/lifecycle.md +73 -0
  84. package/data/harnesses/code/skills/design/references/personality.md +504 -0
  85. package/data/harnesses/code/skills/design/references/structure.md +138 -0
  86. package/data/harnesses/code/skills/design/scripts/derive-personality.mjs +706 -0
  87. package/data/harnesses/code/skills/design/scripts/detector.mjs +898 -0
  88. package/data/harnesses/code/skills/design/scripts/generate-fixtures.mjs +551 -0
  89. package/data/harnesses/code/skills/design/scripts/hook-config.json +11 -0
  90. package/data/harnesses/code/skills/design/scripts/phase-gate.mjs +386 -0
  91. package/data/harnesses/code/skills/design/scripts/preship-sweep.mjs +280 -0
  92. package/data/harnesses/code/skills/design/scripts/render-rung.mjs +690 -0
  93. package/data/harnesses/code/skills/design/scripts/scaffold.mjs +552 -0
  94. package/data/harnesses/code/skills/design/scripts/self-integrity.mjs +432 -0
  95. package/data/harnesses/etna/CLAUDE.md +1 -1
  96. package/data/harnesses/etna/harness.json +2 -2
  97. package/data/harnesses/etna/rules/general.md +2 -1
  98. package/data/harnesses/mobile/harness.json +2 -2
  99. package/data/harnesses/mobile/rules/general.md +2 -1
  100. package/package.json +1 -1
@@ -0,0 +1,706 @@
1
+ #!/usr/bin/env node
2
+ // skills/design/scripts/derive-personality.mjs — design.md parser, coverage gate (--check), and token emitter (--emit) for the operator-UI personality system (D29, D6).
3
+
4
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+
7
+ // The 26 visual primitives, in emission order (names are law; no leading --).
8
+ const PRIMITIVES = [
9
+ "radius", "shadow-intensity", "border-weight", "density-gap", "density-inset",
10
+ "font-sans", "font-mono", "font-weight-normal", "font-weight-strong", "heading-tracking",
11
+ "duration-unit", "duration-fast", "duration-normal", "duration-slow", "ease-default",
12
+ "contrast-boost",
13
+ "icon-stroke", "icon-size-base",
14
+ "sidebar-width-expanded", "sidebar-width-collapsed", "topbar-height", "side-panel-width",
15
+ "side-panel-width-rich", "modal-width-max", "form-max-width", "table-row-height",
16
+ ];
17
+
18
+ // The 17 semantic color tokens, in emission order.
19
+ const SEMANTIC_TOKENS = [
20
+ "background", "foreground", "card", "card-foreground", "muted", "muted-foreground",
21
+ "border", "input", "ring", "primary", "primary-foreground", "accent", "accent-foreground",
22
+ "destructive", "destructive-foreground", "success", "warning",
23
+ ];
24
+
25
+ // The 17 substrate color tokens (the D6 set), in emission order.
26
+ const SUBSTRATE_TOKENS = [
27
+ "popover", "popover-foreground", "secondary", "secondary-foreground",
28
+ "chart-1", "chart-2", "chart-3", "chart-4", "chart-5",
29
+ "sidebar", "sidebar-foreground", "sidebar-primary", "sidebar-primary-foreground",
30
+ "sidebar-accent", "sidebar-accent-foreground", "sidebar-border", "sidebar-ring",
31
+ ];
32
+
33
+ const ALL_TOKENS = [...SEMANTIC_TOKENS, ...SUBSTRATE_TOKENS];
34
+
35
+ const LEGAL_VERBOSITY = ["full", "default", "compact", "expert"];
36
+ const LEGAL_FEEDBACK = ["subtle", "minimal"];
37
+ const LEGAL_DENSITY = ["spacious", "standard", "dense"];
38
+ const LEGAL_PROVENANCE = ["project", "extracted", "library", "default"];
39
+
40
+ // Shadow-level names ranked by increasing intensity, for the ordering check.
41
+ const SHADOW_RANK = { none: 0, "2xs": 1, xs: 2, sm: 3, md: 4, lg: 5, xl: 6, "2xl": 7 };
42
+
43
+ // Strip a trailing #-comment while respecting quoted spans (hex colors are quoted, so an unquoted # is always a comment).
44
+ function stripComment(line) {
45
+ let inSingle = false, inDouble = false;
46
+ for (let i = 0; i < line.length; i++) {
47
+ const c = line[i];
48
+ if (c === '"' && !inSingle) inDouble = !inDouble;
49
+ else if (c === "'" && !inDouble) inSingle = !inSingle;
50
+ else if (c === "#" && !inSingle && !inDouble) return line.slice(0, i);
51
+ }
52
+ return line;
53
+ }
54
+
55
+ // Remove one layer of matching surrounding quotes from a scalar.
56
+ function unquote(raw) {
57
+ const s = raw.trim();
58
+ if (s.length >= 2 && ((s[0] === '"' && s.at(-1) === '"') || (s[0] === "'" && s.at(-1) === "'"))) {
59
+ return s.slice(1, -1);
60
+ }
61
+ return s;
62
+ }
63
+
64
+ // Split a string on a delimiter at bracket-depth 0 and outside quotes (handles nested [] {} and quoted commas).
65
+ function splitTopLevel(str, delim) {
66
+ const parts = [];
67
+ let depth = 0, inSingle = false, inDouble = false, cur = "";
68
+ for (const c of str) {
69
+ if (c === '"' && !inSingle) { inDouble = !inDouble; cur += c; }
70
+ else if (c === "'" && !inDouble) { inSingle = !inSingle; cur += c; }
71
+ else if (!inSingle && !inDouble && (c === "[" || c === "{")) { depth++; cur += c; }
72
+ else if (!inSingle && !inDouble && (c === "]" || c === "}")) { depth--; cur += c; }
73
+ else if (!inSingle && !inDouble && c === delim && depth === 0) { parts.push(cur); cur = ""; }
74
+ else cur += c;
75
+ }
76
+ parts.push(cur);
77
+ return parts;
78
+ }
79
+
80
+ // Parse a scalar / inline array / inline object into a JS value.
81
+ function parseScalar(raw) {
82
+ const s = raw.trim();
83
+ if (s.startsWith("[")) return parseInlineArray(s);
84
+ if (s.startsWith("{")) return parseInlineObject(s);
85
+ return unquote(s);
86
+ }
87
+
88
+ // Parse an inline [a, b, c] array into a JS array of parsed scalars.
89
+ function parseInlineArray(s) {
90
+ const inner = s.slice(s.indexOf("[") + 1, s.lastIndexOf("]"));
91
+ if (inner.trim() === "") return [];
92
+ return splitTopLevel(inner, ",").map((x) => parseScalar(x)).filter((v) => !(typeof v === "string" && v === ""));
93
+ }
94
+
95
+ // Parse an inline { k: v, ... } object into a plain JS object.
96
+ function parseInlineObject(s) {
97
+ const inner = s.slice(s.indexOf("{") + 1, s.lastIndexOf("}"));
98
+ const obj = {};
99
+ for (const part of splitTopLevel(inner, ",")) {
100
+ if (part.trim() === "") continue;
101
+ const ci = part.indexOf(":");
102
+ if (ci < 0) continue;
103
+ obj[part.slice(0, ci).trim()] = parseScalar(part.slice(ci + 1));
104
+ }
105
+ return obj;
106
+ }
107
+
108
+ // Turn a block's raw text into meaningful {indent, content} nodes (comment-stripped, blank-dropped).
109
+ function toNodes(text) {
110
+ const nodes = [];
111
+ for (const rawLine of text.split("\n")) {
112
+ const line = stripComment(rawLine);
113
+ if (line.trim() === "") continue;
114
+ const indent = line.length - line.trimStart().length;
115
+ nodes.push({ indent, content: line.trim() });
116
+ }
117
+ return nodes;
118
+ }
119
+
120
+ // Recursively build a nested map from indentation nodes (tolerant mini-YAML for 2-space maps).
121
+ function buildMap(nodes, start, indent) {
122
+ const obj = {};
123
+ let i = start;
124
+ while (i < nodes.length) {
125
+ const node = nodes[i];
126
+ if (node.indent < indent) break;
127
+ if (node.indent > indent) { i++; continue; }
128
+ const ci = node.content.indexOf(":");
129
+ if (ci < 0) { i++; continue; }
130
+ const key = node.content.slice(0, ci).trim();
131
+ const rest = node.content.slice(ci + 1).trim();
132
+ if (rest === "") {
133
+ if (i + 1 < nodes.length && nodes[i + 1].indent > indent) {
134
+ const [child, next] = buildMap(nodes, i + 1, nodes[i + 1].indent);
135
+ obj[key] = child;
136
+ i = next;
137
+ } else {
138
+ obj[key] = {};
139
+ i++;
140
+ }
141
+ } else {
142
+ obj[key] = parseScalar(rest);
143
+ i++;
144
+ }
145
+ }
146
+ return [obj, i];
147
+ }
148
+
149
+ // Parse a fenced-block body into a nested object via the mini-YAML parser.
150
+ function parseBlock(text) {
151
+ const [obj] = buildMap(toNodes(text), 0, 0);
152
+ return obj;
153
+ }
154
+
155
+ // Extract the body of the fenced block whose info-string ends with the given label (e.g. "design-primitives").
156
+ function extractFencedBlock(source, label) {
157
+ const lines = source.split("\n");
158
+ const openRe = new RegExp("^`{3,}\\s*yaml\\s+" + label + "\\s*$");
159
+ const closeRe = /^`{3,}\s*$/;
160
+ let capturing = false;
161
+ const body = [];
162
+ for (const line of lines) {
163
+ if (!capturing) {
164
+ if (openRe.test(line.trim())) capturing = true;
165
+ continue;
166
+ }
167
+ if (closeRe.test(line.trim())) return body.join("\n");
168
+ body.push(line);
169
+ }
170
+ return capturing ? body.join("\n") : null;
171
+ }
172
+
173
+ // Parse the leading --- frontmatter into a flat object.
174
+ function parseFrontmatter(source) {
175
+ const m = /^---\s*\n([\s\S]*?)\n---\s*(\n|$)/.exec(source);
176
+ if (!m) return null;
177
+ const obj = {};
178
+ for (const rawLine of m[1].split("\n")) {
179
+ const line = stripComment(rawLine);
180
+ const ci = line.indexOf(":");
181
+ if (ci < 0) continue;
182
+ obj[line.slice(0, ci).trim()] = unquote(line.slice(ci + 1));
183
+ }
184
+ return obj;
185
+ }
186
+
187
+ // Parse a whole design.md into its three parsed parts.
188
+ function parseDesignMd(source) {
189
+ const frontmatter = parseFrontmatter(source);
190
+ const primitivesText = extractFencedBlock(source, "design-primitives");
191
+ const manifestText = extractFencedBlock(source, "lever-manifest");
192
+ return {
193
+ frontmatter,
194
+ primitives: primitivesText == null ? null : parseBlock(primitivesText),
195
+ manifest: manifestText == null ? null : parseBlock(manifestText),
196
+ };
197
+ }
198
+
199
+ // True when a parsed leaf is a bare "hint:" pending judgment.
200
+ function isHint(value) {
201
+ return typeof value === "string" && value.trim().startsWith("hint:");
202
+ }
203
+
204
+ // True when a leaf is present as a non-empty scalar.
205
+ function isFilled(value) {
206
+ return typeof value === "string" && value.trim() !== "";
207
+ }
208
+
209
+ // True when an array of numeric-ish values is strictly ascending.
210
+ function isAscending(arr) {
211
+ const nums = arr.map((v) => parseFloat(v));
212
+ if (nums.some((n) => Number.isNaN(n))) return false;
213
+ for (let i = 1; i < nums.length; i++) if (!(nums[i] > nums[i - 1])) return false;
214
+ return true;
215
+ }
216
+
217
+ // Run the coverage gate; return { errors: string[] }.
218
+ function runCoverageGate(doc) {
219
+ const errors = [];
220
+ const { frontmatter, primitives, manifest } = doc;
221
+
222
+ // Rule 1 — frontmatter with schema and legal provenance.
223
+ if (!frontmatter) {
224
+ errors.push("frontmatter: missing --- frontmatter block");
225
+ } else {
226
+ if (frontmatter.schema !== "design-md/v1") {
227
+ errors.push(`frontmatter: schema must be "design-md/v1" (found ${frontmatter.schema ?? "nothing"})`);
228
+ }
229
+ if (!LEGAL_PROVENANCE.includes(frontmatter.provenance)) {
230
+ errors.push(`frontmatter: provenance must be one of ${LEGAL_PROVENANCE.join("|")} (found ${frontmatter.provenance ?? "nothing"})`);
231
+ }
232
+ }
233
+
234
+ // Rule 2 — both machine blocks present and parseable.
235
+ if (!primitives) errors.push("block: missing or unparseable `yaml design-primitives` block");
236
+ if (!manifest) errors.push("block: missing or unparseable `yaml lever-manifest` block");
237
+ if (!primitives || !manifest) return { errors };
238
+
239
+ // Rule 3 — every primitive assigned or hinted.
240
+ for (const key of PRIMITIVES) {
241
+ const v = primitives[key];
242
+ if (v === undefined) errors.push(`missing primitive: ${key}`);
243
+ else if (!isFilled(v)) errors.push(`empty primitive: ${key}`);
244
+ }
245
+
246
+ // Rule 4 — dials, tier, and traits.
247
+ if (!LEGAL_VERBOSITY.includes(primitives.verbosity)) {
248
+ errors.push(`dial verbosity: must be one of ${LEGAL_VERBOSITY.join("|")} (found ${primitives.verbosity ?? "nothing"})`);
249
+ }
250
+ if (!LEGAL_FEEDBACK.includes(primitives.feedback)) {
251
+ errors.push(`dial feedback: must be one of ${LEGAL_FEEDBACK.join("|")} (found ${primitives.feedback ?? "nothing"})`);
252
+ }
253
+ if (!LEGAL_DENSITY.includes(primitives["density-tier"])) {
254
+ errors.push(`density-tier: must be one of ${LEGAL_DENSITY.join("|")} (found ${primitives["density-tier"] ?? "nothing"})`);
255
+ }
256
+ const traits = primitives["product-traits"];
257
+ if (!Array.isArray(traits) || traits.length === 0) {
258
+ errors.push("product-traits: must be a non-empty list");
259
+ }
260
+
261
+ // Rule 5 — every color token valued under color-tokens.light.
262
+ const light = manifest["color-tokens"] && manifest["color-tokens"].light;
263
+ if (!light || typeof light !== "object") {
264
+ errors.push("color-tokens.light: missing color-tokens.light map");
265
+ } else {
266
+ for (const token of ALL_TOKENS) {
267
+ const v = light[token];
268
+ if (v === undefined) errors.push(`missing color token: ${token}`);
269
+ else if (!isFilled(v)) errors.push(`empty color token: ${token}`);
270
+ }
271
+ }
272
+
273
+ // Rule 6 — scales, motion, fonts, icons, accent-count.
274
+ const scaleCheck = (name, arr, allowAscending = true) => {
275
+ if (!Array.isArray(arr) || arr.length === 0) errors.push(`${name}: must be a non-empty list`);
276
+ else if (allowAscending && !isAscending(arr)) errors.push(`${name}: values must be ascending (found ${arr.join(", ")})`);
277
+ };
278
+ scaleCheck("spacing-scale", manifest["spacing-scale"]);
279
+ scaleCheck("radius-scale", manifest["radius-scale"]);
280
+
281
+ const shadow = manifest["shadow-levels"];
282
+ if (!Array.isArray(shadow) || shadow.length === 0) {
283
+ errors.push("shadow-levels: must be a non-empty list");
284
+ } else {
285
+ const ranks = shadow.map((s) => SHADOW_RANK[String(s).trim()]);
286
+ if (ranks.every((r) => r !== undefined)) {
287
+ for (let i = 1; i < ranks.length; i++) {
288
+ if (!(ranks[i] > ranks[i - 1])) { errors.push(`shadow-levels: must be ordered by increasing intensity (found ${shadow.join(", ")})`); break; }
289
+ }
290
+ }
291
+ }
292
+
293
+ const motion = manifest.motion;
294
+ if (!motion || typeof motion !== "object") {
295
+ errors.push("motion: missing motion block");
296
+ } else {
297
+ scaleCheck("motion.durations", motion.durations);
298
+ if (!Array.isArray(motion.easings) || motion.easings.length === 0) errors.push("motion.easings: must be a non-empty list");
299
+ }
300
+
301
+ const fonts = manifest.fonts;
302
+ if (!fonts || typeof fonts !== "object") {
303
+ errors.push("fonts: missing fonts block");
304
+ } else {
305
+ if (!isFilled(fonts.sans)) errors.push("fonts.sans: missing or empty");
306
+ if (!isFilled(fonts.mono)) errors.push("fonts.mono: missing or empty");
307
+ }
308
+
309
+ const icons = manifest.icons;
310
+ if (!icons || typeof icons !== "object") {
311
+ errors.push("icons: missing icons block");
312
+ } else {
313
+ if (!isFilled(icons.family)) errors.push("icons.family: missing or empty");
314
+ if (icons.stroke === undefined || String(icons.stroke).trim() === "") errors.push("icons.stroke: missing or empty");
315
+ }
316
+
317
+ const accent = parseFloat(manifest["accent-count"]);
318
+ if (Number.isNaN(accent) || accent < 1) errors.push(`accent-count: must be >= 1 (found ${manifest["accent-count"] ?? "nothing"})`);
319
+
320
+ return { errors };
321
+ }
322
+
323
+ // Collect every bare "hint:" leaf across primitives, dials, and color tokens for the emit refusal.
324
+ function collectBareHints(doc) {
325
+ const hints = [];
326
+ const { primitives, manifest } = doc;
327
+ if (primitives) {
328
+ for (const key of [...PRIMITIVES, "verbosity", "feedback", "density-tier"]) {
329
+ if (isHint(primitives[key])) hints.push({ key, text: primitives[key].trim().slice(5).trim() });
330
+ }
331
+ }
332
+ const light = manifest && manifest["color-tokens"] && manifest["color-tokens"].light;
333
+ if (light) {
334
+ for (const token of ALL_TOKENS) {
335
+ if (isHint(light[token])) hints.push({ key: `color-tokens.light.${token}`, text: light[token].trim().slice(5).trim() });
336
+ }
337
+ }
338
+ return hints;
339
+ }
340
+
341
+ // Derive a rosterless data-style slug from the product name.
342
+ function slugify(name) {
343
+ return String(name || "project").toLowerCase().trim()
344
+ .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
345
+ }
346
+
347
+ // Quote multi-word font family names so the emitted CSS font stack is valid.
348
+ function quoteFontStack(stack) {
349
+ return String(stack).split(",").map((f) => {
350
+ const t = f.trim();
351
+ if (t === "") return t;
352
+ if (t.startsWith('"') || t.startsWith("'")) return t;
353
+ return /\s/.test(t) ? `"${t}"` : t;
354
+ }).filter((t) => t !== "").join(", ");
355
+ }
356
+
357
+ // Derive --ease-enter: reuse ease-default if it is already a decelerate curve, else the emphasized-decelerate arrival curve.
358
+ function deriveEaseEnter(easeDefault) {
359
+ const m = /cubic-bezier\(\s*[-\d.]+\s*,\s*([-\d.]+)/.exec(String(easeDefault));
360
+ if (m && parseFloat(m[1]) >= 0.9) return easeDefault;
361
+ return "cubic-bezier(0.16, 1, 0.3, 1)";
362
+ }
363
+
364
+ // Derive --stagger-delay in ms: duration-unit x 0.15, clamped 0-40.
365
+ function deriveStaggerMs(durationUnit) {
366
+ const n = parseFloat(durationUnit);
367
+ const raw = Number.isNaN(n) ? 0 : n * 0.15;
368
+ return Math.max(0, Math.min(40, Math.round(raw)));
369
+ }
370
+
371
+ // Build the [data-style] primitive lines (governed density refs + derived temporals inserted after ease-default).
372
+ function buildStyleBlockLines(primitives) {
373
+ const easeEnter = deriveEaseEnter(primitives["ease-default"]);
374
+ const staggerMs = deriveStaggerMs(primitives["duration-unit"]);
375
+ const lines = [];
376
+ for (const key of PRIMITIVES) {
377
+ if (key === "density-gap") lines.push(" --density-gap: var(--density-base-gap);");
378
+ else if (key === "density-inset") lines.push(" --density-inset: var(--density-base-inset);");
379
+ else if (key === "font-sans") lines.push(` --font-sans: ${quoteFontStack(primitives["font-sans"])};`);
380
+ else if (key === "font-mono") lines.push(` --font-mono: ${quoteFontStack(primitives["font-mono"])};`);
381
+ else lines.push(` --${key}: ${primitives[key]};`);
382
+ if (key === "ease-default") {
383
+ lines.push(` --ease-enter: ${easeEnter}; /* derived from ease-default */`);
384
+ lines.push(" --ease-exit: var(--ease-default); /* derived: departure mirrors the neutral curve */");
385
+ lines.push(` --stagger-delay: ${staggerMs}ms; /* derived: duration-unit × 0.15, clamped 0–40ms */`);
386
+ }
387
+ }
388
+ return lines.join("\n");
389
+ }
390
+
391
+ // Emit the full src/styles/personality.css content for a passing document.
392
+ function emitPersonalityCss(doc, slug) {
393
+ const p = doc.primitives;
394
+ const light = doc.manifest["color-tokens"].light;
395
+ const density = p["density-tier"];
396
+ const verbosity = p.verbosity;
397
+ const feedback = p.feedback;
398
+
399
+ const semanticLines = SEMANTIC_TOKENS.map((t) => ` --${t}: ${light[t]};`).join("\n");
400
+ const substrateLines = SUBSTRATE_TOKENS.map((t) => ` --${t}: ${light[t]};`).join("\n");
401
+ const styleLines = buildStyleBlockLines(p);
402
+
403
+ return `/* src/styles/personality.css — operator-UI personality tokens (emitted; do not hand-edit).
404
+ *
405
+ * Active personality — code is the cache. There is no JSON sidecar; these values
406
+ * and the <html data-*> attributes in index.html ARE the state. To change the look,
407
+ * edit the resolved design.md and re-run derive-personality.mjs --emit.
408
+ * style: ${slug} (<html data-style="${slug}">) — the project's own personality (rosterless)
409
+ * density: ${density} (<html data-density="${density}">)
410
+ * verbosity: ${verbosity} (<html data-verbosity="${verbosity}">)
411
+ * feedback: ${feedback} (<html data-feedback="${feedback}">)
412
+ * theme: light (<html data-theme="light">) — light is the default; dark is opt-in
413
+ *
414
+ * Semantic-token doctrine: components name purpose (bg-card, text-foreground, border-border);
415
+ * this file resolves those names to values. Components never use dark: prefixes or literal colors.
416
+ */
417
+
418
+ /* ── System baseline: the 26 primitives at neutral operator defaults. ──
419
+ * The safety net if <html> ever carries no data-style. The project's real
420
+ * values live in the single [data-style="${slug}"] block below, which
421
+ * overrides these. Improve/extract rewrites that block, never this baseline. */
422
+ :root {
423
+ /* geometric */
424
+ --radius: 6px;
425
+ --shadow-intensity: 1;
426
+ --border-weight: 1px;
427
+ --density-base-gap: 1; /* the density tier writes this; see the density mechanism */
428
+ --density-base-inset: 1;
429
+ --density-gap: var(--density-base-gap);
430
+ --density-inset: var(--density-base-inset);
431
+ /* typographic */
432
+ --font-sans: "Inter", system-ui, sans-serif;
433
+ --font-mono: "JetBrains Mono", ui-monospace, monospace;
434
+ --font-weight-normal: 400;
435
+ --font-weight-strong: 600;
436
+ --heading-tracking: -0.01em;
437
+ /* temporal (+ derived) */
438
+ --duration-unit: 200ms;
439
+ --duration-fast: calc(var(--duration-unit) * 0.5);
440
+ --duration-normal: var(--duration-unit);
441
+ --duration-slow: calc(var(--duration-unit) * 2);
442
+ --ease-default: cubic-bezier(0.4, 0, 0.2, 1);
443
+ --ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
444
+ --ease-exit: var(--ease-default);
445
+ --stagger-delay: 30ms;
446
+ /* surface */
447
+ --contrast-boost: 1;
448
+ /* iconography */
449
+ --icon-stroke: 1.75;
450
+ --icon-size-base: 16px;
451
+ /* operator layout */
452
+ --sidebar-width-expanded: 240px;
453
+ --sidebar-width-collapsed: 56px;
454
+ --topbar-height: 52px;
455
+ --side-panel-width: 480px;
456
+ --side-panel-width-rich: 640px;
457
+ --modal-width-max: 480px;
458
+ --form-max-width: 720px;
459
+ --table-row-height: 40px;
460
+ /* behavioral fallbacks (match verbosity=default + feedback=subtle) */
461
+ --label-display: inline-flex;
462
+ --helper-text-display: block;
463
+ --tooltip-delay: 500ms;
464
+ --feedback-press-scale: 0.99;
465
+ --focus-ring-width: 2px;
466
+ --focus-ring-offset: 1px;
467
+ }
468
+
469
+ /* ── Semantic + substrate color tokens — light theme. ──
470
+ * CASCADE CONTRACT 1: the doubled selector out-specifies the library's bare
471
+ * :root defaults in @nurix/components/styles.css — but ONLY because <html> carries
472
+ * data-theme="light". A bare :root here loses to the later library import. All 34
473
+ * tokens are set (D6): no consumed token falls back to a shadcn-gray default. */
474
+ :root,
475
+ :root[data-theme="light"] {
476
+ /* semantic (17) */
477
+ ${semanticLines}
478
+ /* substrate (17, the D6 set) — popover/secondary/chart/sidebar the library consumes */
479
+ ${substrateLines}
480
+ }
481
+
482
+ /* ── The project personality: the single [data-style] block (D3, rosterless). ──
483
+ * Named after the project — not a preset. This materializes the design.md
484
+ * design-primitives block: all 26 primitives + the derived temporal. It is the
485
+ * one block improve/extract rewrites. density-gap/inset are governed refs, so
486
+ * the global tier and per-surface step still compose. */
487
+ [data-style="${slug}"] {
488
+ ${styleLines}
489
+ }
490
+
491
+ /* ── Behavioral dials (D4) — orthogonal to the visual primitives. ── */
492
+ [data-verbosity="full"] { --label-display: inline-flex; --helper-text-display: block; --tooltip-delay: 800ms; }
493
+ [data-verbosity="default"] { --label-display: inline-flex; --helper-text-display: block; --tooltip-delay: 500ms; }
494
+ [data-verbosity="compact"] { --label-display: none; --helper-text-display: none; --tooltip-delay: 200ms; }
495
+ [data-verbosity="expert"] { --label-display: none; --helper-text-display: none; --tooltip-delay: 0ms; }
496
+
497
+ [data-feedback="subtle"] { --feedback-press-scale: 0.99; --focus-ring-width: 2px; --focus-ring-offset: 1px; }
498
+ [data-feedback="minimal"] { --feedback-press-scale: 1; --focus-ring-width: 1px; --focus-ring-offset: 0px; }
499
+
500
+ /* ── Density: global tier → base multipliers (D31). ──
501
+ * The tier writes ONLY the base; --density-gap/inset resolve from it (above),
502
+ * so a per-surface step can re-derive relative to the tier. */
503
+ [data-density="dense"] { --density-base-gap: 0.85; --density-base-inset: 0.85; }
504
+ [data-density="standard"] { --density-base-gap: 1; --density-base-inset: 1; }
505
+ [data-density="spacious"] { --density-base-gap: 1.15; --density-base-inset: 1.15; }
506
+
507
+ /* ── Per-archetype density modifier (D31) — relative step on the tier, pure cascade, no JS. ──
508
+ * A surface element carries data-density-step; it re-derives one step off the
509
+ * inherited base. Reads --density-base-* (never itself) so it composes with the
510
+ * tier and never absolutely overrides it. The recomputed --density-gap flows into
511
+ * the library's p-*/gap-* through the @theme --spacing remap. */
512
+ [data-density-step="tighter"] { --density-gap: calc(var(--density-base-gap) * 0.85); --density-inset: calc(var(--density-base-inset) * 0.85); }
513
+ [data-density-step="looser"] { --density-gap: calc(var(--density-base-gap) * 1.15); --density-inset: calc(var(--density-base-inset) * 1.15); }
514
+
515
+ /* ── Dark theme — OPT-IN ONLY. ──
516
+ * Emitted only when the design.md ships a lever-manifest color-tokens.dark map.
517
+ * Light stays the default; this block never appears for a light-only project.
518
+ [data-theme="dark"] {
519
+ --background: ...; --foreground: ...; ... all 34 tokens remapped ...
520
+ } */
521
+
522
+ /* ── Accessibility ── */
523
+ @media (prefers-reduced-motion: reduce) {
524
+ :root, [data-style] {
525
+ --duration-unit: 0ms; --stagger-delay: 0ms;
526
+ --ease-default: linear; --ease-enter: linear; --ease-exit: linear;
527
+ --feedback-press-scale: 1;
528
+ }
529
+ }
530
+ @media (prefers-contrast: more) {
531
+ :root, [data-style] {
532
+ --contrast-boost: 1.15; --border-weight: 2px; --shadow-intensity: 0; --icon-stroke: 2;
533
+ }
534
+ }
535
+ `;
536
+ }
537
+
538
+ // Emit the full src/styles/globals.css content (Tailwind v4 glue + @theme inline mappings).
539
+ function emitGlobalsCss(doc) {
540
+ const p = doc.primitives;
541
+ const fontSans = quoteFontStack(p["font-sans"]);
542
+ const fontMono = quoteFontStack(p["font-mono"]);
543
+ const fontWeightNormal = p["font-weight-normal"];
544
+ const colorMaps = ALL_TOKENS.map((t) => ` --color-${t}: var(--${t});`).join("\n");
545
+
546
+ return `/* src/styles/globals.css — Tailwind v4 glue + @theme inline mappings (emitted). */
547
+
548
+ /* CASCADE CONTRACT 2: this four-step order is LOAD-BEARING. The library's
549
+ * styles.css ships its own :root token defaults and its own @theme radius scale;
550
+ * our @theme MUST come AFTER the import to win the radius scale and own every
551
+ * mapping. rules/general.md (frontend section) states the same order — co-authority. */
552
+ @import "./personality.css"; /* 1. token source (must be first) */
553
+ @import "tailwindcss"; /* 2. framework */
554
+ @import "@nurix/components/styles.css"; /* 3. library (before our @theme) */
555
+
556
+ @theme inline { /* 4. consumer theme — AFTER the library, so it wins */
557
+ /* radius scale — shadcn offsets on --radius, re-asserted so the CONSUMER owns it.
558
+ * A flat personality sets --radius: 0 → every step collapses to 0. */
559
+ --radius-sm: calc(var(--radius) - 4px);
560
+ --radius-md: calc(var(--radius) - 2px);
561
+ --radius-lg: var(--radius);
562
+ --radius-xl: calc(var(--radius) + 4px);
563
+
564
+ /* density remap — the single line that scales ALL library spacing. Every p-*/gap-*
565
+ * is calc(var(--spacing) × N); driving --spacing off --density-gap flows the tier
566
+ * AND the per-surface density step into the library's internals. */
567
+ --spacing: calc(0.25rem * var(--density-gap));
568
+
569
+ /* shadow — alpha rides --shadow-intensity (matches the library's shadow-xs/sm/md/lg use) */
570
+ --shadow-2xs: 0 1px 2px 0 rgb(15 23 42 / calc(0.04 * var(--shadow-intensity)));
571
+ --shadow-xs: 0 1px 2px 0 rgb(15 23 42 / calc(0.06 * var(--shadow-intensity)));
572
+ --shadow-sm: 0 1px 3px 0 rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 1px 2px -1px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
573
+ --shadow-md: 0 4px 6px -1px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 2px 4px -2px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
574
+ --shadow-lg: 0 10px 15px -3px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 4px 6px -4px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
575
+
576
+ /* font weight — two-weight discipline: medium/semibold/bold all resolve to the
577
+ * single emphasis weight; operator UI carries no third weight. normal is emitted
578
+ * as a literal because its theme key and the primitive share a name (no self-ref). */
579
+ --font-weight-normal: ${fontWeightNormal};
580
+ --font-weight-medium: var(--font-weight-strong);
581
+ --font-weight-semibold: var(--font-weight-strong);
582
+ --font-weight-bold: var(--font-weight-strong);
583
+
584
+ /* tracking — heading tightness reaches the library's tracking-* utilities */
585
+ --tracking-tighter: var(--heading-tracking);
586
+ --tracking-tight: var(--heading-tracking);
587
+
588
+ /* fonts — emitted as the literal stack: a font is not a runtime-toggled token, and
589
+ * the theme key shares a name with the personality primitive (no self-reference).
590
+ * personality.css carries the same --font-sans/--font-mono for direct var() use. */
591
+ --font-sans: ${fontSans};
592
+ --font-mono: ${fontMono};
593
+
594
+ /* transitions */
595
+ --transition-fast: var(--duration-fast) var(--ease-default);
596
+ --transition-normal: var(--duration-normal) var(--ease-default);
597
+ --transition-slow: var(--duration-slow) var(--ease-default);
598
+ --transition-enter: var(--duration-normal) var(--ease-enter);
599
+ --transition-exit: var(--duration-fast) var(--ease-exit);
600
+
601
+ /* semantic + substrate color maps — all 34, so bg-popover / bg-chart-1 / bg-sidebar resolve.
602
+ * --color-destructive-foreground is mapped explicitly (D6): the library leaves it unmapped. */
603
+ ${colorMaps}
604
+ }
605
+
606
+ /* dark variant — emitted ONLY when the project ships dark; redefines the library's
607
+ * class-based dark to follow data-theme so the library's dark: utilities honor our
608
+ * convention. Absent for a light-only project.
609
+ @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); */
610
+ `;
611
+ }
612
+
613
+ // Emit the index.html <html> attribute snippet the shell must carry.
614
+ function emitIndexHtmlAttributes(doc, slug) {
615
+ const p = doc.primitives;
616
+ return `<!-- Personality state lives in these <html data-*> attributes — code is the cache.
617
+ There is no sidecar JSON. To change the look, edit the resolved design.md and
618
+ re-run derive-personality.mjs --emit; to nudge one axis, edit an attribute here. -->
619
+ <html
620
+ lang="en"
621
+ data-style="${slug}"
622
+ data-density="${p["density-tier"]}"
623
+ data-verbosity="${p.verbosity}"
624
+ data-feedback="${p.feedback}"
625
+ data-theme="light"
626
+ >
627
+ `;
628
+ }
629
+
630
+ // Write a file, creating parent directories as needed.
631
+ function writeFileEnsured(path, content) {
632
+ mkdirSync(dirname(path), { recursive: true });
633
+ writeFileSync(path, content);
634
+ }
635
+
636
+ // Print usage and exit non-zero.
637
+ function usage() {
638
+ process.stderr.write("usage: derive-personality.mjs <design.md> [--check | --emit <outdir>]\n");
639
+ process.exit(2);
640
+ }
641
+
642
+ // Entry point: parse argv, run --check or --emit.
643
+ function main() {
644
+ const argv = process.argv.slice(2);
645
+ let designPath = null;
646
+ let mode = "check";
647
+ let outdir = null;
648
+ for (let i = 0; i < argv.length; i++) {
649
+ const a = argv[i];
650
+ if (a === "--check") mode = "check";
651
+ else if (a === "--emit") { mode = "emit"; outdir = argv[++i] ?? null; }
652
+ else if (!a.startsWith("-") && designPath === null) designPath = a;
653
+ else usage();
654
+ }
655
+ if (!designPath) usage();
656
+ if (mode === "emit" && !outdir) usage();
657
+
658
+ let source;
659
+ try {
660
+ source = readFileSync(designPath, "utf8");
661
+ } catch {
662
+ process.stderr.write(`error: cannot read ${designPath}\n`);
663
+ process.exit(2);
664
+ }
665
+
666
+ const doc = parseDesignMd(source);
667
+ const { errors } = runCoverageGate(doc);
668
+ const slug = slugify(doc.frontmatter && doc.frontmatter.product);
669
+
670
+ if (errors.length > 0) {
671
+ process.stderr.write(`coverage gate FAILED (${designPath}): ${errors.length} issue(s)\n`);
672
+ for (const e of errors) process.stderr.write(` - ${e}\n`);
673
+ process.exit(1);
674
+ }
675
+
676
+ if (mode === "check") {
677
+ const provenance = doc.frontmatter.provenance;
678
+ process.stdout.write(`coverage gate PASSED (${designPath}): 26 primitives, verbosity/feedback/density-tier, 34 color tokens, scales OK — provenance=${provenance}, style=${slug}\n`);
679
+ process.exit(0);
680
+ }
681
+
682
+ // --emit: refuse while any bare hint remains.
683
+ const hints = collectBareHints(doc);
684
+ if (hints.length > 0) {
685
+ process.stderr.write(`emit REFUSED (${designPath}): ${hints.length} unresolved hint(s) — resolve each to a concrete value before emission.\n`);
686
+ for (const h of hints) process.stderr.write(` - ${h.key}: hint: ${h.text}\n`);
687
+ process.stderr.write(" Resolve with judgment and record the rationale: \"<key>: <value> # derived: <rationale>\", then re-run --emit.\n");
688
+ process.exit(1);
689
+ }
690
+
691
+ const personalityCss = emitPersonalityCss(doc, slug);
692
+ const globalsCss = emitGlobalsCss(doc);
693
+ const htmlAttrs = emitIndexHtmlAttributes(doc, slug);
694
+ const personalityPath = join(outdir, "src/styles/personality.css");
695
+ const globalsPath = join(outdir, "src/styles/globals.css");
696
+ const attrsPath = join(outdir, "index-html-attributes.txt");
697
+ writeFileEnsured(personalityPath, personalityCss);
698
+ writeFileEnsured(globalsPath, globalsCss);
699
+ writeFileEnsured(attrsPath, htmlAttrs);
700
+
701
+ process.stdout.write(`emitted personality system for style=${slug} → ${outdir}\n`);
702
+ process.stdout.write(` ${personalityPath}\n ${globalsPath}\n ${attrsPath}\n`);
703
+ process.exit(0);
704
+ }
705
+
706
+ main();