forgedock 1.0.16 → 1.0.18

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 (190) hide show
  1. package/README.md +54 -3
  2. package/bin/cross-publish.mjs +344 -0
  3. package/bin/forgedock.mjs +2205 -541
  4. package/bin/github-app-manifest.json.license +2 -0
  5. package/bin/hooks/session-start.mjs +4 -5
  6. package/bin/registry.mjs +20 -0
  7. package/bin/tui.mjs +172 -96
  8. package/commands/adopt.md +433 -0
  9. package/commands/analytics.md +2 -0
  10. package/commands/audit-agents.md +2 -0
  11. package/commands/audit.md +2 -0
  12. package/commands/autopilot.md +77 -0
  13. package/commands/changelog.md +354 -0
  14. package/commands/cleanup.md +2 -0
  15. package/commands/compat-audit.md +330 -0
  16. package/commands/deploy-info.md +6 -5
  17. package/commands/design-bench.md +176 -0
  18. package/commands/design-render-critique-loop.md +103 -0
  19. package/commands/design.md +259 -0
  20. package/commands/diagnose.md +483 -0
  21. package/commands/explain.md +349 -0
  22. package/commands/forge-stats.md +2 -0
  23. package/commands/forgedock-init.md +2 -0
  24. package/commands/geo-audit.md +2 -0
  25. package/commands/incident-response.md +2 -0
  26. package/commands/issue.md +9 -3
  27. package/commands/milestone.md +2 -0
  28. package/commands/optimize.md +411 -0
  29. package/commands/orchestrate.md +239 -0
  30. package/commands/pipeline-health.md +60 -1
  31. package/commands/qa-sweep.md +39 -2
  32. package/commands/quality-gate.md +67 -0
  33. package/commands/replay.md +263 -0
  34. package/commands/resume.md +264 -0
  35. package/commands/review-pr-agents.md +5 -1
  36. package/commands/review-pr-staging.md +160 -2
  37. package/commands/review-pr.md +85 -3
  38. package/commands/rollback.md +2 -0
  39. package/commands/scope.md +280 -0
  40. package/commands/security-audit.md +2 -0
  41. package/commands/status.md +262 -0
  42. package/commands/sync-ecosystem.md +2 -0
  43. package/commands/test-gate.md +929 -0
  44. package/commands/validate.md +2 -0
  45. package/commands/work-on/build/architect.md +128 -18
  46. package/commands/work-on/build/context.md +130 -17
  47. package/commands/work-on/build/implement.md +7 -3
  48. package/commands/work-on/build/validate.md +2 -0
  49. package/commands/work-on/build.md +32 -7
  50. package/commands/work-on/close.md +85 -0
  51. package/commands/work-on/decompose.md +2 -0
  52. package/commands/work-on/investigate.md +30 -3
  53. package/commands/work-on/review.md +13 -1
  54. package/commands/work-on-monolithic.md +2 -0
  55. package/commands/work-on.md +676 -38
  56. package/docs/BRAND.md +51 -0
  57. package/docs/CI.md +177 -0
  58. package/docs/CONFIG.md +94 -0
  59. package/docs/FORGE-PROTOCOL.md +324 -0
  60. package/docs/articles/b-vs-c-baseline.md +210 -0
  61. package/docs/articles/full-abc-benchmark.md +205 -0
  62. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +174 -0
  63. package/docs/articles/selective-spec-loading-token-savings.md +140 -0
  64. package/docs/claude-breakpoints.json +47 -0
  65. package/docs/design/abc-benchmark.md +195 -0
  66. package/docs/design/design-architect-rationale.md +123 -0
  67. package/docs/design/design-memory.md +61 -0
  68. package/docs/design/design-spec-schema.md +248 -0
  69. package/docs/design/design-system-lint.md +150 -0
  70. package/docs/design/divergent-generation.md +106 -0
  71. package/docs/design/effects-appropriateness.md +133 -0
  72. package/docs/design/fixtures/briefs/README.md +47 -0
  73. package/docs/design/fixtures/briefs/cadence.md +39 -0
  74. package/docs/design/fixtures/briefs/plume.md +39 -0
  75. package/docs/design/fixtures/briefs/slipstream.md +39 -0
  76. package/docs/design/fixtures/briefs/tender.md +39 -0
  77. package/docs/design/fixtures/briefs/voltage.md +39 -0
  78. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-1.html +364 -0
  79. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-2.html +420 -0
  80. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-3.html +506 -0
  81. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-1.html +611 -0
  82. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-2.html +594 -0
  83. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-3.html +817 -0
  84. package/docs/design/fixtures/runs/b-vs-c-baseline/runs.json +175 -0
  85. package/docs/design/fixtures/runs/b-vs-c-baseline/scorecard.json +138 -0
  86. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-1.html +373 -0
  87. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-2.html +398 -0
  88. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-3.html +595 -0
  89. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-1.html +531 -0
  90. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-2.html +565 -0
  91. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-3.html +674 -0
  92. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-1.html +388 -0
  93. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-2.html +463 -0
  94. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-3.html +568 -0
  95. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-1.html +562 -0
  96. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-2.html +618 -0
  97. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-3.html +618 -0
  98. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-1.html +364 -0
  99. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-2.html +420 -0
  100. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-3.html +506 -0
  101. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-1.html +527 -0
  102. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-2.html +445 -0
  103. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-3.html +483 -0
  104. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-1.html +611 -0
  105. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-2.html +594 -0
  106. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-3.html +817 -0
  107. package/docs/design/fixtures/runs/full-abc/runs.json +190 -0
  108. package/docs/design/fixtures/runs/full-abc/scorecard.json +144 -0
  109. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-1.html +496 -0
  110. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-2.html +447 -0
  111. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-3.html +564 -0
  112. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-1.html +373 -0
  113. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-2.html +398 -0
  114. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-3.html +595 -0
  115. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-1.html +622 -0
  116. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-2.html +475 -0
  117. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-3.html +660 -0
  118. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-1.html +531 -0
  119. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-2.html +565 -0
  120. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-3.html +674 -0
  121. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-1.html +525 -0
  122. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-2.html +446 -0
  123. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-3.html +463 -0
  124. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-1.html +388 -0
  125. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-2.html +463 -0
  126. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-3.html +568 -0
  127. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-1.html +1068 -0
  128. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-2.html +1423 -0
  129. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-3.html +1245 -0
  130. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-1.html +305 -0
  131. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-2.html +327 -0
  132. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-3.html +428 -0
  133. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-1.html +1270 -0
  134. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-2.html +1172 -0
  135. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-3.html +1532 -0
  136. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-1.html +315 -0
  137. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-2.html +342 -0
  138. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-3.html +353 -0
  139. package/docs/design/fixtures/runs/full-abc-opus/runs.json +190 -0
  140. package/docs/design/fixtures/runs/full-abc-opus/scorecard.json +144 -0
  141. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-1.html +345 -0
  142. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-2.html +396 -0
  143. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-3.html +502 -0
  144. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-1.html +408 -0
  145. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-2.html +446 -0
  146. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-3.html +496 -0
  147. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-1.html +813 -0
  148. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-2.html +711 -0
  149. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-3.html +721 -0
  150. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-1.html +280 -0
  151. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-2.html +291 -0
  152. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-3.html +366 -0
  153. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-1.html +1010 -0
  154. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-2.html +836 -0
  155. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-3.html +930 -0
  156. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-1.html +407 -0
  157. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-2.html +396 -0
  158. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-3.html +529 -0
  159. package/docs/design/foundations/brutalist.css +294 -0
  160. package/docs/design/foundations/editorial.css +300 -0
  161. package/docs/design/foundations/minimal-luxury.css +295 -0
  162. package/docs/design/foundations/technical.css +293 -0
  163. package/docs/design/foundations/warm-photo.css +299 -0
  164. package/docs/design/reference-corpus.md +1726 -0
  165. package/docs/design/render-critique-loop.md +57 -0
  166. package/docs/site/getting-started.md +21 -0
  167. package/docs/spec-graph-schema.md +397 -0
  168. package/package.json +3 -3
  169. package/scripts/bench-scorecard.mjs +239 -0
  170. package/scripts/build-spec-graph.mjs +509 -0
  171. package/scripts/classify-lane.sh +138 -0
  172. package/scripts/design-system-lint.mjs +993 -0
  173. package/scripts/graph-query.sh +486 -0
  174. package/scripts/transition-label.sh +169 -0
  175. package/scripts/validate-pr-target.sh +30 -0
  176. package/scripts/validate-spec-graph.sh +423 -0
  177. package/templates/devdocs/README.md +72 -0
  178. package/templates/devdocs/agent/using-forgedock.md +3 -0
  179. package/templates/devdocs/agent/using-github.md +3 -0
  180. package/templates/devdocs/index.yaml +162 -0
  181. package/templates/devdocs/project/architecture.md +3 -0
  182. package/templates/devdocs/project/conventions.md +3 -0
  183. package/templates/devdocs/project/custom-instructions.md +3 -0
  184. package/templates/devdocs/project/glossary.md +3 -0
  185. package/templates/devdocs/project/stack.md +3 -0
  186. package/templates/devdocs/templates/commit-convention.md +3 -0
  187. package/templates/devdocs/templates/issue.md +3 -0
  188. package/templates/devdocs/templates/pull-request.md +3 -0
  189. package/templates/devdocs/templates/review-finding.md +3 -0
  190. package/templates/workflows/forgedock-review.yml +96 -0
@@ -0,0 +1,993 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/design-system-lint.mjs — ForgeDock deterministic design-system linter (anti-slop gate)
4
+ *
5
+ * The positive, deterministic floor of the UI Taste Harness (milestone #13, issue #884).
6
+ * Hard-fails generated landing-page output that violates the committed FORGE:DESIGN_SPEC
7
+ * (#881) by checking it against the machine-checkable "negatives" enumerated in the
8
+ * reference corpus (#880). Prose in the spec is a suggestion; a failing lint is a constraint.
9
+ *
10
+ * Scope — DETERMINISTIC negatives only (the rest are vision-critic-owned, see #882):
11
+ * N1 Purple/blue gradient hero on a white/near-white background (BLOCKING)
12
+ * N2 Inter / system-default font at default weights as display/body (BLOCKING)
13
+ * N4 Exactly three identical feature cards (structural pattern only) (WARNING — icon judgement is the critic's)
14
+ * N5 A single radius applied to everything / off-scale radii (BLOCKING)
15
+ * N6 Default Tailwind palette (slate/gray/zinc/neutral/indigo) used as brand color (BLOCKING)
16
+ * N7 The boilerplate skeleton: hero → 3 cards → testimonial → CTA (BLOCKING)
17
+ * -- Off-scale spacing (padding/margin/gap not on spacing.scale) (WARNING)
18
+ * -- Contrast: foreground vs background below acceptance.a11y.contrast_min (BLOCKING)
19
+ *
20
+ * Which checks BLOCK is driven by the spec — a check only hard-fails when the spec opts in via
21
+ * `color.rules` (e.g. "no-default-tailwind-palette", "contrast>=4.5") or `negatives[]` (e.g. "N6").
22
+ * In `--strict` mode every deterministic check blocks regardless of spec opt-in.
23
+ *
24
+ * Zero runtime dependencies — Node built-ins only (matches scripts/gen-logo.mjs). No `eval`,
25
+ * no shell interpolation of inputs (avoids the injection class of review-findings #73 / #335).
26
+ *
27
+ * Usage:
28
+ * node scripts/design-system-lint.mjs --spec <spec.json> --html <page.html> [options]
29
+ *
30
+ * Options:
31
+ * --spec <path> Path to the FORGE:DESIGN_SPEC JSON (required)
32
+ * --html <path> Path to the generated HTML file to lint (required, unless --selftest)
33
+ * --css <path> Optional separate CSS file (otherwise inline <style> + style="" are read)
34
+ * --baseline Report findings but always exit 0 (survey existing slop, never fail the build)
35
+ * --strict Every deterministic check blocks, even if the spec did not opt in
36
+ * --json Emit findings as a JSON array on stdout (for the #882 critique loop)
37
+ * --selftest Run internal self-checks (contrast math, palette table) and exit
38
+ * --help Show this help
39
+ *
40
+ * Exit codes (matches scripts/verify-env-vars.sh):
41
+ * 0 = pass (no blocking findings) 1 = blocking findings 2 = warnings only
42
+ * --baseline always exits 0, including when the spec is unreadable or malformed.
43
+ * --json always emits a JSON object on stdout, including on bad-spec errors (adds an "error" field).
44
+ *
45
+ * Example:
46
+ * node scripts/design-system-lint.mjs --spec design-spec.json --html out/index.html
47
+ * node scripts/design-system-lint.mjs --spec design-spec.json --html out/index.html --json
48
+ */
49
+
50
+ import { readFileSync } from "node:fs";
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Argument parsing
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function parseArgs(argv) {
57
+ const opts = {
58
+ spec: null,
59
+ html: null,
60
+ css: null,
61
+ baseline: false,
62
+ strict: false,
63
+ json: false,
64
+ selftest: false,
65
+ help: false,
66
+ };
67
+ for (let i = 0; i < argv.length; i++) {
68
+ const a = argv[i];
69
+ switch (a) {
70
+ case "--spec":
71
+ opts.spec = argv[++i];
72
+ break;
73
+ case "--html":
74
+ opts.html = argv[++i];
75
+ break;
76
+ case "--css":
77
+ opts.css = argv[++i];
78
+ break;
79
+ case "--baseline":
80
+ opts.baseline = true;
81
+ break;
82
+ case "--strict":
83
+ opts.strict = true;
84
+ break;
85
+ case "--json":
86
+ opts.json = true;
87
+ break;
88
+ case "--selftest":
89
+ opts.selftest = true;
90
+ break;
91
+ case "--help":
92
+ case "-h":
93
+ opts.help = true;
94
+ break;
95
+ default:
96
+ throw new Error(`Unknown argument: ${a}`);
97
+ }
98
+ }
99
+ return opts;
100
+ }
101
+
102
+ const HELP = `
103
+ Usage: node scripts/design-system-lint.mjs --spec <spec.json> --html <page.html> [options]
104
+
105
+ Deterministic design-system linter (anti-slop gate, ForgeDock #884).
106
+ Hard-fails generated output that violates the committed FORGE:DESIGN_SPEC.
107
+
108
+ Options:
109
+ --spec <path> FORGE:DESIGN_SPEC JSON (required)
110
+ --html <path> Generated HTML to lint (required, unless --selftest)
111
+ --css <path> Optional separate CSS file
112
+ --baseline Report findings but always exit 0
113
+ --strict Every deterministic check blocks, even without spec opt-in
114
+ --json Emit findings as a JSON array on stdout
115
+ --selftest Run internal self-checks and exit
116
+ --help Show this help
117
+
118
+ Exit: 0 = pass, 1 = blocking, 2 = warnings only (--baseline always 0)
119
+ `;
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Default Tailwind palette (the N6 "tell"). These are the canonical Tailwind v3
123
+ // neutral/indigo hex families most often left in as the actual brand color.
124
+ // Used as a brand/background/foreground/accent value, they signal AI-slop.
125
+ // ---------------------------------------------------------------------------
126
+
127
+ const TAILWIND_DEFAULT_HEXES = new Set(
128
+ [
129
+ // slate
130
+ "#f8fafc",
131
+ "#f1f5f9",
132
+ "#e2e8f0",
133
+ "#cbd5e1",
134
+ "#94a3b8",
135
+ "#64748b",
136
+ "#475569",
137
+ "#334155",
138
+ "#1e293b",
139
+ "#0f172a",
140
+ // gray
141
+ "#f9fafb",
142
+ "#f3f4f6",
143
+ "#e5e7eb",
144
+ "#d1d5db",
145
+ "#9ca3af",
146
+ "#6b7280",
147
+ "#4b5563",
148
+ "#374151",
149
+ "#1f2937",
150
+ "#111827",
151
+ // zinc
152
+ "#fafafa",
153
+ "#f4f4f5",
154
+ "#e4e4e7",
155
+ "#d4d4d8",
156
+ "#a1a1aa",
157
+ "#71717a",
158
+ "#52525b",
159
+ "#3f3f46",
160
+ "#27272a",
161
+ "#18181b",
162
+ // neutral
163
+ "#f5f5f5",
164
+ "#e5e5e5",
165
+ "#d4d4d4",
166
+ "#a3a3a3",
167
+ "#737373",
168
+ "#525252",
169
+ "#404040",
170
+ "#262626",
171
+ "#171717",
172
+ // indigo (the classic "AI gradient" accent)
173
+ "#eef2ff",
174
+ "#e0e7ff",
175
+ "#c7d2fe",
176
+ "#a5b4fc",
177
+ "#818cf8",
178
+ "#6366f1",
179
+ "#4f46e5",
180
+ "#4338ca",
181
+ "#3730a3",
182
+ "#312e81",
183
+ // violet / purple (the "AI gradient" partner)
184
+ "#8b5cf6",
185
+ "#7c3aed",
186
+ "#a78bfa",
187
+ "#c4b5fd",
188
+ "#a855f7",
189
+ "#9333ea",
190
+ ].map((h) => h.toLowerCase()),
191
+ );
192
+
193
+ // Tailwind class-name families that map to the default palette as a brand color.
194
+ const TAILWIND_DEFAULT_CLASS_RE =
195
+ /\b(?:bg|text|from|via|to|border|ring)-(?:slate|gray|zinc|neutral|indigo|violet|purple)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/g;
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Color utilities (WCAG contrast — standard relative-luminance formula)
199
+ // ---------------------------------------------------------------------------
200
+
201
+ function normalizeHex(hex) {
202
+ if (typeof hex !== "string") return null;
203
+ let h = hex.trim().toLowerCase();
204
+ const m = h.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
205
+ if (!m) return null;
206
+ if (m[1].length === 3) {
207
+ h =
208
+ "#" +
209
+ m[1]
210
+ .split("")
211
+ .map((c) => c + c)
212
+ .join("");
213
+ }
214
+ return h;
215
+ }
216
+
217
+ function hexToRgb(hex) {
218
+ const h = normalizeHex(hex);
219
+ if (!h) return null;
220
+ return {
221
+ r: parseInt(h.slice(1, 3), 16),
222
+ g: parseInt(h.slice(3, 5), 16),
223
+ b: parseInt(h.slice(5, 7), 16),
224
+ };
225
+ }
226
+
227
+ function relativeLuminance({ r, g, b }) {
228
+ const srgb = [r, g, b].map((v) => {
229
+ const c = v / 255;
230
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
231
+ });
232
+ return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
233
+ }
234
+
235
+ function contrastRatio(hexA, hexB) {
236
+ const a = hexToRgb(hexA);
237
+ const b = hexToRgb(hexB);
238
+ if (!a || !b) return null;
239
+ const lA = relativeLuminance(a);
240
+ const lB = relativeLuminance(b);
241
+ const lighter = Math.max(lA, lB);
242
+ const darker = Math.min(lA, lB);
243
+ return (lighter + 0.05) / (darker + 0.05);
244
+ }
245
+
246
+ // A "white / near-white" background — used by the N1 hero-gradient check.
247
+ function isNearWhite(hex) {
248
+ const rgb = hexToRgb(hex);
249
+ if (!rgb) return false;
250
+ return relativeLuminance(rgb) > 0.85;
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Findings model
255
+ // ---------------------------------------------------------------------------
256
+
257
+ const findings = [];
258
+
259
+ function record(severity, negative, message) {
260
+ // severity: "BLOCKING" | "WARNING" | "OK"
261
+ findings.push({ severity, negative, message });
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Spec helpers — which checks block is spec-driven
266
+ // ---------------------------------------------------------------------------
267
+
268
+ function specRules(spec) {
269
+ const rules =
270
+ spec.color && Array.isArray(spec.color.rules) ? spec.color.rules : [];
271
+ return rules.map((r) => String(r).toLowerCase());
272
+ }
273
+
274
+ function specNegatives(spec) {
275
+ const negs = Array.isArray(spec.negatives) ? spec.negatives : [];
276
+ // Negatives may be raw IDs ("N6"), {id:"N6"} objects, or descriptive strings.
277
+ return negs.map((n) => {
278
+ if (typeof n === "string") return n.toLowerCase();
279
+ if (n && typeof n === "object")
280
+ return String(n.id || n.negative || "").toLowerCase();
281
+ return "";
282
+ });
283
+ }
284
+
285
+ // Decide blocking severity: a check blocks if (strict) OR the spec opted in.
286
+ function blockIf(optedIn, strict) {
287
+ return optedIn || strict ? "BLOCKING" : "WARNING";
288
+ }
289
+
290
+ function specOptsIn(spec, { rule, negId }, strict) {
291
+ if (strict) return true;
292
+ const rules = specRules(spec);
293
+ const negs = specNegatives(spec);
294
+ if (rule && rules.some((r) => r.includes(rule))) return true;
295
+ if (
296
+ negId &&
297
+ negs.some(
298
+ (n) => n === negId.toLowerCase() || n.includes(negId.toLowerCase()),
299
+ )
300
+ )
301
+ return true;
302
+ return false;
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // HTML / CSS extraction helpers (tolerant regex scanning — zero deps)
307
+ // ---------------------------------------------------------------------------
308
+
309
+ function stripComments(html) {
310
+ return html.replace(/<!--[\s\S]*?-->/g, "");
311
+ }
312
+
313
+ function extractInlineStyles(html) {
314
+ // Concatenate <style>...</style> blocks + all style="..." attributes.
315
+ let css = "";
316
+ const styleBlocks = html.match(/<style[^>]*>([\s\S]*?)<\/style>/gi) || [];
317
+ for (const block of styleBlocks) {
318
+ css += block.replace(/<\/?style[^>]*>/gi, "") + "\n";
319
+ }
320
+ const styleAttrs = html.match(/style\s*=\s*"([^"]*)"/gi) || [];
321
+ for (const attr of styleAttrs) {
322
+ const m = attr.match(/style\s*=\s*"([^"]*)"/i);
323
+ if (m) css += m[1] + ";\n";
324
+ }
325
+ return css;
326
+ }
327
+
328
+ function extractAllHex(text) {
329
+ const hits = text.match(/#[0-9a-fA-F]{3,6}\b/g) || [];
330
+ return hits.map((h) => h.toLowerCase()).filter((h) => normalizeHex(h));
331
+ }
332
+
333
+ function extractClassNames(html) {
334
+ const out = [];
335
+ const classAttrs = html.match(/class\s*=\s*"([^"]*)"/gi) || [];
336
+ for (const attr of classAttrs) {
337
+ const m = attr.match(/class\s*=\s*"([^"]*)"/i);
338
+ if (m) out.push(...m[1].split(/\s+/).filter(Boolean));
339
+ }
340
+ return out;
341
+ }
342
+
343
+ // Convert a px / rem value string to a number of px (rem assumes 16px root).
344
+ function toPx(value, unit) {
345
+ const n = parseFloat(value);
346
+ if (Number.isNaN(n)) return null;
347
+ if (unit === "rem" || unit === "em") return n * 16;
348
+ return n;
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Checks
353
+ // ---------------------------------------------------------------------------
354
+
355
+ // N6 — Default Tailwind palette used as the actual brand color.
356
+ function checkPalette(spec, html, css, strict) {
357
+ const optedIn = specOptsIn(
358
+ spec,
359
+ { rule: "no-default-tailwind-palette", negId: "N6" },
360
+ strict,
361
+ );
362
+ const sev = blockIf(optedIn, strict);
363
+
364
+ // (a) Spec's own committed colors must not BE the default palette.
365
+ const color = spec.color || {};
366
+ const committed = [
367
+ ["background", color.background],
368
+ ["foreground", color.foreground],
369
+ ["accent", color.accent],
370
+ ...(color.supporting || []).map((c, i) => [`supporting[${i}]`, c]),
371
+ ];
372
+ let specHit = false;
373
+ for (const [name, val] of committed) {
374
+ const h = normalizeHex(val);
375
+ if (h && TAILWIND_DEFAULT_HEXES.has(h)) {
376
+ record(
377
+ sev,
378
+ "N6",
379
+ `Committed color ${name}=${h} is a default Tailwind palette value — not a brand color.`,
380
+ );
381
+ specHit = true;
382
+ }
383
+ }
384
+
385
+ // (b) Rendered HTML using default-palette Tailwind classes as brand surfaces.
386
+ const classHits = html.match(TAILWIND_DEFAULT_CLASS_RE) || [];
387
+ const uniqueClassHits = [...new Set(classHits)];
388
+ if (uniqueClassHits.length > 0) {
389
+ record(
390
+ sev,
391
+ "N6",
392
+ `Default Tailwind palette used as brand color via ${uniqueClassHits.length} class family/families: ${uniqueClassHits.slice(0, 8).join(", ")}${uniqueClassHits.length > 8 ? ", …" : ""}.`,
393
+ );
394
+ specHit = true;
395
+ }
396
+
397
+ // (c) Raw default-palette hexes in inline CSS used as fills/backgrounds.
398
+ const cssHexes = new Set(extractAllHex(css));
399
+ const cssDefaultHexes = [...cssHexes].filter((h) =>
400
+ TAILWIND_DEFAULT_HEXES.has(h),
401
+ );
402
+ if (cssDefaultHexes.length > 0) {
403
+ record(
404
+ sev,
405
+ "N6",
406
+ `Default Tailwind palette hex(es) in styles: ${cssDefaultHexes.slice(0, 8).join(", ")}.`,
407
+ );
408
+ specHit = true;
409
+ }
410
+
411
+ if (!specHit)
412
+ record("OK", "N6", "No default-Tailwind-palette brand color detected.");
413
+ }
414
+
415
+ // N2 — Inter / system-default font at default weights everywhere.
416
+ function checkTypography(spec, html, css, strict) {
417
+ const optedIn =
418
+ specOptsIn(spec, { rule: "non-default-typeface", negId: "N2" }, strict) ||
419
+ // typography is core taste — block by default when a non-default family was committed.
420
+ (spec.typography &&
421
+ spec.typography.display_family &&
422
+ !/inter|system|sans-serif/i.test(spec.typography.display_family));
423
+ const sev = blockIf(optedIn, strict);
424
+
425
+ const haystack = (css + " " + html).toLowerCase();
426
+ const DEFAULTS = [
427
+ "inter",
428
+ "system-ui",
429
+ "-apple-system",
430
+ "blinkmacsystemfont",
431
+ "segoe ui",
432
+ "arial",
433
+ "helvetica",
434
+ ];
435
+
436
+ // (a) Spec committed a default family as the display/body face.
437
+ const typ = spec.typography || {};
438
+ for (const [name, fam] of [
439
+ ["display_family", typ.display_family],
440
+ ["body_family", typ.body_family],
441
+ ]) {
442
+ if (fam && DEFAULTS.some((d) => String(fam).toLowerCase().includes(d))) {
443
+ record(
444
+ sev,
445
+ "N2",
446
+ `Spec ${name}="${fam}" is a default/system typeface — the Inter-at-default tell.`,
447
+ );
448
+ }
449
+ }
450
+
451
+ // (b) Rendered output declares a default family as the primary font.
452
+ const declaredFamilies = (
453
+ css.match(/font-family\s*:\s*([^;}\n]+)/gi) || []
454
+ ).map((d) => d.replace(/font-family\s*:\s*/i, "").toLowerCase());
455
+ const usesDefault = declaredFamilies.some((d) => {
456
+ const first = d.split(",")[0].replace(/['"]/g, "").trim();
457
+ return DEFAULTS.some((def) => first.includes(def));
458
+ });
459
+ const committedDisplay = typ.display_family
460
+ ? String(typ.display_family).toLowerCase()
461
+ : null;
462
+ const committedPresent =
463
+ committedDisplay &&
464
+ !DEFAULTS.some((d) => committedDisplay.includes(d)) &&
465
+ haystack.includes(
466
+ committedDisplay.replace(/['"]/g, "").split(",")[0].trim(),
467
+ );
468
+
469
+ if (usesDefault && !committedPresent) {
470
+ record(
471
+ sev,
472
+ "N2",
473
+ "Rendered output uses a default/system font-family as the primary face, and the committed non-default display family is absent.",
474
+ );
475
+ } else if (
476
+ !committedPresent &&
477
+ committedDisplay &&
478
+ !DEFAULTS.some((d) => committedDisplay.includes(d))
479
+ ) {
480
+ record(
481
+ "WARNING",
482
+ "N2",
483
+ `Committed display family "${typ.display_family}" not found in rendered output — verify the font is actually applied.`,
484
+ );
485
+ } else {
486
+ record("OK", "N2", "Committed non-default typeface appears to be applied.");
487
+ }
488
+ }
489
+
490
+ // N5 — Single radius applied to everything / off-scale radii.
491
+ function checkRadius(spec, html, css, strict) {
492
+ const optedIn =
493
+ specOptsIn(spec, { rule: "radius-system", negId: "N5" }, strict) ||
494
+ (spec.radius &&
495
+ Array.isArray(spec.radius.scale) &&
496
+ spec.radius.scale.length > 1);
497
+ const sev = blockIf(optedIn, strict);
498
+
499
+ const scale =
500
+ spec.radius && Array.isArray(spec.radius.scale)
501
+ ? spec.radius.scale.map(Number)
502
+ : null;
503
+
504
+ // Collect radii from CSS (border-radius) + Tailwind rounded-* classes.
505
+ const cssRadii = [];
506
+ for (const m of css.matchAll(/border-radius\s*:\s*([0-9.]+)(px|rem|em)/gi)) {
507
+ const px = toPx(m[1], m[2].toLowerCase());
508
+ if (px !== null) cssRadii.push(px);
509
+ }
510
+ const roundedClasses =
511
+ html.match(/\brounded(?:-(?:none|sm|md|lg|xl|2xl|3xl|full))?\b/g) || [];
512
+
513
+ // (a) Off-scale radii in CSS.
514
+ if (scale) {
515
+ const offScale = [...new Set(cssRadii)].filter(
516
+ (r) => r !== 0 && !scale.includes(r),
517
+ );
518
+ if (offScale.length > 0) {
519
+ record(
520
+ sev,
521
+ "N5",
522
+ `Off-scale border-radius value(s): ${offScale.join(", ")}px — not in spec radius.scale [${scale.join(", ")}].`,
523
+ );
524
+ }
525
+ }
526
+
527
+ // (b) Single radius applied to everything (the rounded-2xl-on-all tell).
528
+ const uniqueRounded = [...new Set(roundedClasses)];
529
+ if (
530
+ roundedClasses.length >= 4 &&
531
+ uniqueRounded.length === 1 &&
532
+ uniqueRounded[0] !== "rounded-none"
533
+ ) {
534
+ record(
535
+ sev,
536
+ "N5",
537
+ `Single radius "${uniqueRounded[0]}" applied to ${roundedClasses.length} elements — no deliberate radius system (rounded-everything tell).`,
538
+ );
539
+ } else if (
540
+ scale &&
541
+ [...new Set(cssRadii)].filter((r) => r !== 0).length === 1 &&
542
+ cssRadii.length >= 4
543
+ ) {
544
+ record(
545
+ sev,
546
+ "N5",
547
+ `Single border-radius value applied to ${cssRadii.length} elements — no deliberate radius system.`,
548
+ );
549
+ } else {
550
+ record(
551
+ "OK",
552
+ "N5",
553
+ "Radius usage looks deliberate (multiple tokens or within scale).",
554
+ );
555
+ }
556
+ }
557
+
558
+ // Off-scale spacing (padding/margin/gap not on spacing.scale).
559
+ function checkSpacing(spec, html, css, strict) {
560
+ const scale =
561
+ spec.spacing && Array.isArray(spec.spacing.scale)
562
+ ? spec.spacing.scale.map(Number)
563
+ : null;
564
+ if (!scale) {
565
+ record(
566
+ "OK",
567
+ "SPACING",
568
+ "No spacing.scale in spec — skipping off-scale spacing check.",
569
+ );
570
+ return;
571
+ }
572
+ const optedIn = specOptsIn(
573
+ spec,
574
+ { rule: "spacing-on-scale", negId: "SPACING" },
575
+ strict,
576
+ );
577
+ const sev = blockIf(optedIn, false); // spacing is a rhythm WARNING unless explicitly opted in
578
+
579
+ const values = new Set();
580
+ for (const m of css.matchAll(
581
+ /(?:padding|margin|gap|row-gap|column-gap)(?:-(?:top|right|bottom|left))?\s*:\s*([0-9.]+)(px|rem|em)/gi,
582
+ )) {
583
+ const px = toPx(m[1], m[2].toLowerCase());
584
+ if (px !== null && px !== 0) values.add(px);
585
+ }
586
+ const offScale = [...values].filter((v) => !scale.includes(v));
587
+ if (offScale.length > 0) {
588
+ record(
589
+ sev,
590
+ "SPACING",
591
+ `Off-scale spacing value(s): ${offScale.slice(0, 12).join(", ")}px — not on spacing.scale [${scale.join(", ")}].`,
592
+ );
593
+ } else {
594
+ record(
595
+ "OK",
596
+ "SPACING",
597
+ "All measured spacing values are on-scale (or none found in inline CSS).",
598
+ );
599
+ }
600
+ }
601
+
602
+ // Contrast — foreground vs background below acceptance.a11y.contrast_min.
603
+ function checkContrast(spec, strict) {
604
+ const color = spec.color || {};
605
+ const fg = color.foreground;
606
+ const bg = color.background;
607
+ const a11y = (spec.acceptance && spec.acceptance.a11y) || {};
608
+ const min = Number(a11y.contrast_min) || 4.5;
609
+
610
+ const optedIn =
611
+ specOptsIn(spec, { rule: "contrast", negId: "CONTRAST" }, strict) ||
612
+ a11y.contrast_min !== undefined;
613
+ const sev = blockIf(optedIn, strict);
614
+
615
+ if (!fg || !bg) {
616
+ record(
617
+ "WARNING",
618
+ "CONTRAST",
619
+ "Spec missing color.foreground or color.background — cannot compute contrast.",
620
+ );
621
+ return;
622
+ }
623
+ const ratio = contrastRatio(fg, bg);
624
+ if (ratio === null) {
625
+ record(
626
+ "WARNING",
627
+ "CONTRAST",
628
+ `Could not parse color.foreground (${fg}) / color.background (${bg}) as hex.`,
629
+ );
630
+ return;
631
+ }
632
+ if (ratio < min) {
633
+ record(
634
+ sev,
635
+ "CONTRAST",
636
+ `Contrast ${ratio.toFixed(2)}:1 (fg ${fg} on bg ${bg}) is below the required ${min}:1.`,
637
+ );
638
+ } else {
639
+ record(
640
+ "OK",
641
+ "CONTRAST",
642
+ `Contrast ${ratio.toFixed(2)}:1 meets the ${min}:1 floor.`,
643
+ );
644
+ }
645
+ }
646
+
647
+ // N7 — The boilerplate skeleton: hero → 3 cards → testimonial → CTA.
648
+ function checkSectionSkeleton(spec, html, strict) {
649
+ const optedIn =
650
+ specOptsIn(spec, { rule: "non-boilerplate-layout", negId: "N7" }, strict) ||
651
+ (spec.layout_grammar && Array.isArray(spec.layout_grammar.sections));
652
+ const sev = blockIf(optedIn, strict);
653
+
654
+ // Derive an ordered list of section identities from the rendered HTML.
655
+ const sectionIds = [];
656
+ for (const m of html.matchAll(/<section\b[^>]*\bid\s*=\s*"([^"]+)"/gi)) {
657
+ sectionIds.push(m[1].toLowerCase());
658
+ }
659
+ // Fall back to data-section / aria-label hints if no ids.
660
+ if (sectionIds.length === 0) {
661
+ for (const m of html.matchAll(
662
+ /<section\b[^>]*\b(?:data-section|aria-label)\s*=\s*"([^"]+)"/gi,
663
+ )) {
664
+ sectionIds.push(m[1].toLowerCase());
665
+ }
666
+ }
667
+
668
+ const looksLike = (id, kinds) => kinds.some((k) => id.includes(k));
669
+ // The exact boilerplate signature, in order.
670
+ const SKELETON = [
671
+ ["hero"],
672
+ ["feature", "card", "benefit"],
673
+ ["testimonial", "quote", "review"],
674
+ ["cta", "call-to-action", "signup", "get-started"],
675
+ ];
676
+
677
+ if (sectionIds.length >= 4) {
678
+ // Find whether the first four meaningful sections match the skeleton in order.
679
+ let idx = 0;
680
+ let matched = 0;
681
+ for (const id of sectionIds) {
682
+ if (idx < SKELETON.length && looksLike(id, SKELETON[idx])) {
683
+ matched++;
684
+ idx++;
685
+ }
686
+ }
687
+ if (matched === SKELETON.length && idx === SKELETON.length) {
688
+ record(
689
+ sev,
690
+ "N7",
691
+ `Boilerplate section skeleton detected: hero → 3-cards → testimonial → CTA (order: ${sectionIds.join(" → ")}).`,
692
+ );
693
+ return;
694
+ }
695
+ }
696
+
697
+ // Also flag if the spec's own committed layout_grammar IS the skeleton.
698
+ const specSections =
699
+ spec.layout_grammar && Array.isArray(spec.layout_grammar.sections)
700
+ ? spec.layout_grammar.sections.map((s) =>
701
+ String(s.id || "").toLowerCase(),
702
+ )
703
+ : [];
704
+ if (specSections.length >= 4) {
705
+ let idx = 0,
706
+ matched = 0;
707
+ for (const id of specSections) {
708
+ if (idx < SKELETON.length && looksLike(id, SKELETON[idx])) {
709
+ matched++;
710
+ idx++;
711
+ }
712
+ }
713
+ if (matched === SKELETON.length && idx === SKELETON.length) {
714
+ record(
715
+ sev,
716
+ "N7",
717
+ `Spec layout_grammar.sections IS the boilerplate skeleton (hero → 3-cards → testimonial → CTA).`,
718
+ );
719
+ return;
720
+ }
721
+ }
722
+
723
+ record(
724
+ "OK",
725
+ "N7",
726
+ "Section sequence is not the hero → 3-cards → testimonial → CTA boilerplate.",
727
+ );
728
+ }
729
+
730
+ // N1 — Purple/blue gradient hero on a white/near-white background.
731
+ function checkHeroGradient(spec, html, css, strict) {
732
+ const optedIn = specOptsIn(
733
+ spec,
734
+ { rule: "no-hero-gradient", negId: "N1" },
735
+ strict,
736
+ );
737
+ const sev = blockIf(optedIn, strict);
738
+
739
+ // Isolate the hero region if identifiable; otherwise scan the top of the document.
740
+ let heroBlock = "";
741
+ const heroMatch = html.match(
742
+ /<section\b[^>]*\b(?:id|class|data-section)\s*=\s*"[^"]*hero[^"]*"[^>]*>([\s\S]*?)<\/section>/i,
743
+ );
744
+ if (heroMatch) heroBlock = heroMatch[0];
745
+ else heroBlock = html.slice(0, 4000); // top of page as a heuristic hero proxy
746
+
747
+ const heroLower = heroBlock.toLowerCase();
748
+ const heroCssHexes = extractAllHex(heroBlock + " " + css);
749
+
750
+ const hasGradient =
751
+ /gradient\(/.test(heroLower) ||
752
+ /\b(?:bg-gradient-to-[a-z]+)\b/.test(heroLower);
753
+ const hasPurpleBlue =
754
+ /\b(?:from|via|to|bg)-(?:indigo|violet|purple|blue|fuchsia)-(?:300|400|500|600|700)\b/.test(
755
+ heroLower,
756
+ ) ||
757
+ heroCssHexes.some((h) => {
758
+ const rgb = hexToRgb(h);
759
+ if (!rgb) return false;
760
+ // crude "purple/blue" test: blue dominant and red present (violet) or strongly blue.
761
+ return rgb.b > 150 && rgb.b > rgb.g && rgb.b >= rgb.r;
762
+ });
763
+
764
+ // White-ish page/hero background.
765
+ const bgWhite =
766
+ isNearWhite(spec.color && spec.color.background) ||
767
+ /\bbg-white\b/.test(heroLower) ||
768
+ extractAllHex(heroBlock).some((h) => isNearWhite(h));
769
+
770
+ if (hasGradient && hasPurpleBlue && bgWhite) {
771
+ record(
772
+ sev,
773
+ "N1",
774
+ "Purple/blue gradient hero on a white/near-white background — the classic AI-gradient tell.",
775
+ );
776
+ } else if (hasGradient && hasPurpleBlue) {
777
+ record(
778
+ "WARNING",
779
+ "N1",
780
+ "Purple/blue gradient detected in the hero region — verify it is earned (background is not white).",
781
+ );
782
+ } else {
783
+ record("OK", "N1", "No purple/blue-gradient-on-white hero detected.");
784
+ }
785
+ }
786
+
787
+ // N4 — Three identical feature cards (structural pattern only; icon judgement is the critic's).
788
+ function checkRepeatedCards(spec, html) {
789
+ // Look for a feature/cards grid containing exactly three structurally-similar children.
790
+ // Heuristic: a container whose direct repeated child class appears exactly 3 times.
791
+ const classNames = extractClassNames(html);
792
+ const counts = new Map();
793
+ for (const c of classNames) {
794
+ // focus on card-like class names
795
+ if (/(?:card|feature|tile|benefit)/i.test(c)) {
796
+ counts.set(c, (counts.get(c) || 0) + 1);
797
+ }
798
+ }
799
+ const triples = [...counts.entries()]
800
+ .filter(([, n]) => n === 3)
801
+ .map(([c]) => c);
802
+ if (triples.length > 0) {
803
+ record(
804
+ "WARNING",
805
+ "N4",
806
+ `Exactly three identical card-like elements (class "${triples[0]}") — the 3-feature-cards structural tell. Vision critic (#882) judges the icons.`,
807
+ );
808
+ } else {
809
+ record(
810
+ "OK",
811
+ "N4",
812
+ "No exactly-three-identical-cards structural pattern detected.",
813
+ );
814
+ }
815
+ }
816
+
817
+ // ---------------------------------------------------------------------------
818
+ // Self-test (contrast math + palette table sanity)
819
+ // ---------------------------------------------------------------------------
820
+
821
+ function selftest() {
822
+ const cases = [
823
+ ["#000000", "#ffffff", 21, "black/white"],
824
+ ["#ffffff", "#ffffff", 1, "white/white"],
825
+ ["#777777", "#ffffff", 4.48, "gray/white ~4.48"],
826
+ ];
827
+ let ok = true;
828
+ for (const [a, b, expected, label] of cases) {
829
+ const got = contrastRatio(a, b);
830
+ const pass = Math.abs(got - expected) < 0.05;
831
+ if (!pass) ok = false;
832
+ console.log(
833
+ `${pass ? "OK" : "FAIL"}: contrast ${label} expected ~${expected}, got ${got.toFixed(2)}`,
834
+ );
835
+ }
836
+ // Palette table: indigo-600 must be flagged.
837
+ const indigo = TAILWIND_DEFAULT_HEXES.has("#4f46e5");
838
+ console.log(
839
+ `${indigo ? "OK" : "FAIL"}: indigo-600 (#4f46e5) in default-palette table`,
840
+ );
841
+ if (!indigo) ok = false;
842
+ // 3-digit hex normalization.
843
+ const norm = normalizeHex("#fff") === "#ffffff";
844
+ console.log(`${norm ? "OK" : "FAIL"}: 3-digit hex normalization`);
845
+ if (!norm) ok = false;
846
+ return ok;
847
+ }
848
+
849
+ // ---------------------------------------------------------------------------
850
+ // Exit helpers
851
+ // ---------------------------------------------------------------------------
852
+
853
+ /**
854
+ * Exits for an unusable-spec error while honoring the --baseline and --json contracts.
855
+ * Always writes `message` to stderr so the error is visible regardless of flags.
856
+ * If --json: writes a JSON error payload to stdout before exiting so consumers always
857
+ * receive parseable output (shape matches the normal --json output plus an "error" field).
858
+ * If --baseline: exits 0 (the documented contract: "--baseline always exits 0").
859
+ * Otherwise: exits 2.
860
+ */
861
+ function badSpecExit(opts, message) {
862
+ process.stderr.write(message + "\n");
863
+ if (opts.json) {
864
+ process.stdout.write(
865
+ JSON.stringify(
866
+ {
867
+ blocking: 0,
868
+ warnings: 0,
869
+ baseline: opts.baseline,
870
+ findings: [],
871
+ error: message,
872
+ },
873
+ null,
874
+ 2,
875
+ ) + "\n",
876
+ );
877
+ }
878
+ process.exit(opts.baseline ? 0 : 2);
879
+ }
880
+
881
+ // ---------------------------------------------------------------------------
882
+ // Main
883
+ // ---------------------------------------------------------------------------
884
+
885
+ function main() {
886
+ let opts;
887
+ try {
888
+ opts = parseArgs(process.argv.slice(2));
889
+ } catch (e) {
890
+ process.stderr.write(String(e.message) + "\n" + HELP);
891
+ process.exit(2);
892
+ }
893
+
894
+ if (opts.help) {
895
+ process.stdout.write(HELP);
896
+ process.exit(0);
897
+ }
898
+
899
+ if (opts.selftest) {
900
+ const ok = selftest();
901
+ process.exit(ok ? 0 : 1);
902
+ }
903
+
904
+ if (!opts.spec || !opts.html) {
905
+ process.stderr.write("ERROR: --spec and --html are required.\n" + HELP);
906
+ process.exit(2);
907
+ }
908
+
909
+ let spec, htmlRaw;
910
+ try {
911
+ spec = JSON.parse(readFileSync(opts.spec, "utf8"));
912
+ } catch (e) {
913
+ badSpecExit(
914
+ opts,
915
+ `ERROR: could not read/parse --spec ${opts.spec}: ${e.message}`,
916
+ );
917
+ }
918
+ // JSON.parse accepts null/primitives/arrays as valid JSON; the check functions
919
+ // all dereference spec.<field>, so a non-object spec (e.g. a literal `null`)
920
+ // would throw an uncaught TypeError. Degrade gracefully via badSpecExit so that
921
+ // --baseline and --json contracts are honored (see badSpecExit above).
922
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
923
+ const kind =
924
+ spec === null ? "null" : Array.isArray(spec) ? "array" : typeof spec;
925
+ badSpecExit(
926
+ opts,
927
+ `WARNING: --spec ${opts.spec} is not a design-spec object (got ${kind}); skipping deterministic checks.`,
928
+ );
929
+ }
930
+ try {
931
+ htmlRaw = readFileSync(opts.html, "utf8");
932
+ } catch (e) {
933
+ process.stderr.write(
934
+ `ERROR: could not read --html ${opts.html}: ${e.message}\n`,
935
+ );
936
+ process.exit(2);
937
+ }
938
+
939
+ const html = stripComments(htmlRaw);
940
+ let css = extractInlineStyles(htmlRaw);
941
+ if (opts.css) {
942
+ try {
943
+ css += "\n" + readFileSync(opts.css, "utf8");
944
+ } catch {
945
+ /* optional */
946
+ }
947
+ }
948
+
949
+ // Run all deterministic checks.
950
+ checkPalette(spec, html, css, opts.strict);
951
+ checkTypography(spec, html, css, opts.strict);
952
+ checkRadius(spec, html, css, opts.strict);
953
+ checkSpacing(spec, html, css, opts.strict);
954
+ checkContrast(spec, opts.strict);
955
+ checkSectionSkeleton(spec, html, opts.strict);
956
+ checkHeroGradient(spec, html, css, opts.strict);
957
+ checkRepeatedCards(spec, html);
958
+
959
+ const blocking = findings.filter((f) => f.severity === "BLOCKING");
960
+ const warnings = findings.filter((f) => f.severity === "WARNING");
961
+
962
+ if (opts.json) {
963
+ process.stdout.write(
964
+ JSON.stringify(
965
+ {
966
+ blocking: blocking.length,
967
+ warnings: warnings.length,
968
+ baseline: opts.baseline,
969
+ findings,
970
+ },
971
+ null,
972
+ 2,
973
+ ) + "\n",
974
+ );
975
+ } else {
976
+ for (const f of findings) {
977
+ process.stdout.write(`${f.severity}: [${f.negative}] ${f.message}\n`);
978
+ }
979
+ process.stdout.write("\n=== Summary ===\n");
980
+ process.stdout.write(`Checks run: ${findings.length}\n`);
981
+ process.stdout.write(`Blocking: ${blocking.length}\n`);
982
+ process.stdout.write(`Warnings: ${warnings.length}\n`);
983
+ if (opts.baseline)
984
+ process.stdout.write("Mode: --baseline (reporting only, exit 0)\n");
985
+ }
986
+
987
+ if (opts.baseline) process.exit(0);
988
+ if (blocking.length > 0) process.exit(1);
989
+ if (warnings.length > 0) process.exit(2);
990
+ process.exit(0);
991
+ }
992
+
993
+ main();