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.
- package/README.md +54 -3
- package/bin/cross-publish.mjs +344 -0
- package/bin/forgedock.mjs +2205 -541
- package/bin/github-app-manifest.json.license +2 -0
- package/bin/hooks/session-start.mjs +4 -5
- package/bin/registry.mjs +20 -0
- package/bin/tui.mjs +172 -96
- package/commands/adopt.md +433 -0
- package/commands/analytics.md +2 -0
- package/commands/audit-agents.md +2 -0
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +77 -0
- package/commands/changelog.md +354 -0
- package/commands/cleanup.md +2 -0
- package/commands/compat-audit.md +330 -0
- package/commands/deploy-info.md +6 -5
- package/commands/design-bench.md +176 -0
- package/commands/design-render-critique-loop.md +103 -0
- package/commands/design.md +259 -0
- package/commands/diagnose.md +483 -0
- package/commands/explain.md +349 -0
- package/commands/forge-stats.md +2 -0
- package/commands/forgedock-init.md +2 -0
- package/commands/geo-audit.md +2 -0
- package/commands/incident-response.md +2 -0
- package/commands/issue.md +9 -3
- package/commands/milestone.md +2 -0
- package/commands/optimize.md +411 -0
- package/commands/orchestrate.md +239 -0
- package/commands/pipeline-health.md +60 -1
- package/commands/qa-sweep.md +39 -2
- package/commands/quality-gate.md +67 -0
- package/commands/replay.md +263 -0
- package/commands/resume.md +264 -0
- package/commands/review-pr-agents.md +5 -1
- package/commands/review-pr-staging.md +160 -2
- package/commands/review-pr.md +85 -3
- package/commands/rollback.md +2 -0
- package/commands/scope.md +280 -0
- package/commands/security-audit.md +2 -0
- package/commands/status.md +262 -0
- package/commands/sync-ecosystem.md +2 -0
- package/commands/test-gate.md +929 -0
- package/commands/validate.md +2 -0
- package/commands/work-on/build/architect.md +128 -18
- package/commands/work-on/build/context.md +130 -17
- package/commands/work-on/build/implement.md +7 -3
- package/commands/work-on/build/validate.md +2 -0
- package/commands/work-on/build.md +32 -7
- package/commands/work-on/close.md +85 -0
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +30 -3
- package/commands/work-on/review.md +13 -1
- package/commands/work-on-monolithic.md +2 -0
- package/commands/work-on.md +676 -38
- package/docs/BRAND.md +51 -0
- package/docs/CI.md +177 -0
- package/docs/CONFIG.md +94 -0
- package/docs/FORGE-PROTOCOL.md +324 -0
- package/docs/articles/b-vs-c-baseline.md +210 -0
- package/docs/articles/full-abc-benchmark.md +205 -0
- package/docs/articles/per-repo-adaptive-scripts-token-savings.md +174 -0
- package/docs/articles/selective-spec-loading-token-savings.md +140 -0
- package/docs/claude-breakpoints.json +47 -0
- package/docs/design/abc-benchmark.md +195 -0
- package/docs/design/design-architect-rationale.md +123 -0
- package/docs/design/design-memory.md +61 -0
- package/docs/design/design-spec-schema.md +248 -0
- package/docs/design/design-system-lint.md +150 -0
- package/docs/design/divergent-generation.md +106 -0
- package/docs/design/effects-appropriateness.md +133 -0
- package/docs/design/fixtures/briefs/README.md +47 -0
- package/docs/design/fixtures/briefs/cadence.md +39 -0
- package/docs/design/fixtures/briefs/plume.md +39 -0
- package/docs/design/fixtures/briefs/slipstream.md +39 -0
- package/docs/design/fixtures/briefs/tender.md +39 -0
- package/docs/design/fixtures/briefs/voltage.md +39 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-1.html +364 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-2.html +420 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-3.html +506 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-1.html +611 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-2.html +594 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-3.html +817 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/runs.json +175 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/scorecard.json +138 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-1.html +373 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-2.html +398 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-3.html +595 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-1.html +531 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-2.html +565 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-3.html +674 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-1.html +388 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-2.html +463 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-3.html +568 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-1.html +562 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-2.html +618 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-3.html +618 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-1.html +364 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-2.html +420 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-3.html +506 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-1.html +527 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-2.html +445 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-3.html +483 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-1.html +611 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-2.html +594 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-3.html +817 -0
- package/docs/design/fixtures/runs/full-abc/runs.json +190 -0
- package/docs/design/fixtures/runs/full-abc/scorecard.json +144 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-1.html +496 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-2.html +447 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-3.html +564 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-1.html +373 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-2.html +398 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-3.html +595 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-1.html +622 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-2.html +475 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-3.html +660 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-1.html +531 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-2.html +565 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-3.html +674 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-1.html +525 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-2.html +446 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-3.html +463 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-1.html +388 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-2.html +463 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-3.html +568 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-1.html +1068 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-2.html +1423 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-3.html +1245 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-1.html +305 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-2.html +327 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-3.html +428 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-1.html +1270 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-2.html +1172 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-3.html +1532 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-1.html +315 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-2.html +342 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-3.html +353 -0
- package/docs/design/fixtures/runs/full-abc-opus/runs.json +190 -0
- package/docs/design/fixtures/runs/full-abc-opus/scorecard.json +144 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-1.html +345 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-2.html +396 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-3.html +502 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-1.html +408 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-2.html +446 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-3.html +496 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-1.html +813 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-2.html +711 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-3.html +721 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-1.html +280 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-2.html +291 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-3.html +366 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-1.html +1010 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-2.html +836 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-3.html +930 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-1.html +407 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-2.html +396 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-3.html +529 -0
- package/docs/design/foundations/brutalist.css +294 -0
- package/docs/design/foundations/editorial.css +300 -0
- package/docs/design/foundations/minimal-luxury.css +295 -0
- package/docs/design/foundations/technical.css +293 -0
- package/docs/design/foundations/warm-photo.css +299 -0
- package/docs/design/reference-corpus.md +1726 -0
- package/docs/design/render-critique-loop.md +57 -0
- package/docs/site/getting-started.md +21 -0
- package/docs/spec-graph-schema.md +397 -0
- package/package.json +3 -3
- package/scripts/bench-scorecard.mjs +239 -0
- package/scripts/build-spec-graph.mjs +509 -0
- package/scripts/classify-lane.sh +138 -0
- package/scripts/design-system-lint.mjs +993 -0
- package/scripts/graph-query.sh +486 -0
- package/scripts/transition-label.sh +169 -0
- package/scripts/validate-pr-target.sh +30 -0
- package/scripts/validate-spec-graph.sh +423 -0
- package/templates/devdocs/README.md +72 -0
- package/templates/devdocs/agent/using-forgedock.md +3 -0
- package/templates/devdocs/agent/using-github.md +3 -0
- package/templates/devdocs/index.yaml +162 -0
- package/templates/devdocs/project/architecture.md +3 -0
- package/templates/devdocs/project/conventions.md +3 -0
- package/templates/devdocs/project/custom-instructions.md +3 -0
- package/templates/devdocs/project/glossary.md +3 -0
- package/templates/devdocs/project/stack.md +3 -0
- package/templates/devdocs/templates/commit-convention.md +3 -0
- package/templates/devdocs/templates/issue.md +3 -0
- package/templates/devdocs/templates/pull-request.md +3 -0
- package/templates/devdocs/templates/review-finding.md +3 -0
- 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();
|