start-vibing 4.3.1 → 4.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ // score-typeui.mjs — rank typeui-* skills by fit against a site fingerprint.
3
+ //
4
+ // Usage:
5
+ // node score-typeui.mjs <fingerprint.json> [--top 3]
6
+ // node score-typeui.mjs --from-audit <audit-dir> # derive fingerprint from sd-audit findings + DIS
7
+ // node score-typeui.mjs --list # list available typeui skills with axes
8
+ //
9
+ // Fingerprint input (JSON):
10
+ // { density: "low|medium|high", contrast: "low|medium|high",
11
+ // geometry: "round|square|mixed", color: "muted|vibrant|bold",
12
+ // typography: "neutral|expressive|display", motion: "static|subtle|dynamic",
13
+ // audience: "enterprise|developer|consumer|creative" }
14
+ //
15
+ // Output (stdout): JSON { ranked: [{ name, fit, reasons, applies_tokens }] }
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+
21
+ const AXES = ['density', 'contrast', 'geometry', 'color', 'typography', 'motion', 'audience'];
22
+
23
+ // Static fingerprint matrix — derived from each typeui SKILL.md style foundations.
24
+ // Keep in sync with skills at ~/.claude/skills/typeui-*/SKILL.md. Expand via harvest-typeui.sh.
25
+ const TYPEUI = {
26
+ ant: {
27
+ density: 'high', contrast: 'medium', geometry: 'square',
28
+ color: 'muted', typography: 'neutral', motion: 'static', audience: 'enterprise',
29
+ tokens: { primary: '#1677ff', radius: 6, spacing: [4, 8, 12, 16, 24, 32] },
30
+ summary: 'Structured, enterprise-focused design. Data-dense tables, forms.',
31
+ },
32
+ application: {
33
+ density: 'medium', contrast: 'high', geometry: 'round',
34
+ color: 'vibrant', typography: 'neutral', motion: 'subtle', audience: 'developer',
35
+ tokens: { primary: '#9333ea', radius: 8, spacing: [4, 8, 12, 16, 24, 32] },
36
+ summary: 'Vercel/GitHub purple aesthetic. Top-bar nav, card layouts.',
37
+ },
38
+ artistic: {
39
+ density: 'low', contrast: 'high', geometry: 'mixed',
40
+ color: 'bold', typography: 'expressive', motion: 'dynamic', audience: 'creative',
41
+ tokens: { primary: '#000000', radius: 0, spacing: [8, 16, 24, 40, 64, 96] },
42
+ summary: 'High-contrast, expressive. Creative typography, bold color.',
43
+ },
44
+ bento: {
45
+ density: 'medium', contrast: 'medium', geometry: 'round',
46
+ color: 'muted', typography: 'neutral', motion: 'subtle', audience: 'consumer',
47
+ tokens: { primary: '#0ea5e9', radius: 16, spacing: [8, 16, 24, 32, 48, 64] },
48
+ summary: 'Modular grid of card-blocks. Clear hierarchy, soft spacing.',
49
+ },
50
+ bold: {
51
+ density: 'low', contrast: 'high', geometry: 'square',
52
+ color: 'bold', typography: 'display', motion: 'dynamic', audience: 'consumer',
53
+ tokens: { primary: '#111111', radius: 4, spacing: [8, 16, 24, 48, 80, 128] },
54
+ summary: 'Strong visual presence. Heavyweight type, high-contrast.',
55
+ },
56
+ clean: {
57
+ density: 'low', contrast: 'medium', geometry: 'round',
58
+ color: 'muted', typography: 'neutral', motion: 'static', audience: 'consumer',
59
+ tokens: { primary: '#18181b', radius: 8, spacing: [8, 16, 24, 32, 48, 72] },
60
+ summary: 'Simplicity-focused. Whitespace, legible type, limited palette.',
61
+ },
62
+ dashboard: {
63
+ density: 'high', contrast: 'high', geometry: 'round',
64
+ color: 'vibrant', typography: 'neutral', motion: 'subtle', audience: 'developer',
65
+ tokens: { primary: '#3b82f6', radius: 6, spacing: [4, 8, 12, 16, 24, 32] },
66
+ summary: 'Dark cloud-platform. Modular grids, glass panels, data hierarchy.',
67
+ },
68
+ doodle: {
69
+ density: 'low', contrast: 'medium', geometry: 'mixed',
70
+ color: 'vibrant', typography: 'expressive', motion: 'dynamic', audience: 'creative',
71
+ tokens: { primary: '#f59e0b', radius: 12, spacing: [8, 16, 24, 40, 64, 96] },
72
+ summary: 'Hand-drawn, sketch-like. Doodles, handwritten fonts.',
73
+ },
74
+ dramatic: {
75
+ density: 'low', contrast: 'high', geometry: 'mixed',
76
+ color: 'bold', typography: 'display', motion: 'dynamic', audience: 'creative',
77
+ tokens: { primary: '#dc2626', radius: 0, spacing: [16, 32, 48, 80, 128, 200] },
78
+ summary: 'Theatrical. Bold layouts, immersive visuals, unconventional.',
79
+ },
80
+ enterprise: {
81
+ density: 'high', contrast: 'high', geometry: 'square',
82
+ color: 'muted', typography: 'neutral', motion: 'static', audience: 'enterprise',
83
+ tokens: { primary: '#0f172a', radius: 4, spacing: [4, 8, 12, 16, 24, 32] },
84
+ summary: 'Clean, high-contrast enterprise. Drag-drop, structured layouts.',
85
+ },
86
+ neobrutalism: {
87
+ density: 'medium', contrast: 'high', geometry: 'square',
88
+ color: 'bold', typography: 'display', motion: 'subtle', audience: 'creative',
89
+ tokens: { primary: '#ffd700', radius: 0, spacing: [8, 16, 24, 32, 48, 64] },
90
+ summary: 'Bold borders, vivid accents, raw high-contrast on warm surfaces.',
91
+ },
92
+ paper: {
93
+ density: 'medium', contrast: 'medium', geometry: 'round',
94
+ color: 'muted', typography: 'expressive', motion: 'static', audience: 'consumer',
95
+ tokens: { primary: '#78350f', radius: 4, spacing: [8, 16, 24, 32, 48, 64] },
96
+ summary: 'Paper-textured, print-inspired. Serif/sans, tactile.',
97
+ },
98
+ };
99
+
100
+ // Axis weights — density/contrast/geometry dominate; audience is secondary.
101
+ const WEIGHTS = {
102
+ density: 0.22, contrast: 0.18, geometry: 0.16, color: 0.14,
103
+ typography: 0.12, motion: 0.10, audience: 0.08,
104
+ };
105
+
106
+ function matchScore(a, b) {
107
+ if (a === b) return 1.0;
108
+ const pairs = {
109
+ 'low|medium': 0.5, 'medium|high': 0.5,
110
+ 'muted|vibrant': 0.5, 'vibrant|bold': 0.5,
111
+ 'neutral|expressive': 0.5, 'expressive|display': 0.5,
112
+ 'static|subtle': 0.5, 'subtle|dynamic': 0.5,
113
+ 'round|mixed': 0.6, 'square|mixed': 0.6,
114
+ };
115
+ const key = [a, b].sort().join('|');
116
+ return pairs[key] ?? 0;
117
+ }
118
+
119
+ function scoreFor(fp, typeui) {
120
+ let total = 0;
121
+ const reasons = [];
122
+ for (const axis of AXES) {
123
+ const fpv = fp[axis];
124
+ const tuv = typeui[axis];
125
+ if (!fpv || !tuv) continue;
126
+ const s = matchScore(fpv, tuv);
127
+ total += s * WEIGHTS[axis];
128
+ if (s >= 0.5) reasons.push(`${axis}:${tuv}`);
129
+ }
130
+ return { fit: Math.round(total * 100) / 100, reasons };
131
+ }
132
+
133
+ function rank(fp, topN = 3) {
134
+ const scored = Object.entries(TYPEUI).map(([name, tu]) => ({
135
+ name: `typeui-${name}`,
136
+ ...scoreFor(fp, tu),
137
+ applies_tokens: tu.tokens,
138
+ summary: tu.summary,
139
+ }));
140
+ scored.sort((a, b) => b.fit - a.fit);
141
+ return scored.slice(0, topN);
142
+ }
143
+
144
+ // Derive fingerprint from sd-audit findings.json + design-intelligence.json.
145
+ function fingerprintFromAudit(auditDir) {
146
+ const fp = {};
147
+ const diPath = path.join(auditDir, 'design-intelligence.json');
148
+ const fPath = path.join(auditDir, 'findings.json');
149
+ if (!fs.existsSync(diPath)) {
150
+ throw new Error(`design-intelligence.json not found in ${auditDir}`);
151
+ }
152
+ const di = JSON.parse(fs.readFileSync(diPath, 'utf8'));
153
+ const findings = fs.existsSync(fPath) ? JSON.parse(fs.readFileSync(fPath, 'utf8')) : [];
154
+
155
+ const scores = di.per_page?.[0]?.categories || di.categories || {};
156
+ const s = (k) => scores[k]?.score ?? scores[k] ?? 5;
157
+
158
+ // Density: C1 density + C11 information scent
159
+ const densityScore = (s('C1') + s('C11')) / 2;
160
+ fp.density = densityScore >= 7 ? 'high' : densityScore >= 4 ? 'medium' : 'low';
161
+
162
+ // Contrast: C6 contrast (inverse — low craft = low contrast detected)
163
+ fp.contrast = s('C6') >= 7 ? 'high' : s('C6') >= 4 ? 'medium' : 'low';
164
+
165
+ // Geometry: C14 border-radius variance (heuristic)
166
+ const radiusVar = di.summary?.radius_variance ?? 0;
167
+ fp.geometry = radiusVar > 8 ? 'mixed' : radiusVar > 2 ? 'round' : 'square';
168
+
169
+ // Color: C5 palette saturation
170
+ const satScore = di.summary?.color_saturation ?? 0.5;
171
+ fp.color = satScore > 0.7 ? 'bold' : satScore > 0.4 ? 'vibrant' : 'muted';
172
+
173
+ // Typography: C7 type scale variance
174
+ const typeVariance = di.summary?.type_variance ?? 0.5;
175
+ fp.typography = typeVariance > 0.7 ? 'display' : typeVariance > 0.4 ? 'expressive' : 'neutral';
176
+
177
+ // Motion: C13 motion/transitions
178
+ fp.motion = s('C13') >= 7 ? 'dynamic' : s('C13') >= 4 ? 'subtle' : 'static';
179
+
180
+ // Audience: inferred from findings categories — best-effort
181
+ const mobileCount = findings.filter((f) => f.id?.startsWith('M')).length;
182
+ const dataCount = findings.filter((f) => /table|dense|data/i.test(f.quote || '')).length;
183
+ fp.audience = dataCount > mobileCount ? 'enterprise' : 'consumer';
184
+
185
+ return fp;
186
+ }
187
+
188
+ function listSkills() {
189
+ return Object.entries(TYPEUI).map(([name, tu]) => ({
190
+ name: `typeui-${name}`,
191
+ axes: AXES.reduce((o, a) => ({ ...o, [a]: tu[a] }), {}),
192
+ summary: tu.summary,
193
+ }));
194
+ }
195
+
196
+ // CLI
197
+ const args = process.argv.slice(2);
198
+ if (args.includes('--list')) {
199
+ console.log(JSON.stringify(listSkills(), null, 2));
200
+ process.exit(0);
201
+ }
202
+
203
+ let fp;
204
+ let topN = 3;
205
+ const topIdx = args.indexOf('--top');
206
+ if (topIdx >= 0) topN = parseInt(args[topIdx + 1], 10) || 3;
207
+
208
+ const fromAuditIdx = args.indexOf('--from-audit');
209
+ if (fromAuditIdx >= 0) {
210
+ const dir = args[fromAuditIdx + 1];
211
+ if (!dir) {
212
+ console.error('--from-audit requires a directory path');
213
+ process.exit(2);
214
+ }
215
+ fp = fingerprintFromAudit(dir);
216
+ } else if (args[0] && fs.existsSync(args[0])) {
217
+ fp = JSON.parse(fs.readFileSync(args[0], 'utf8'));
218
+ } else {
219
+ console.error('Usage: score-typeui.mjs <fingerprint.json> | --from-audit <dir> | --list');
220
+ process.exit(2);
221
+ }
222
+
223
+ const ranked = rank(fp, topN);
224
+ console.log(JSON.stringify({ fingerprint: fp, ranked }, null, 2));
@@ -1,33 +1,64 @@
1
1
  #!/usr/bin/env bash
2
- # Usage: validate-state.sh [<state_path>]
2
+ # Usage: validate-state.sh [<app_path_or_state_path>]
3
3
  #
4
4
  # Validates the super-design audit state file. On schema/parse errors,
5
5
  # moves the broken file aside (artifact §3 "Graceful corruption handling"
6
6
  # line 74) and emits a JSON verdict. Also enforces schema_version major
7
7
  # compatibility (artifact §12 line 934).
8
+ #
9
+ # Monorepo support (artifact §11 line 902): the positional arg is the
10
+ # app root (e.g. `apps/web`); state is looked up at
11
+ # `<app_path>/docs/super-design/.audit-state.json`. For single-app
12
+ # repos pass "." or omit (default behavior preserved). Back-compat: if
13
+ # the arg ends in `.audit-state.json` it is used verbatim.
14
+ #
15
+ # Validation strategy:
16
+ # 1. If `ajv` is on PATH, validate against audit-state.schema.json
17
+ # (draft-07 canonical schema, task #18).
18
+ # 2. Otherwise fall back to the inline jq shape check that existed
19
+ # pre-schema. Same corrupt-rename behavior in both paths.
8
20
  set -euo pipefail
9
- STATE="${1:-docs/super-design/.audit-state.json}"
21
+ ARG="${1:-.}"
22
+ case "$ARG" in
23
+ *.audit-state.json) STATE="$ARG" ;;
24
+ .|"") STATE="docs/super-design/.audit-state.json" ;;
25
+ *) STATE="${ARG%/}/docs/super-design/.audit-state.json" ;;
26
+ esac
27
+ SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
28
+ SCHEMA="$SKILL_DIR/audit-state.schema.json"
10
29
 
11
30
  # Current schema major is either read from a sibling .schema-version file
12
31
  # (so the number can be bumped without editing shell) or falls back to 1.
13
- SCHEMA_VERSION_FILE="$(dirname "$0")/../.schema-version"
14
- if [[ -f "$SCHEMA_VERSION_FILE" ]]; then
32
+ SCHEMA_VERSION_FILE="$SKILL_DIR/.schema-version"
33
+ if [ -f "$SCHEMA_VERSION_FILE" ]; then
15
34
  CURRENT_SCHEMA_MAJOR="$(cut -d. -f1 <"$SCHEMA_VERSION_FILE" | tr -d '[:space:]')"
16
35
  else
17
36
  CURRENT_SCHEMA_MAJOR=1
18
37
  fi
19
38
 
20
- if [[ ! -f "$STATE" ]]; then echo '{"status":"missing"}'; exit 2; fi
39
+ if [ ! -f "$STATE" ]; then echo '{"status":"missing"}'; exit 2; fi
21
40
 
22
41
  # Parse + shape check. On failure, rename so the user can inspect and we
23
42
  # fall through to first-audit (SKILL.md Step 1 treats "corrupt" that way).
24
- if ! jq -e '
25
- (.schema_version | type == "string") and
26
- (.last_audit_at | fromdateiso8601 | . > 0) and
27
- (.git_sha_at_audit | test("^[0-9a-f]{7,64}$")) and
28
- (.skill_version | type == "string") and
29
- (.tools | type == "object")
30
- ' "$STATE" >/dev/null 2>&1; then
43
+ schema_ok=1
44
+ if command -v ajv >/dev/null 2>&1 && [ -f "$SCHEMA" ]; then
45
+ if ! ajv validate -s "$SCHEMA" -d "$STATE" --errors=text >/dev/null 2>&1; then
46
+ schema_ok=0
47
+ fi
48
+ else
49
+ # Fallback: inline jq shape check (pre-schema behavior).
50
+ if ! jq -e '
51
+ (.schema_version | type == "string") and
52
+ (.last_audit_at | fromdateiso8601 | . > 0) and
53
+ (.git_sha_at_audit | test("^[0-9a-f]{7,64}$")) and
54
+ (.skill_version | type == "string") and
55
+ (.tools | type == "object")
56
+ ' "$STATE" >/dev/null 2>&1; then
57
+ schema_ok=0
58
+ fi
59
+ fi
60
+
61
+ if [ "$schema_ok" -eq 0 ]; then
31
62
  mv "$STATE" "$STATE.corrupt-$(date +%s)" 2>/dev/null || true
32
63
  echo '{"status":"corrupt"}'; exit 2
33
64
  fi
@@ -36,12 +67,12 @@ fi
36
67
  # incompatible-older skill, force a full re-audit rather than silently
37
68
  # trusting the shape.
38
69
  STATE_MAJOR="$(jq -r '.schema_version' "$STATE" | cut -d. -f1)"
39
- if [[ -z "$STATE_MAJOR" || "$STATE_MAJOR" != "$CURRENT_SCHEMA_MAJOR" ]]; then
70
+ if [ -z "$STATE_MAJOR" ] || [ "$STATE_MAJOR" != "$CURRENT_SCHEMA_MAJOR" ]; then
40
71
  echo "{\"status\":\"schema-incompatible\",\"action\":\"force-full\",\"state_major\":\"${STATE_MAJOR:-unknown}\",\"current_major\":\"${CURRENT_SCHEMA_MAJOR}\"}"
41
72
  exit 1
42
73
  fi
43
74
 
44
75
  AGE_DAYS=$(( ( $(date -u +%s) - $(jq -r '.last_audit_at | fromdateiso8601' "$STATE") ) / 86400 ))
45
- if (( AGE_DAYS > 180 )); then echo "{\"status\":\"stale-force-full\",\"age_days\":$AGE_DAYS}"; exit 1
46
- elif (( AGE_DAYS > 90 )); then echo "{\"status\":\"stale-refresh-research\",\"age_days\":$AGE_DAYS}"; exit 1
76
+ if [ "$AGE_DAYS" -gt 180 ]; then echo "{\"status\":\"stale-force-full\",\"age_days\":$AGE_DAYS}"; exit 1
77
+ elif [ "$AGE_DAYS" -gt 90 ]; then echo "{\"status\":\"stale-refresh-research\",\"age_days\":$AGE_DAYS}"; exit 1
47
78
  else echo "{\"status\":\"fresh\",\"age_days\":$AGE_DAYS}"; exit 0; fi
@@ -1,19 +1,72 @@
1
1
  #!/usr/bin/env bash
2
+ # Usage: verify-audit.sh [--strict] <session_dir>
3
+ #
4
+ # Verifies the artifacts produced by an sd-audit session:
5
+ # 1. .audit-state.json exists and passes validate-state.sh
6
+ # (so the schema task #18 schema is actually enforced end-to-end).
7
+ # 2. findings.json exists and parses as a JSON array.
8
+ # 3. Every finding has a SHOT (screenshot_path) + SNAP (snapshot_path)
9
+ # that resolves to a non-empty file on disk.
10
+ # 4. Every finding's snapshot_quote appears verbatim in its snapshot.
11
+ #
12
+ # Exit codes:
13
+ # 0 OK (no errors, no warnings — or warnings present without --strict)
14
+ # 1 non-fatal warning in --strict mode, or verification failure
15
+ # 2 missing prerequisites (session dir / findings.json)
2
16
  set -euo pipefail
3
- SESSION_DIR="${1:?usage: verify-audit.sh <session_dir>}"
17
+
18
+ STRICT=0
19
+ if [ "${1:-}" = "--strict" ]; then STRICT=1; shift; fi
20
+
21
+ SESSION_DIR="${1:?usage: verify-audit.sh [--strict] <session_dir>}"
4
22
  FINDINGS="$SESSION_DIR/findings.json"
5
- if [[ ! -f "$FINDINGS" ]]; then echo "FATAL: no findings.json at $FINDINGS" >&2; exit 2; fi
23
+ SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
24
+ VALIDATE="$SKILL_DIR/scripts/validate-state.sh"
25
+ STATE="${AUDIT_STATE:-docs/super-design/.audit-state.json}"
26
+
27
+ WARNINGS=0
28
+ warn() { echo "WARN: $*" >&2; WARNINGS=$((WARNINGS + 1)); }
6
29
 
7
- jq -r '.[] | [.id, .screenshot_path, .snapshot_path] | @tsv' "$FINDINGS" | while IFS=$'\t' read -r id shot snap; do
8
- if [[ ! -s "$shot" ]]; then echo "FAIL $id: missing/empty screenshot $shot" >&2; exit 1; fi
9
- if [[ ! -s "$snap" ]]; then echo "FAIL $id: missing/empty snapshot $snap" >&2; exit 1; fi
30
+ # 1. State file schema check non-fatal (state may live outside session
31
+ # dir, or may legitimately be absent on first run). In --strict mode
32
+ # any anomaly counts as a warning.
33
+ if [ ! -f "$STATE" ]; then
34
+ warn "no audit state at $STATE (first audit? expected on CI)"
35
+ else
36
+ if ! bash "$VALIDATE" "$STATE" >/dev/null 2>&1; then
37
+ warn "validate-state.sh rejected $STATE (schema drift or stale)"
38
+ fi
39
+ fi
40
+
41
+ # 2. findings.json must exist and parse.
42
+ if [ ! -f "$FINDINGS" ]; then
43
+ echo "FATAL: no findings.json at $FINDINGS" >&2; exit 2
44
+ fi
45
+ if ! jq -e 'type == "array"' "$FINDINGS" >/dev/null 2>&1; then
46
+ echo "FATAL: $FINDINGS is not a JSON array" >&2; exit 1
47
+ fi
48
+
49
+ # 3. Every referenced screenshot/snapshot resolves to a non-empty file.
50
+ jq -r '.[] | [.id, .screenshot_path, .snapshot_path] | @tsv' "$FINDINGS" | \
51
+ while IFS=$'\t' read -r id shot snap; do
52
+ if [ ! -s "$shot" ]; then echo "FAIL $id: missing/empty screenshot $shot" >&2; exit 1; fi
53
+ if [ ! -s "$snap" ]; then echo "FAIL $id: missing/empty snapshot $snap" >&2; exit 1; fi
10
54
  done
11
55
 
56
+ # 4. snapshot_quote must appear verbatim in the snapshot.
12
57
  jq -c '.[]' "$FINDINGS" | while read -r f; do
13
58
  id=$(echo "$f" | jq -r .id)
14
- q=$(echo "$f" | jq -r .snapshot_quote)
15
- s=$(echo "$f" | jq -r .snapshot_path)
16
- if ! grep -qF "$q" "$s"; then echo "FAIL $id: quote not found verbatim in $s" >&2; exit 1; fi
59
+ q=$(echo "$f" | jq -r .snapshot_quote)
60
+ s=$(echo "$f" | jq -r .snapshot_path)
61
+ if ! grep -qF "$q" "$s"; then
62
+ echo "FAIL $id: quote not found verbatim in $s" >&2; exit 1
63
+ fi
17
64
  done
18
65
 
19
- echo "OK: $(jq 'length' "$FINDINGS") findings verified"
66
+ COUNT=$(jq 'length' "$FINDINGS")
67
+ if [ "$STRICT" -eq 1 ] && [ "$WARNINGS" -gt 0 ]; then
68
+ echo "STRICT: $COUNT findings verified, $WARNINGS warning(s)" >&2
69
+ exit 1
70
+ fi
71
+
72
+ echo "OK: $COUNT findings verified${WARNINGS:+ ($WARNINGS warning(s))}"
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: visual-regression.sh [--update-baselines] [<state_file>]
3
+ #
4
+ # Expected `.audit-state.json` `visual_regression` block (artifact §16
5
+ # lines 1367-1384). Kept inline here so the script self-documents the
6
+ # schema shape when audit-state.schema.json is not yet present.
7
+ #
8
+ # "visual_regression": {
9
+ # "enabled": true,
10
+ # "engine": "pixelmatch", // pixelmatch | odiff | sha256-fallback
11
+ # "threshold": 0.1, // per-pixel YIQ (pixelmatch/odiff)
12
+ # "max_diff_pixel_ratio": 0.01, // 1% pixels allowed
13
+ # "antialiasing": true,
14
+ # "viewports": [
15
+ # { "label": "mobile_375", "width": 375, "height": 667 },
16
+ # { "label": "tablet_768", "width": 768, "height": 1024 },
17
+ # { "label": "desktop_1280","width": 1280, "height": 800 }
18
+ # ],
19
+ # "mask_selectors": [
20
+ # "[data-timestamp]", ".relative-time",
21
+ # "[data-react-hydration]", "video", "canvas"
22
+ # ],
23
+ # "baseline_dir": ".super-design/baselines",
24
+ # "current_dir": "docs/super-design/.cache/hashes/screenshots",
25
+ # "diff_dir": "docs/super-design/.cache/hashes/diffs"
26
+ # }
27
+ #
28
+ # Runner behaviour:
29
+ # - Reads the block from .audit-state.json (default path overridable via
30
+ # positional arg).
31
+ # - For every (page, viewport) it finds a current screenshot for, locates
32
+ # the matching baseline PNG, runs the configured diff engine, emits
33
+ # {page, viewport, diff_ratio, threshold, pass, diff_image_path} into
34
+ # <diff_dir>/results.json.
35
+ # - Engine fallback chain: pixelmatch (npx) → odiff (npx) → sha256
36
+ # equality (logs warning on stderr).
37
+ # - --update-baselines replaces every baseline with the current capture
38
+ # and exits without diffing.
39
+ #
40
+ # POSIX sh + node; runs under Windows git-bash.
41
+ set -euo pipefail
42
+
43
+ UPDATE_BASELINES=0
44
+ STATE="docs/super-design/.audit-state.json"
45
+ while (( $# )); do
46
+ case "$1" in
47
+ --update-baselines) UPDATE_BASELINES=1 ;;
48
+ -h|--help)
49
+ grep '^#' "$0" | sed 's/^# \{0,1\}//'
50
+ exit 0
51
+ ;;
52
+ *) STATE="$1" ;;
53
+ esac
54
+ shift
55
+ done
56
+
57
+ if [[ ! -f "$STATE" ]]; then
58
+ echo '{"status":"missing-state","hint":"run audit first"}' >&2
59
+ exit 2
60
+ fi
61
+
62
+ # Defaults match artifact §16. jq pulls the override from state, if any.
63
+ CFG="$(jq -c '.visual_regression // {}' "$STATE")"
64
+ ENABLED="$(jq -r '.enabled // false' <<<"$CFG")"
65
+ if [[ "$ENABLED" != "true" && "$UPDATE_BASELINES" -ne 1 ]]; then
66
+ echo '{"status":"disabled","hint":"set visual_regression.enabled=true"}'
67
+ exit 0
68
+ fi
69
+
70
+ BASELINE_DIR="$(jq -r '.baseline_dir // ".super-design/baselines"' <<<"$CFG")"
71
+ CURRENT_DIR="$(jq -r '.current_dir // "docs/super-design/.cache/hashes/screenshots"' <<<"$CFG")"
72
+ DIFF_DIR="$(jq -r '.diff_dir // "docs/super-design/.cache/hashes/diffs"' <<<"$CFG")"
73
+ ENGINE="$(jq -r '.engine // "pixelmatch"' <<<"$CFG")"
74
+ THRESHOLD="$(jq -r '.threshold // 0.1' <<<"$CFG")"
75
+ MAX_RATIO="$(jq -r '.max_diff_pixel_ratio // 0.01' <<<"$CFG")"
76
+ ANTIALIAS="$(jq -r '.antialiasing // true' <<<"$CFG")"
77
+
78
+ mkdir -p "$BASELINE_DIR" "$DIFF_DIR"
79
+
80
+ # Enumerate current screenshots the hash-pages.sh pass wrote under
81
+ # <current_dir>/<url-encoded-url>/<viewport>.png.
82
+ if [[ ! -d "$CURRENT_DIR" ]]; then
83
+ echo "{\"status\":\"no-screenshots\",\"hint\":\"run hash-pages.sh first\",\"looked_in\":\"$CURRENT_DIR\"}" >&2
84
+ exit 2
85
+ fi
86
+
87
+ # --update-baselines: mirror current -> baseline and exit.
88
+ if [[ "$UPDATE_BASELINES" -eq 1 ]]; then
89
+ copied=0
90
+ while IFS= read -r -d '' png; do
91
+ rel="${png#$CURRENT_DIR/}"
92
+ dst="$BASELINE_DIR/$rel"
93
+ mkdir -p "$(dirname "$dst")"
94
+ cp -f "$png" "$dst"
95
+ copied=$((copied + 1))
96
+ done < <(find "$CURRENT_DIR" -type f -name '*.png' -print0)
97
+ echo "{\"status\":\"baselines-updated\",\"count\":$copied,\"baseline_dir\":\"$BASELINE_DIR\"}"
98
+ exit 0
99
+ fi
100
+
101
+ # Resolve engine availability up-front so we warn once rather than per page.
102
+ resolve_engine() {
103
+ local want="$1"
104
+ case "$want" in
105
+ pixelmatch)
106
+ if command -v npx >/dev/null 2>&1; then
107
+ if npx --yes pixelmatch --help >/dev/null 2>&1; then echo pixelmatch; return; fi
108
+ fi
109
+ ;;
110
+ odiff)
111
+ if command -v npx >/dev/null 2>&1; then
112
+ if npx --yes odiff-bin --help >/dev/null 2>&1; then echo odiff; return; fi
113
+ fi
114
+ ;;
115
+ esac
116
+ # Chain: pixelmatch > odiff > sha256.
117
+ if command -v npx >/dev/null 2>&1; then
118
+ if npx --yes pixelmatch --help >/dev/null 2>&1; then echo pixelmatch; return; fi
119
+ if npx --yes odiff-bin --help >/dev/null 2>&1; then echo odiff; return; fi
120
+ fi
121
+ echo sha256-fallback
122
+ }
123
+
124
+ RESOLVED_ENGINE="$(resolve_engine "$ENGINE")"
125
+ if [[ "$RESOLVED_ENGINE" == "sha256-fallback" ]]; then
126
+ echo "warning: pixelmatch/odiff unavailable; falling back to sha256 equality (binary pass/fail, diff_ratio will be 0 or 1)" >&2
127
+ fi
128
+
129
+ # Per-comparison worker. Runs in a node one-shot because pixelmatch /
130
+ # odiff are both invoked there; sha256 fallback is pure node too.
131
+ compare_pair() {
132
+ local baseline="$1"
133
+ local current="$2"
134
+ local diff_out="$3"
135
+
136
+ BASE="$baseline" CURR="$current" DIFF="$diff_out" \
137
+ ENGINE="$RESOLVED_ENGINE" THRESHOLD="$THRESHOLD" \
138
+ ANTIALIAS="$ANTIALIAS" \
139
+ node --experimental-vm-modules <<'JS'
140
+ import { createHash } from "node:crypto";
141
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
142
+ import { dirname } from "node:path";
143
+ import { spawnSync } from "node:child_process";
144
+
145
+ const base = process.env.BASE;
146
+ const curr = process.env.CURR;
147
+ const diffPath = process.env.DIFF;
148
+ const engine = process.env.ENGINE;
149
+ const threshold = Number(process.env.THRESHOLD);
150
+ const antialias = process.env.ANTIALIAS === "true";
151
+
152
+ mkdirSync(dirname(diffPath), { recursive: true });
153
+
154
+ function sha(buf) { return createHash("sha256").update(buf).digest("hex"); }
155
+
156
+ if (!existsSync(base)) {
157
+ console.log(JSON.stringify({
158
+ pass: false, diff_ratio: 1, reason: "baseline-missing",
159
+ engine, diff_image_path: null,
160
+ }));
161
+ process.exit(0);
162
+ }
163
+
164
+ if (engine === "sha256-fallback") {
165
+ const bHash = sha(readFileSync(base));
166
+ const cHash = sha(readFileSync(curr));
167
+ const ratio = bHash === cHash ? 0 : 1;
168
+ console.log(JSON.stringify({
169
+ pass: ratio === 0, diff_ratio: ratio, engine,
170
+ diff_image_path: null,
171
+ }));
172
+ process.exit(0);
173
+ }
174
+
175
+ if (engine === "pixelmatch") {
176
+ // npx pixelmatch <baseline> <current> <diff> [threshold]
177
+ const r = spawnSync("npx", [
178
+ "--yes", "pixelmatch", base, curr, diffPath, String(threshold),
179
+ ], { encoding: "utf8", shell: process.platform === "win32" });
180
+ // pixelmatch prints e.g. "error: 123 different pixels\n" to stdout.
181
+ // total-pixels isn't emitted, so we read PNG dimensions from the diff
182
+ // header (the diff PNG is always produced even on zero-diff).
183
+ const diffPixels = Number((r.stdout.match(/(\d+)\s+different/) || [0, 0])[1]);
184
+ let total = 1;
185
+ if (existsSync(diffPath)) {
186
+ const buf = readFileSync(diffPath);
187
+ // PNG width/height live at bytes 16..23 big-endian.
188
+ if (buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50) {
189
+ const w = buf.readUInt32BE(16);
190
+ const h = buf.readUInt32BE(20);
191
+ total = Math.max(1, w * h);
192
+ }
193
+ }
194
+ const ratio = diffPixels / total;
195
+ console.log(JSON.stringify({
196
+ pass: null, // decided by caller against max_diff_pixel_ratio
197
+ diff_ratio: ratio, diff_pixels: diffPixels, total_pixels: total,
198
+ engine, diff_image_path: diffPath,
199
+ exit: r.status, stderr: (r.stderr || "").trim().slice(0, 500),
200
+ }));
201
+ process.exit(0);
202
+ }
203
+
204
+ if (engine === "odiff") {
205
+ // odiff-bin <baseline> <current> <diff> --threshold=<t>
206
+ const args = [
207
+ "--yes", "odiff-bin", base, curr, diffPath,
208
+ `--threshold=${threshold}`,
209
+ ];
210
+ if (!antialias) args.push("--antialiasing");
211
+ const r = spawnSync("npx", args, {
212
+ encoding: "utf8", shell: process.platform === "win32",
213
+ });
214
+ const matchRatio = (r.stdout + r.stderr).match(/(\d+(?:\.\d+)?)\s*%/);
215
+ const pctDiff = matchRatio ? Number(matchRatio[1]) / 100 : (r.status === 0 ? 0 : 1);
216
+ console.log(JSON.stringify({
217
+ pass: null,
218
+ diff_ratio: pctDiff,
219
+ engine, diff_image_path: diffPath,
220
+ exit: r.status, stderr: (r.stderr || "").trim().slice(0, 500),
221
+ }));
222
+ process.exit(0);
223
+ }
224
+
225
+ // Unknown engine — surface as a hard failure rather than silent pass.
226
+ console.log(JSON.stringify({
227
+ pass: false, diff_ratio: 1,
228
+ reason: `unknown-engine:${engine}`, engine, diff_image_path: null,
229
+ }));
230
+ JS
231
+ }
232
+
233
+ results="[]"
234
+ any_fail=0
235
+ while IFS= read -r -d '' curr_png; do
236
+ rel="${curr_png#$CURRENT_DIR/}" # e.g. https%3A%2F%2F.../mobile_375.png
237
+ page_dir="$(dirname "$rel")"
238
+ viewport="$(basename "$rel" .png)"
239
+ page_url="$(printf '%s' "$page_dir" | node -e 'process.stdin.on("data",d=>process.stdout.write(decodeURIComponent(d.toString())))')"
240
+ base_png="$BASELINE_DIR/$rel"
241
+ diff_png="$DIFF_DIR/$rel"
242
+
243
+ raw="$(compare_pair "$base_png" "$curr_png" "$diff_png" || echo '{"pass":false,"diff_ratio":1,"reason":"compare-error"}')"
244
+
245
+ # Decide pass/fail against max_diff_pixel_ratio when engine left it null.
246
+ merged="$(jq -c \
247
+ --arg page "$page_url" \
248
+ --arg viewport "$viewport" \
249
+ --argjson threshold "$THRESHOLD" \
250
+ --argjson max_ratio "$MAX_RATIO" \
251
+ '. as $r
252
+ | .pass = (if .pass == null then (.diff_ratio <= $max_ratio) else .pass end)
253
+ | .page = $page
254
+ | .viewport = $viewport
255
+ | .threshold = $threshold
256
+ | .max_diff_pixel_ratio = $max_ratio' <<<"$raw")"
257
+
258
+ if [[ "$(jq -r '.pass' <<<"$merged")" != "true" ]]; then any_fail=1; fi
259
+ results="$(jq -c --argjson m "$merged" '. + [$m]' <<<"$results")"
260
+ done < <(find "$CURRENT_DIR" -type f -name '*.png' -print0)
261
+
262
+ out_file="$DIFF_DIR/results.json"
263
+ mkdir -p "$DIFF_DIR"
264
+ jq -n --argjson r "$results" \
265
+ --arg engine "$RESOLVED_ENGINE" \
266
+ --argjson threshold "$THRESHOLD" \
267
+ --argjson max_ratio "$MAX_RATIO" \
268
+ '{engine:$engine, threshold:$threshold, max_diff_pixel_ratio:$max_ratio,
269
+ results:$r, count: ($r|length),
270
+ passed: ([$r[] | select(.pass==true)] | length),
271
+ failed: ([$r[] | select(.pass!=true)] | length)}' \
272
+ >"$out_file"
273
+
274
+ cat "$out_file"
275
+ exit $any_fail