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.
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // Extract + canonicalize + hash design tokens from Tailwind configs and
3
- // CSS custom properties. Output is deterministic regardless of insertion
4
- // order (artifact §9.2 line 722: "canonical = JSON.stringify(theme,
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
- // TODO(sd-audit-state §9.1 artifact line 707-709): DTCG *.tokens.json and
37
- // Tokens Studio support — parse JSON, resolve `{alias}` refs per §9.2 line
38
- // 747 before hashing, then merge into `out` with prefix `dtcg:`.
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
- # TODO(sd-audit-state artifact §10 line 492, §16 line 1367-1384):
5
- # Per-viewport hashes (mobile_375 / tablet_768 / desktop_1280) + pHash
6
- # for perceptual similarity, plus mask_selectors passed to
7
- # page.screenshot({ mask: [...] }) for deterministic diffs. Current impl
8
- # hashes a single desktop viewport only.
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" node --experimental-vm-modules <<'JS'
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 urls = readFileSync(urlsFile, "utf8").split("\n").map(s => s.trim()).filter(Boolean);
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
- await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
32
- const html = (await page.content()).replace(/\s+/g, " ").trim();
33
- const dom = await page.evaluate(() => {
34
- const V = new Set(["nonce","data-timestamp","data-reactid","data-next-hydrate"]);
35
- const walk = n => n.nodeType !== 1 ? "" :
36
- `<${n.tagName.toLowerCase()}[${[...n.attributes].filter(a=>!V.has(a.name))
37
- .map(a=>`${a.name}=${a.value}`).sort().join(",")}]${[...n.childNodes].map(walk).join("")}>`;
38
- return walk(document.documentElement);
39
- });
40
- const buf = await page.screenshot({ fullPage: true, animations: "disabled", caret: "hide" });
41
- results.push({ url, html_hash: "sha256:" + sha(html),
42
- dom_structure_hash: "sha256:" + sha(dom), screenshot_hash: "sha256:" + sha(buf) });
43
- } catch (e) { results.push({ url, error: String(e.message || e) }); }
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
- writeFileSync(outDir + "/hashes.json", JSON.stringify(results, null, 2));
218
+
219
+ writeFileSync(join(outDir, "hashes.json"), JSON.stringify(results, null, 2));
46
220
  await browser.close();
47
- console.log(JSON.stringify({ count: results.length, path: outDir + "/hashes.json" }));
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