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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Extract + canonicalize + hash design tokens from Tailwind configs
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Extract + canonicalize + hash design tokens from Tailwind configs, CSS custom
|
|
3
|
+
// properties, and DTCG / Tokens Studio JSON (artifact §9.1 lines 707-709, §9.2
|
|
4
|
+
// lines 720-761). Output is deterministic regardless of insertion order
|
|
5
|
+
// (artifact §9.2 line 722: "canonical = JSON.stringify(theme,
|
|
5
6
|
// Object.keys(theme).sort())").
|
|
6
7
|
import fs from "node:fs";
|
|
7
8
|
import path from "node:path";
|
|
@@ -9,7 +10,9 @@ import { createHash } from "node:crypto";
|
|
|
9
10
|
import { pathToFileURL } from "node:url";
|
|
10
11
|
|
|
11
12
|
const out = {};
|
|
13
|
+
const sources = [];
|
|
12
14
|
const tailwindConfigs = ["tailwind.config.ts", "tailwind.config.js", "tailwind.config.mjs", "tailwind.config.cjs"];
|
|
15
|
+
let tailwindFound = false;
|
|
13
16
|
for (const candidate of tailwindConfigs) {
|
|
14
17
|
if (fs.existsSync(candidate)) {
|
|
15
18
|
try {
|
|
@@ -17,10 +20,14 @@ for (const candidate of tailwindConfigs) {
|
|
|
17
20
|
const cfg = (await import(pathToFileURL(path.resolve(candidate)).href)).default;
|
|
18
21
|
const resolved = resolveConfig(cfg);
|
|
19
22
|
flatten(resolved.theme, "tw", out);
|
|
23
|
+
tailwindFound = true;
|
|
20
24
|
} catch (e) { out[`_error_${candidate}`] = String(e.message || e); }
|
|
21
25
|
break;
|
|
22
26
|
}
|
|
23
27
|
}
|
|
28
|
+
if (tailwindFound) sources.push("tailwind");
|
|
29
|
+
|
|
30
|
+
let cssFound = false;
|
|
24
31
|
try {
|
|
25
32
|
const postcss = (await import("postcss")).default;
|
|
26
33
|
const cssCandidates = ["styles/globals.css","src/styles/globals.css","app/globals.css","styles/theme.css","src/app/globals.css"];
|
|
@@ -28,21 +35,37 @@ try {
|
|
|
28
35
|
if (!fs.existsSync(css)) continue;
|
|
29
36
|
const source = fs.readFileSync(css, "utf8");
|
|
30
37
|
const root = postcss.parse(source);
|
|
31
|
-
root.walkAtRules("theme", at => { at.walkDecls(/^--/, d => { out[`css:${d.prop}`] = d.value.trim(); }); });
|
|
32
|
-
root.walkRules(":root", rule => { rule.walkDecls(/^--/, d => { out[`css:${d.prop}`] = d.value.trim(); }); });
|
|
38
|
+
root.walkAtRules("theme", at => { at.walkDecls(/^--/, d => { out[`css:${d.prop}`] = d.value.trim(); cssFound = true; }); });
|
|
39
|
+
root.walkRules(":root", rule => { rule.walkDecls(/^--/, d => { out[`css:${d.prop}`] = d.value.trim(); cssFound = true; }); });
|
|
33
40
|
}
|
|
34
41
|
} catch (e) { out._postcss_error = String(e.message || e); }
|
|
42
|
+
if (cssFound) sources.push("css-vars");
|
|
35
43
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
// DTCG + Tokens Studio support (artifact §9.1 line 707-709, §9.2 line 747).
|
|
45
|
+
const dtcgFiles = discoverDtcgFiles(".");
|
|
46
|
+
let dtcgFound = false;
|
|
47
|
+
for (const file of dtcgFiles) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
// Tokens Studio $metadata/$themes are not token trees — skip.
|
|
52
|
+
if (path.basename(file) === "$metadata.json" || path.basename(file) === "$themes.json") continue;
|
|
53
|
+
const resolved = resolveDtcgAliases(parsed, file);
|
|
54
|
+
const relFile = path.relative(".", file).replaceAll("\\", "/");
|
|
55
|
+
flattenDtcg(resolved, `dtcg:${relFile}`, out);
|
|
56
|
+
dtcgFound = true;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
out[`_dtcg_error_${file}`] = String(e.message || e);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (dtcgFound) sources.push("dtcg");
|
|
39
62
|
|
|
40
63
|
// Deterministic canonical form: keys sorted top-to-bottom.
|
|
41
64
|
const sorted = Object.keys(out).sort().reduce((acc, k) => { acc[k] = out[k]; return acc; }, {});
|
|
42
65
|
const canonical = JSON.stringify(sorted);
|
|
43
66
|
const tokens_hash = "sha256:" + createHash("sha256").update(canonical).digest("hex");
|
|
44
67
|
|
|
45
|
-
console.log(JSON.stringify({ tokens: sorted, tokens_hash }, null, 2));
|
|
68
|
+
console.log(JSON.stringify({ tokens: sorted, tokens_hash, sources: sources.sort() }, null, 2));
|
|
46
69
|
|
|
47
70
|
function flatten(obj, prefix, acc) {
|
|
48
71
|
if (obj == null) return;
|
|
@@ -56,3 +79,124 @@ function flatten(obj, prefix, acc) {
|
|
|
56
79
|
else acc[key] = Array.isArray(v) ? v.join(",") : String(v);
|
|
57
80
|
}
|
|
58
81
|
}
|
|
82
|
+
|
|
83
|
+
// Walk cwd, returning **/*.tokens.json (excluding build/vendor dirs) plus the
|
|
84
|
+
// two conventional Tokens Studio single-file exports.
|
|
85
|
+
function discoverDtcgFiles(rootDir) {
|
|
86
|
+
const skipDirs = new Set(["node_modules", "dist", ".next", "build", ".git", "coverage", ".turbo", ".cache"]);
|
|
87
|
+
const found = [];
|
|
88
|
+
const walk = (dir) => {
|
|
89
|
+
let entries;
|
|
90
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
if (skipDirs.has(entry.name) || entry.name.startsWith(".")) {
|
|
94
|
+
// Allow explicit `.tokens` dir used by some Tokens Studio setups.
|
|
95
|
+
if (entry.name !== ".tokens") continue;
|
|
96
|
+
}
|
|
97
|
+
walk(path.join(dir, entry.name));
|
|
98
|
+
} else if (entry.isFile()) {
|
|
99
|
+
const full = path.join(dir, entry.name);
|
|
100
|
+
if (entry.name.endsWith(".tokens.json")) found.push(full);
|
|
101
|
+
else if (entry.name === "tokens.json" && (dir === rootDir || path.basename(dir) === ".tokens")) found.push(full);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
walk(rootDir);
|
|
106
|
+
return found.sort();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Resolve DTCG aliases transitively. Supports both `{color.brand.primary}` and
|
|
110
|
+
// Tokens Studio legacy `$color.brand.primary`. Cycle detection throws loudly.
|
|
111
|
+
function resolveDtcgAliases(tree, filename) {
|
|
112
|
+
const resolving = new Set();
|
|
113
|
+
const lookup = (pathStr) => {
|
|
114
|
+
const segs = pathStr.split(".");
|
|
115
|
+
let node = tree;
|
|
116
|
+
for (const s of segs) {
|
|
117
|
+
if (node == null || typeof node !== "object") return undefined;
|
|
118
|
+
node = node[s];
|
|
119
|
+
}
|
|
120
|
+
return node;
|
|
121
|
+
};
|
|
122
|
+
const resolveValue = (value, trail) => {
|
|
123
|
+
if (typeof value !== "string") return value;
|
|
124
|
+
const braceMatch = value.match(/^\{([^}]+)\}$/);
|
|
125
|
+
const dollarMatch = value.match(/^\$([A-Za-z_][A-Za-z0-9_.-]*)$/);
|
|
126
|
+
const aliasPath = braceMatch ? braceMatch[1] : dollarMatch ? dollarMatch[1] : null;
|
|
127
|
+
if (!aliasPath) return value;
|
|
128
|
+
if (trail.has(aliasPath)) {
|
|
129
|
+
throw new Error(`DTCG alias cycle in ${filename}: ${[...trail, aliasPath].join(" -> ")}`);
|
|
130
|
+
}
|
|
131
|
+
const target = lookup(aliasPath);
|
|
132
|
+
if (target == null) return value; // dangling alias: pass through raw
|
|
133
|
+
const next = typeof target === "object" && target !== null && "$value" in target ? target.$value : target;
|
|
134
|
+
return resolveValue(next, new Set([...trail, aliasPath]));
|
|
135
|
+
};
|
|
136
|
+
const walk = (node) => {
|
|
137
|
+
if (node == null || typeof node !== "object") return node;
|
|
138
|
+
if (Array.isArray(node)) return node.map(walk);
|
|
139
|
+
if ("$value" in node) {
|
|
140
|
+
const copy = { ...node };
|
|
141
|
+
try {
|
|
142
|
+
copy.$value = resolveValue(copy.$value, new Set());
|
|
143
|
+
} catch (err) {
|
|
144
|
+
// Cycles must fail loudly per requirements.
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
copy.$value = applyTokensStudioTransforms(copy.$value, node.$extensions, filename);
|
|
148
|
+
return copy;
|
|
149
|
+
}
|
|
150
|
+
const result = {};
|
|
151
|
+
for (const k of Object.keys(node)) result[k] = walk(node[k]);
|
|
152
|
+
return result;
|
|
153
|
+
};
|
|
154
|
+
return walk(tree);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Apply Tokens Studio `modify` transforms (lighten/darken/alpha). Anything
|
|
158
|
+
// unparseable emits a warning to stderr and passes through the raw $value.
|
|
159
|
+
function applyTokensStudioTransforms(value, extensions, filename) {
|
|
160
|
+
const modify = extensions?.["studio.tokens"]?.modify;
|
|
161
|
+
if (!modify || typeof value !== "string") return value;
|
|
162
|
+
const { type, amount, space } = modify;
|
|
163
|
+
const num = Number(amount);
|
|
164
|
+
try {
|
|
165
|
+
if (type === "alpha" && /^#([0-9a-f]{6})$/i.test(value) && Number.isFinite(num)) {
|
|
166
|
+
const a = Math.round(Math.max(0, Math.min(1, num)) * 255).toString(16).padStart(2, "0");
|
|
167
|
+
return value + a;
|
|
168
|
+
}
|
|
169
|
+
if ((type === "lighten" || type === "darken") && /^#([0-9a-f]{6})$/i.test(value) && Number.isFinite(num)) {
|
|
170
|
+
const hex = value.slice(1);
|
|
171
|
+
const rgb = [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16));
|
|
172
|
+
const factor = type === "lighten" ? num : -num;
|
|
173
|
+
const adjusted = rgb.map(c => Math.max(0, Math.min(255, Math.round(c + (factor * 255)))));
|
|
174
|
+
return "#" + adjusted.map(c => c.toString(16).padStart(2, "0")).join("");
|
|
175
|
+
}
|
|
176
|
+
process.stderr.write(`[extract-tokens] warn: unsupported Tokens Studio modify(${type}, space=${space}) in ${filename}; passing through raw value\n`);
|
|
177
|
+
return value;
|
|
178
|
+
} catch {
|
|
179
|
+
process.stderr.write(`[extract-tokens] warn: failed to apply Tokens Studio modify(${type}) in ${filename}; passing through raw value\n`);
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Flatten a resolved DTCG tree into `prefix.path = stringified $value` leaves.
|
|
185
|
+
function flattenDtcg(node, prefix, acc) {
|
|
186
|
+
if (node == null) return;
|
|
187
|
+
if (typeof node !== "object" || Array.isArray(node)) {
|
|
188
|
+
acc[prefix] = Array.isArray(node) ? node.join(",") : String(node);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if ("$value" in node) {
|
|
192
|
+
const v = node.$value;
|
|
193
|
+
const t = node.$type;
|
|
194
|
+
const key = t ? `${prefix}[${t}]` : prefix;
|
|
195
|
+
acc[key] = typeof v === "object" ? JSON.stringify(v, Object.keys(v).sort()) : String(v);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
for (const k of Object.keys(node).sort()) {
|
|
199
|
+
if (k.startsWith("$")) continue; // $description / $extensions / $metadata
|
|
200
|
+
flattenDtcg(node[k], `${prefix}.${k}`, acc);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# harvest-typeui.sh — discover + install typeui-* and related design skills.
|
|
3
|
+
#
|
|
4
|
+
# Sources, in order:
|
|
5
|
+
# 1. typeui.sh registry (if reachable) — fetch catalog.json, install missing
|
|
6
|
+
# 2. vercel-labs/agent-skills design skills (composition, web-design-guidelines)
|
|
7
|
+
# 3. anthropics/skills webapp-testing + mcp-builder (already covered, idempotent)
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# harvest-typeui.sh # install everything missing (user-scope ~/.claude/skills/)
|
|
11
|
+
# harvest-typeui.sh --list # print catalog without installing
|
|
12
|
+
# harvest-typeui.sh --refresh # re-download even if already installed
|
|
13
|
+
# harvest-typeui.sh --dest <dir> # install to custom dir (default: ~/.claude/skills)
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
DEST="${HOME}/.claude/skills"
|
|
18
|
+
LIST_ONLY=0
|
|
19
|
+
REFRESH=0
|
|
20
|
+
|
|
21
|
+
while (( $# )); do
|
|
22
|
+
case "$1" in
|
|
23
|
+
--list) LIST_ONLY=1 ;;
|
|
24
|
+
--refresh) REFRESH=1 ;;
|
|
25
|
+
--dest) DEST="$2"; shift ;;
|
|
26
|
+
-h|--help)
|
|
27
|
+
sed -n '2,18p' "$0"
|
|
28
|
+
exit 0 ;;
|
|
29
|
+
*) echo "unknown flag: $1" >&2; exit 2 ;;
|
|
30
|
+
esac
|
|
31
|
+
shift
|
|
32
|
+
done
|
|
33
|
+
|
|
34
|
+
mkdir -p "$DEST"
|
|
35
|
+
|
|
36
|
+
TYPEUI_CATALOG_URL="${TYPEUI_CATALOG_URL:-https://typeui.sh/catalog.json}"
|
|
37
|
+
TMP=$(mktemp)
|
|
38
|
+
trap 'rm -f "$TMP"' EXIT
|
|
39
|
+
|
|
40
|
+
log() { echo "[harvest-typeui] $*"; }
|
|
41
|
+
|
|
42
|
+
fetch_catalog() {
|
|
43
|
+
if command -v curl >/dev/null 2>&1; then
|
|
44
|
+
curl -sSfL --max-time 10 "$TYPEUI_CATALOG_URL" -o "$TMP" 2>/dev/null && return 0
|
|
45
|
+
fi
|
|
46
|
+
if command -v wget >/dev/null 2>&1; then
|
|
47
|
+
wget -q --timeout=10 -O "$TMP" "$TYPEUI_CATALOG_URL" 2>/dev/null && return 0
|
|
48
|
+
fi
|
|
49
|
+
return 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Fallback catalog — built-in list of known typeui skills + companions.
|
|
53
|
+
# Format: name|base_url (raw SKILL.md location)
|
|
54
|
+
DEFAULT_CATALOG=$(cat <<'EOF'
|
|
55
|
+
typeui-ant|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-ant/SKILL.md
|
|
56
|
+
typeui-application|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-application/SKILL.md
|
|
57
|
+
typeui-artistic|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-artistic/SKILL.md
|
|
58
|
+
typeui-bento|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-bento/SKILL.md
|
|
59
|
+
typeui-bold|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-bold/SKILL.md
|
|
60
|
+
typeui-clean|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-clean/SKILL.md
|
|
61
|
+
typeui-dashboard|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-dashboard/SKILL.md
|
|
62
|
+
typeui-doodle|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-doodle/SKILL.md
|
|
63
|
+
typeui-dramatic|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-dramatic/SKILL.md
|
|
64
|
+
typeui-enterprise|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-enterprise/SKILL.md
|
|
65
|
+
typeui-neobrutalism|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-neobrutalism/SKILL.md
|
|
66
|
+
typeui-paper|https://raw.githubusercontent.com/typeui-sh/skills/main/typeui-paper/SKILL.md
|
|
67
|
+
composition-patterns|https://raw.githubusercontent.com/vercel-labs/agent-skills/main/skills/composition-patterns/SKILL.md
|
|
68
|
+
web-design-guidelines|https://raw.githubusercontent.com/vercel-labs/agent-skills/main/skills/web-design-guidelines/SKILL.md
|
|
69
|
+
react-best-practices|https://raw.githubusercontent.com/vercel-labs/agent-skills/main/skills/react-best-practices/SKILL.md
|
|
70
|
+
webapp-testing|https://raw.githubusercontent.com/anthropics/skills/main/skills/webapp-testing/SKILL.md
|
|
71
|
+
mcp-builder|https://raw.githubusercontent.com/anthropics/skills/main/skills/mcp-builder/SKILL.md
|
|
72
|
+
EOF
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
CATALOG=""
|
|
76
|
+
if fetch_catalog; then
|
|
77
|
+
# typeui.sh returned JSON — extract {name,url} tuples via node
|
|
78
|
+
if command -v node >/dev/null 2>&1; then
|
|
79
|
+
CATALOG=$(node -e "
|
|
80
|
+
const c = JSON.parse(require('fs').readFileSync('$TMP','utf8'));
|
|
81
|
+
const items = c.skills || c.items || c;
|
|
82
|
+
for (const it of items) {
|
|
83
|
+
const n = it.name || it.slug;
|
|
84
|
+
const u = it.skill_url || it.url || it.raw || it.source;
|
|
85
|
+
if (n && u) console.log(n + '|' + u);
|
|
86
|
+
}
|
|
87
|
+
" 2>/dev/null || echo "")
|
|
88
|
+
fi
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
if [[ -z "$CATALOG" ]]; then
|
|
92
|
+
log "registry unreachable or empty — using built-in catalog"
|
|
93
|
+
CATALOG="$DEFAULT_CATALOG"
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
if (( LIST_ONLY )); then
|
|
97
|
+
echo "$CATALOG"
|
|
98
|
+
exit 0
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
INSTALLED=0
|
|
102
|
+
SKIPPED=0
|
|
103
|
+
FAILED=0
|
|
104
|
+
|
|
105
|
+
while IFS='|' read -r name url; do
|
|
106
|
+
[[ -z "$name" ]] && continue
|
|
107
|
+
target_dir="$DEST/$name"
|
|
108
|
+
target_file="$target_dir/SKILL.md"
|
|
109
|
+
|
|
110
|
+
if [[ -f "$target_file" && $REFRESH -eq 0 ]]; then
|
|
111
|
+
SKIPPED=$((SKIPPED + 1))
|
|
112
|
+
continue
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
mkdir -p "$target_dir"
|
|
116
|
+
if command -v curl >/dev/null 2>&1; then
|
|
117
|
+
if curl -sSfL --max-time 15 "$url" -o "$target_file" 2>/dev/null; then
|
|
118
|
+
log "installed $name"
|
|
119
|
+
INSTALLED=$((INSTALLED + 1))
|
|
120
|
+
else
|
|
121
|
+
log "failed $name ($url)"
|
|
122
|
+
FAILED=$((FAILED + 1))
|
|
123
|
+
rmdir "$target_dir" 2>/dev/null || true
|
|
124
|
+
fi
|
|
125
|
+
else
|
|
126
|
+
log "curl missing — cannot fetch $name"
|
|
127
|
+
FAILED=$((FAILED + 1))
|
|
128
|
+
fi
|
|
129
|
+
done <<< "$CATALOG"
|
|
130
|
+
|
|
131
|
+
log "done: installed=$INSTALLED skipped=$SKIPPED failed=$FAILED dest=$DEST"
|
|
@@ -1,48 +1,228 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Usage: hash-pages.sh <urls_file>
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
4
|
+
# Captures three viewports per URL (mobile_375, tablet_768, desktop_1280),
|
|
5
|
+
# computes sha256 (exact) + phash (perceptual) for each, and emits a single
|
|
6
|
+
# JSON file with one record per URL (artifact §3.4, §10 line 492, §16 lines
|
|
7
|
+
# 1367-1384).
|
|
8
|
+
#
|
|
9
|
+
# Env vars:
|
|
10
|
+
# OUT_DIR — where to write hashes.json and the per-viewport PNGs
|
|
11
|
+
# (default: docs/super-design/.cache/hashes).
|
|
12
|
+
# MASK_SELECTORS — comma-separated CSS selectors masked on every
|
|
13
|
+
# screenshot via Playwright's `mask:` option with
|
|
14
|
+
# maskColor "#000". Artifact §3.4 defaults are always
|
|
15
|
+
# merged in; user-supplied selectors are additive.
|
|
16
|
+
# MASK_COLOR — overrides the mask fill color (default #000).
|
|
17
|
+
# HASH_URL_TIMEOUT — per-URL navigation timeout ms (default 30000).
|
|
18
|
+
#
|
|
19
|
+
# Output shape (artifact §10 line 492):
|
|
20
|
+
# {
|
|
21
|
+
# "url": "...",
|
|
22
|
+
# "html_hash": "sha256:...",
|
|
23
|
+
# "dom_structure_hash": "sha256:...",
|
|
24
|
+
# "screenshot_hash": "sha256:...", (desktop, back-compat)
|
|
25
|
+
# "viewport_hashes": {
|
|
26
|
+
# "mobile_375": { "sha256": "...", "phash": "..." },
|
|
27
|
+
# "tablet_768": { "sha256": "...", "phash": "..." },
|
|
28
|
+
# "desktop_1280":{ "sha256": "...", "phash": "..." }
|
|
29
|
+
# }
|
|
30
|
+
# }
|
|
31
|
+
#
|
|
32
|
+
# Perceptual hash (phash):
|
|
33
|
+
# We prefer `sharp` if installed (true DCT/aHash on a 32x32 greyscale
|
|
34
|
+
# downsample). If sharp is unavailable we fall back to a deterministic
|
|
35
|
+
# PNG-structural fingerprint — 64 bits derived from 8 evenly-sliced
|
|
36
|
+
# sha256 windows over the PNG buffer. This is NOT a true perceptual
|
|
37
|
+
# hash; it survives identical re-renders but is sensitive to any pixel
|
|
38
|
+
# change. Compare with Hamming distance only when sharp was used (the
|
|
39
|
+
# emitted record includes an "engine" field so consumers can pick the
|
|
40
|
+
# right distance metric).
|
|
9
41
|
set -euo pipefail
|
|
42
|
+
|
|
10
43
|
URLS="${1:?usage: hash-pages.sh <urls_file>}"
|
|
11
44
|
OUT_DIR="${OUT_DIR:-docs/super-design/.cache/hashes}"
|
|
45
|
+
MASK_SELECTORS="${MASK_SELECTORS:-}"
|
|
46
|
+
MASK_COLOR="${MASK_COLOR:-#000}"
|
|
47
|
+
HASH_URL_TIMEOUT="${HASH_URL_TIMEOUT:-30000}"
|
|
12
48
|
mkdir -p "$OUT_DIR"
|
|
13
49
|
|
|
14
|
-
URLS="$URLS" OUT_DIR="$OUT_DIR"
|
|
50
|
+
URLS="$URLS" OUT_DIR="$OUT_DIR" \
|
|
51
|
+
MASK_SELECTORS="$MASK_SELECTORS" MASK_COLOR="$MASK_COLOR" \
|
|
52
|
+
HASH_URL_TIMEOUT="$HASH_URL_TIMEOUT" node --experimental-vm-modules <<'JS'
|
|
15
53
|
import { chromium } from "playwright";
|
|
16
54
|
import { createHash } from "node:crypto";
|
|
17
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
55
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
56
|
+
import { join } from "node:path";
|
|
18
57
|
|
|
19
58
|
const urlsFile = process.env.URLS;
|
|
20
59
|
const outDir = process.env.OUT_DIR;
|
|
21
|
-
const
|
|
60
|
+
const maskColor = process.env.MASK_COLOR || "#000";
|
|
61
|
+
const navTimeout = Number(process.env.HASH_URL_TIMEOUT || 30000);
|
|
62
|
+
|
|
63
|
+
// Artifact §3.4 defaults — always masked.
|
|
64
|
+
const DEFAULT_MASKS = [
|
|
65
|
+
"[data-timestamp]",
|
|
66
|
+
".relative-time",
|
|
67
|
+
"[data-react-hydration]",
|
|
68
|
+
"video",
|
|
69
|
+
"canvas",
|
|
70
|
+
];
|
|
71
|
+
const userMasks = (process.env.MASK_SELECTORS || "")
|
|
72
|
+
.split(",").map(s => s.trim()).filter(Boolean);
|
|
73
|
+
const maskSelectors = [...new Set([...DEFAULT_MASKS, ...userMasks])];
|
|
74
|
+
|
|
75
|
+
// Volatile attributes stripped from the DOM structure hash (artifact §3.4
|
|
76
|
+
// — added data-react-hydration to match the mask defaults).
|
|
77
|
+
const VOLATILE_ATTRS = new Set([
|
|
78
|
+
"nonce",
|
|
79
|
+
"data-timestamp",
|
|
80
|
+
"data-reactid",
|
|
81
|
+
"data-next-hydrate",
|
|
82
|
+
"data-react-hydration",
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const urls = readFileSync(urlsFile, "utf8")
|
|
86
|
+
.split("\n").map(s => s.trim()).filter(Boolean);
|
|
87
|
+
|
|
88
|
+
const VIEWPORTS = [
|
|
89
|
+
{ label: "mobile_375", width: 375, height: 667 },
|
|
90
|
+
{ label: "tablet_768", width: 768, height: 1024 },
|
|
91
|
+
{ label: "desktop_1280", width: 1280, height: 800 },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
95
|
+
|
|
96
|
+
// Optional sharp — gives us a real perceptual hash. Falls back to a
|
|
97
|
+
// PNG-structural fingerprint (documented in the header) when absent.
|
|
98
|
+
let sharp = null;
|
|
99
|
+
let phashEngine = "fallback-png-fingerprint";
|
|
100
|
+
try {
|
|
101
|
+
sharp = (await import("sharp")).default;
|
|
102
|
+
phashEngine = "sharp-ahash-8x8";
|
|
103
|
+
} catch { /* no sharp installed; use fallback */ }
|
|
104
|
+
|
|
105
|
+
async function aHash(buf) {
|
|
106
|
+
if (!sharp) return fallbackFingerprint(buf);
|
|
107
|
+
// Average-hash: downscale to 8x8 greyscale, threshold at mean.
|
|
108
|
+
const raw = await sharp(buf)
|
|
109
|
+
.greyscale()
|
|
110
|
+
.resize(8, 8, { fit: "fill", kernel: "cubic" })
|
|
111
|
+
.raw()
|
|
112
|
+
.toBuffer();
|
|
113
|
+
let sum = 0;
|
|
114
|
+
for (let i = 0; i < raw.length; i++) sum += raw[i];
|
|
115
|
+
const mean = sum / raw.length;
|
|
116
|
+
let bits = 0n;
|
|
117
|
+
for (let i = 0; i < 64; i++) {
|
|
118
|
+
bits = (bits << 1n) | (raw[i] >= mean ? 1n : 0n);
|
|
119
|
+
}
|
|
120
|
+
return bits.toString(16).padStart(16, "0");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fallbackFingerprint(buf) {
|
|
124
|
+
// Zero-dep deterministic fingerprint: slice PNG into 8 windows, take the
|
|
125
|
+
// first byte of each window's sha256, concat to 16 hex chars. Order of
|
|
126
|
+
// magnitude cheaper than a real pHash and only collision-safe for
|
|
127
|
+
// identical-buffer comparisons, but it gives us a stable 64-bit slot in
|
|
128
|
+
// viewport_hashes so downstream tooling can still key on it.
|
|
129
|
+
if (buf.length === 0) return "0".repeat(16);
|
|
130
|
+
const windows = 8;
|
|
131
|
+
const step = Math.max(1, Math.floor(buf.length / windows));
|
|
132
|
+
let out = "";
|
|
133
|
+
for (let i = 0; i < windows; i++) {
|
|
134
|
+
const start = i * step;
|
|
135
|
+
const end = i === windows - 1 ? buf.length : start + step;
|
|
136
|
+
const slice = buf.subarray(start, end);
|
|
137
|
+
const h = createHash("sha256").update(slice).digest();
|
|
138
|
+
out += h.subarray(0, 1).toString("hex");
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
22
143
|
const browser = await chromium.launch();
|
|
23
|
-
const ctx = await browser.newContext({
|
|
24
|
-
viewport: { width: 1280, height: 800 }, reducedMotion: "reduce", deviceScaleFactor: 1,
|
|
25
|
-
});
|
|
26
|
-
const page = await ctx.newPage();
|
|
27
|
-
const sha = s => createHash("sha256").update(s).digest("hex");
|
|
28
144
|
const results = [];
|
|
29
145
|
for (const url of urls) {
|
|
146
|
+
const entry = { url, viewport_hashes: {} };
|
|
30
147
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
148
|
+
// Shared context: cache html/dom work on first (desktop) viewport.
|
|
149
|
+
let capturedHtml = null;
|
|
150
|
+
let capturedDom = null;
|
|
151
|
+
|
|
152
|
+
for (const vp of VIEWPORTS) {
|
|
153
|
+
const ctx = await browser.newContext({
|
|
154
|
+
viewport: { width: vp.width, height: vp.height },
|
|
155
|
+
reducedMotion: "reduce",
|
|
156
|
+
deviceScaleFactor: 1,
|
|
157
|
+
});
|
|
158
|
+
const page = await ctx.newPage();
|
|
159
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: navTimeout });
|
|
160
|
+
|
|
161
|
+
if (capturedHtml === null) {
|
|
162
|
+
capturedHtml = (await page.content()).replace(/\s+/g, " ").trim();
|
|
163
|
+
capturedDom = await page.evaluate((volatileList) => {
|
|
164
|
+
const V = new Set(volatileList);
|
|
165
|
+
const walk = (n) => n.nodeType !== 1 ? "" :
|
|
166
|
+
`<${n.tagName.toLowerCase()}[${[...n.attributes]
|
|
167
|
+
.filter(a => !V.has(a.name))
|
|
168
|
+
.map(a => `${a.name}=${a.value}`)
|
|
169
|
+
.sort().join(",")}]${[...n.childNodes].map(walk).join("")}>`;
|
|
170
|
+
return walk(document.documentElement);
|
|
171
|
+
}, [...VOLATILE_ATTRS]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Build mask locator list (skip selectors that error — they're OK
|
|
175
|
+
// if nothing matches; only invalid syntax throws).
|
|
176
|
+
const masks = [];
|
|
177
|
+
for (const sel of maskSelectors) {
|
|
178
|
+
try { masks.push(page.locator(sel)); } catch { /* bad selector */ }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const buf = await page.screenshot({
|
|
182
|
+
fullPage: true,
|
|
183
|
+
animations: "disabled",
|
|
184
|
+
caret: "hide",
|
|
185
|
+
mask: masks,
|
|
186
|
+
maskColor,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Persist PNG for visual-regression.sh to consume.
|
|
190
|
+
const pngDir = join(outDir, "screenshots", encodeURIComponent(url));
|
|
191
|
+
mkdirSync(pngDir, { recursive: true });
|
|
192
|
+
const pngPath = join(pngDir, `${vp.label}.png`);
|
|
193
|
+
writeFileSync(pngPath, buf);
|
|
194
|
+
|
|
195
|
+
const ph = await aHash(buf);
|
|
196
|
+
entry.viewport_hashes[vp.label] = {
|
|
197
|
+
sha256: "sha256:" + sha(buf),
|
|
198
|
+
phash: `${phashEngine === "sharp-ahash-8x8" ? "phash" : "fpr"}:${ph}`,
|
|
199
|
+
png_path: pngPath,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
if (vp.label === "desktop_1280") {
|
|
203
|
+
entry.screenshot_hash = entry.viewport_hashes[vp.label].sha256;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await ctx.close();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
entry.html_hash = "sha256:" + sha(capturedHtml);
|
|
210
|
+
entry.dom_structure_hash = "sha256:" + sha(capturedDom);
|
|
211
|
+
entry.mask_selectors = maskSelectors;
|
|
212
|
+
entry.phash_engine = phashEngine;
|
|
213
|
+
} catch (e) {
|
|
214
|
+
entry.error = String(e.message || e);
|
|
215
|
+
}
|
|
216
|
+
results.push(entry);
|
|
44
217
|
}
|
|
45
|
-
|
|
218
|
+
|
|
219
|
+
writeFileSync(join(outDir, "hashes.json"), JSON.stringify(results, null, 2));
|
|
46
220
|
await browser.close();
|
|
47
|
-
console.log(JSON.stringify({
|
|
221
|
+
console.log(JSON.stringify({
|
|
222
|
+
count: results.length,
|
|
223
|
+
path: join(outDir, "hashes.json"),
|
|
224
|
+
phash_engine: phashEngine,
|
|
225
|
+
viewports: VIEWPORTS.map(v => v.label),
|
|
226
|
+
mask_selectors: maskSelectors,
|
|
227
|
+
}));
|
|
48
228
|
JS
|