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.
- package/package.json +2 -2
- package/template/.claude/skills/super-design/SKILL.md +108 -5
- package/template/.claude/skills/super-design/audit-state.schema.json +226 -0
- package/template/.claude/skills/super-design/scripts/build-import-graph.sh +208 -0
- package/template/.claude/skills/super-design/scripts/detect-apps.sh +180 -0
- package/template/.claude/skills/super-design/scripts/detect-changes.sh +73 -12
- package/template/.claude/skills/super-design/scripts/discover-routes.sh +120 -13
- package/template/.claude/skills/super-design/scripts/extract-tokens.mjs +153 -9
- package/template/.claude/skills/super-design/scripts/harvest-typeui.sh +131 -0
- package/template/.claude/skills/super-design/scripts/hash-pages.sh +208 -28
- package/template/.claude/skills/super-design/scripts/score-typeui.mjs +224 -0
- package/template/.claude/skills/super-design/scripts/validate-state.sh +46 -15
- package/template/.claude/skills/super-design/scripts/verify-audit.sh +62 -9
- package/template/.claude/skills/super-design/scripts/visual-regression.sh +275 -0
- package/template/.claude/skills/super-design/scripts/write-state.sh +29 -2
- package/template/.claude/skills/super-design/templates/audit-state.schema.json +0 -57
|
@@ -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 [<
|
|
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
|
-
|
|
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="$
|
|
14
|
-
if [
|
|
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 [
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 [
|
|
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
|
|
46
|
-
elif
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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"
|
|
15
|
-
s=$(echo "$f"
|
|
16
|
-
if ! grep -qF "$q" "$s"; then
|
|
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
|
-
|
|
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
|