start-vibing 4.1.0 → 4.1.2

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.
Files changed (72) hide show
  1. package/package.json +1 -1
  2. package/template/.claude/CLAUDE.md +86 -20
  3. package/template/.claude/agents/sd-audit.md +197 -0
  4. package/template/.claude/agents/sd-fix-verify-semantic.md +112 -0
  5. package/template/.claude/agents/sd-fix-verify-technical.md +36 -0
  6. package/template/.claude/agents/sd-fix.md +194 -0
  7. package/template/.claude/agents/sd-research.md +61 -0
  8. package/template/.claude/agents/sd-synthesis.md +74 -0
  9. package/template/.claude/commands/super-design.md +15 -0
  10. package/template/.claude/hooks/super-design-session-start.sh +4 -0
  11. package/template/.claude/settings.json +14 -0
  12. package/template/.claude/skills/codebase-knowledge/SKILL.md +145 -0
  13. package/template/.claude/skills/codebase-knowledge/TEMPLATE.md +35 -0
  14. package/template/.claude/skills/codebase-knowledge/domains/claude-system.md +93 -0
  15. package/template/.claude/skills/composition-patterns/SKILL.md +89 -0
  16. package/template/.claude/skills/docs-tracker/SKILL.md +239 -0
  17. package/template/.claude/skills/mcp-builder/SKILL.md +236 -0
  18. package/template/.claude/skills/quality-gate/scripts/check-all.sh +83 -0
  19. package/template/.claude/skills/react-best-practices/SKILL.md +146 -0
  20. package/template/.claude/skills/security-scan/reference/owasp-top-10.md +257 -0
  21. package/template/.claude/skills/security-scan/scripts/scan.py +190 -0
  22. package/template/.claude/skills/super-design/README.md +37 -0
  23. package/template/.claude/skills/super-design/SKILL.md +105 -0
  24. package/template/.claude/skills/super-design/hooks/guard-paths.py +35 -0
  25. package/template/.claude/skills/super-design/hooks/post-edit-lint.py +57 -0
  26. package/template/.claude/skills/super-design/references/audit-methodology.md +513 -0
  27. package/template/.claude/skills/super-design/references/change-detection-playbook.md +1432 -0
  28. package/template/.claude/skills/super-design/references/design-theory.md +706 -0
  29. package/template/.claude/skills/super-design/references/fix-agent-playbook.md +118 -0
  30. package/template/.claude/skills/super-design/references/market-research-playbook.md +773 -0
  31. package/template/.claude/skills/super-design/references/playwright-mcp-reference.md +1057 -0
  32. package/template/.claude/skills/super-design/references/skills-subagents-reference.md +784 -0
  33. package/template/.claude/skills/super-design/references/superpowers-and-distribution.md +136 -0
  34. package/template/.claude/skills/super-design/scripts/detect-changes.sh +61 -0
  35. package/template/.claude/skills/super-design/scripts/diff-tokens.sh +13 -0
  36. package/template/.claude/skills/super-design/scripts/discover-routes.sh +45 -0
  37. package/template/.claude/skills/super-design/scripts/extract-tokens.mjs +41 -0
  38. package/template/.claude/skills/super-design/scripts/hash-pages.sh +42 -0
  39. package/template/.claude/skills/super-design/scripts/validate-state.sh +15 -0
  40. package/template/.claude/skills/super-design/scripts/verify-audit.sh +19 -0
  41. package/template/.claude/skills/super-design/templates/audit-state.schema.json +57 -0
  42. package/template/.claude/skills/super-design/templates/findings.schema.json +57 -0
  43. package/template/.claude/skills/super-design/templates/fix-history.md.tpl +26 -0
  44. package/template/.claude/skills/super-design/templates/overview.md.tpl +52 -0
  45. package/template/.claude/skills/test-coverage/reference/playwright-patterns.md +260 -0
  46. package/template/.claude/skills/test-coverage/scripts/coverage-check.sh +52 -0
  47. package/template/.claude/skills/typeui-ant/SKILL.md +133 -0
  48. package/template/.claude/skills/typeui-application/SKILL.md +128 -0
  49. package/template/.claude/skills/typeui-artistic/SKILL.md +133 -0
  50. package/template/.claude/skills/typeui-bento/SKILL.md +127 -0
  51. package/template/.claude/skills/typeui-bold/SKILL.md +127 -0
  52. package/template/.claude/skills/typeui-clean/SKILL.md +128 -0
  53. package/template/.claude/skills/typeui-dashboard/SKILL.md +133 -0
  54. package/template/.claude/skills/typeui-doodle/SKILL.md +142 -0
  55. package/template/.claude/skills/typeui-dramatic/SKILL.md +127 -0
  56. package/template/.claude/skills/typeui-enterprise/SKILL.md +132 -0
  57. package/template/.claude/skills/typeui-neobrutalism/SKILL.md +127 -0
  58. package/template/.claude/skills/typeui-paper/SKILL.md +127 -0
  59. package/template/.claude/skills/ui-ux-audit/QUICK-START.md +450 -0
  60. package/template/.claude/skills/ui-ux-audit/README.md +470 -0
  61. package/template/.claude/skills/ui-ux-audit/templates/audit-report.md +591 -0
  62. package/template/.claude/skills/ui-ux-audit/templates/competitor-analysis.md +363 -0
  63. package/template/.claude/skills/ui-ux-audit/templates/component-spec.md +491 -0
  64. package/template/.claude/skills/ui-ux-audit/templates/improvement-recommendation.md +450 -0
  65. package/template/.claude/skills/web-design-guidelines/SKILL.md +39 -0
  66. package/template/.claude/skills/webapp-testing/SKILL.md +96 -0
  67. package/template/.claude/skills/workflow-state/workflow-state.json +77 -0
  68. package/template/.claude/config/README.md +0 -27
  69. package/template/.claude/config/project-config.json +0 -53
  70. package/template/.claude/config/quality-gates.json +0 -46
  71. package/template/.claude/config/security-rules.json +0 -45
  72. package/template/.claude/config/testing-config.json +0 -164
@@ -0,0 +1,1432 @@
1
+ # Change-detection playbook for the super-design skill
2
+
3
+ **Purpose.** This reference lets a Claude instance execute the full re-audit flow of the `super-design` skill from this document alone. It specifies how to detect whether an audit has run before, how to compute exactly what changed since the last audit, how to scope the incremental re-audit, and how to persist state robustly. Every command, library option, and threshold cited here is traceable to a primary source listed inline.
4
+
5
+ The skill's contract: on first invocation, produce `/docs/super-design/overview.md` plus a full findings set. On every subsequent invocation, read prior state, compute the delta, and re-audit only the impacted surface — falling back to a full audit only when the delta cannot be bounded (token change, theory-doc change, corrupt state, staleness threshold, or explicit user override).
6
+
7
+ ---
8
+
9
+ ## 1. Detecting whether an audit has run before
10
+
11
+ The skill's entry check is cheap: **stat the state file, then validate it**. Two files carry authority together — never just one.
12
+
13
+ ```
14
+ /docs/super-design/overview.md # human-readable output
15
+ /docs/super-design/.audit-state.json # machine-readable state (committed)
16
+ ```
17
+
18
+ **Presence logic.**
19
+
20
+ ```bash
21
+ OVERVIEW="docs/super-design/overview.md"
22
+ STATE="docs/super-design/.audit-state.json"
23
+
24
+ if [[ ! -f "$STATE" ]]; then
25
+ MODE="first-audit" # no prior state at all
26
+ elif [[ ! -f "$OVERVIEW" ]]; then
27
+ MODE="regenerate-overview" # state exists but report was deleted; rebuild from findings/
28
+ else
29
+ MODE="incremental-candidate"
30
+ fi
31
+ ```
32
+
33
+ **Reading audit metadata.** The state file carries `last_audit_at`, `git_sha_at_audit`, `skill_version`, `theory_doc_sha`, tool versions, per-page hashes, and finding counts. Parse defensively: a single unreadable field must not abort the skill.
34
+
35
+ ```ts
36
+ import { z } from "zod";
37
+
38
+ const StateSchema = z.object({
39
+ schema_version: z.string(),
40
+ last_audit_at: z.string().datetime(),
41
+ git_sha_at_audit: z.string().regex(/^[0-9a-f]{7,64}$/),
42
+ git_branch: z.string().optional(),
43
+ skill_version: z.string(),
44
+ theory_doc_sha: z.string(),
45
+ tools: z.record(z.string()),
46
+ pages_audited: z.array(z.object({
47
+ url: z.string(),
48
+ html_hash: z.string().optional(),
49
+ dom_structure_hash: z.string().optional(),
50
+ screenshot_hash: z.string().optional(),
51
+ viewport_hashes: z.record(z.string()).optional(),
52
+ last_audited: z.string().datetime(),
53
+ })),
54
+ components: z.record(z.string()).optional(), // path -> content hash
55
+ route_map: z.array(z.string()).optional(),
56
+ findings_counts: z.object({
57
+ blockers: z.number(), high: z.number(),
58
+ medium: z.number(), nitpicks: z.number(),
59
+ }),
60
+ research_at: z.string().datetime().optional(),
61
+ market_analysis_sha: z.string().optional(),
62
+ });
63
+
64
+ function readState(path: string) {
65
+ try {
66
+ const raw = JSON.parse(fs.readFileSync(path, "utf8"));
67
+ return { ok: true, state: StateSchema.parse(raw) };
68
+ } catch (err) {
69
+ return { ok: false, reason: err instanceof z.ZodError ? "schema" : "parse", err };
70
+ }
71
+ }
72
+ ```
73
+
74
+ **Graceful corruption handling.** If `readState` fails, log the reason, move the broken file to `.audit-state.json.corrupt-<timestamp>`, and fall through to `first-audit`. Never delete silently — the user should be able to inspect what went wrong.
75
+
76
+ **Invalidation criteria (any one triggers a full re-audit).**
77
+
78
+ | Condition | Check |
79
+ |---|---|
80
+ | **Tool major bump** (axe-core 4.x → 5.x, Lighthouse 13 → 14) | `semver.major(state.tools[name]) !== semver.major(current)` |
81
+ | **Theory doc updated** | `sha256(references/design-theory.md) !== state.theory_doc_sha` |
82
+ | **Staleness** | `Date.now() - Date.parse(state.last_audit_at) > 90 * 86400_000` |
83
+ | **Dependency major bump** | New major in `package.json` for React/Next/Tailwind/shadcn |
84
+ | **Skill schema bump** | `state.schema_version` older than current |
85
+ | **User override** | `--force-full` flag |
86
+
87
+ ---
88
+
89
+ ## 2. Using `git log` for change detection
90
+
91
+ The primary signal is a commit-range query bounded by `state.git_sha_at_audit..HEAD`. All syntax below is taken from the canonical Git docs at git-scm.com.
92
+
93
+ ### 2.1 The core range query
94
+
95
+ ```bash
96
+ git log "$LAST_SHA..HEAD" --name-only --pretty=format:"%H|%s|%an|%aI"
97
+ ```
98
+
99
+ `A..B` expands to "reachable from B but not A". The `%H|%s|%an|%aI` placeholders are **commit hash | subject | author name | author date (strict ISO-8601)** — `%aI` (capital I) gives strict ISO-8601 which is unambiguous to parse; `%ai` gives the looser space-separated form. `format:` uses separator semantics; if you want a trailing newline per record, use `tformat:` or plain `--pretty=tformat:...` (unrecognized `%`-strings default to `tformat`). See https://git-scm.com/docs/pretty-formats.
100
+
101
+ Sample output (name-only appends files per commit, separated by a blank line):
102
+
103
+ ```
104
+ a1b2c3d4...|Add login flow|Jane Doe|2026-04-18T14:22:11-07:00
105
+ src/auth/login.ts
106
+ src/auth/session.ts
107
+
108
+ 9f8e7d6c...|Bump deps|Bot|2026-04-17T02:00:00+00:00
109
+ package.json
110
+ pnpm-lock.yaml
111
+ ```
112
+
113
+ **For machine parsing prefer `-z` NUL-termination** (handles filenames with spaces/newlines per `core.quotePath`).
114
+
115
+ ### 2.2 Magnitude quantification
116
+
117
+ ```bash
118
+ git diff --shortstat "$LAST_SHA..HEAD" # one summary line
119
+ git diff --stat "$LAST_SHA..HEAD" # per-file bars (human)
120
+ git diff --numstat "$LAST_SHA..HEAD" # per-file added\tdeleted\tpath (machine)
121
+ ```
122
+
123
+ `--numstat` prints binary files as `-\t-\t<path>`; with `-z` it emits renames as `added\tdeleted\t\0oldpath\0newpath\0` (an **extra NUL before the preimage path** distinguishes the rename record without lookahead). See https://git-scm.com/docs/git-diff.
124
+
125
+ ### 2.3 Filtering noise with pathspecs
126
+
127
+ `git log`/`git diff` accept negative pathspecs to exclude noise. The short form is `:!pattern` (or `:^`); the long form is `:(exclude)pattern`. Magic pathspecs live in `:(magic1,magic2,…)pattern` — most commonly `:(glob)` to make `**` meaningful and `*` not cross `/`, and `:(icase)` for case-insensitive matches. See https://git-scm.com/docs/gitglossary.
128
+
129
+ ```bash
130
+ git log "$LAST_SHA..HEAD" \
131
+ --name-only --pretty=format: \
132
+ -- \
133
+ ':(glob)**/*' \
134
+ ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!yarn.lock' \
135
+ ':!.github/**' ':!**/*.test.*' ':!**/*.spec.*' \
136
+ ':!**/__mocks__/**' ':!**/*.stories.*' \
137
+ | sort -u
138
+ ```
139
+
140
+ The `--` is **required** to separate pathspecs from rev args.
141
+
142
+ ### 2.4 Design-relevance classifier
143
+
144
+ Classify each changed path into an impact bucket. This is the heart of the delta logic.
145
+
146
+ | Path pattern | Bucket | Consequence |
147
+ |---|---|---|
148
+ | `tailwind.config.*`, `**/*.tokens.json`, `styles/theme.css`, `styles/tokens.css`, `@theme` blocks in any CSS | `tokens` | Global — full re-audit |
149
+ | `components/**`, `src/components/**`, `app/_components/**` | `components` | Re-audit pages importing changed component (transitive closure) |
150
+ | `app/**/page.{tsx,jsx,ts,js,md,mdx}`, `pages/**/*.{tsx,...}`, `src/routes/**/+page.svelte`, `src/pages/**/*.astro`, `app/routes/**/*.tsx` | `routes` | Identify added/modified/deleted routes |
151
+ | `public/**`, `src/assets/**` (images, svgs, fonts) | `imagery` | Imagery audit only; no framework inspection |
152
+ | `package.json` (dependencies / devDependencies changed) | `deps` | Rerun Lighthouse + axe; check for framework major bump |
153
+ | `references/design-theory.md`, `references/market-analysis.md` (inside the skill) | `theory` | Invalidate prior heuristic findings |
154
+ | `*.md`, `*.mdx` outside `references/` | `content` | A11y + content checks only |
155
+ | `**/*.test.*`, `**/*.spec.*`, `.github/**`, `*.lock`, `**/*.md` in `node_modules` | `ignored` | Skip |
156
+
157
+ **New-files-only query** for route discovery:
158
+
159
+ ```bash
160
+ git log --diff-filter=A --name-only --pretty=format: "$LAST_SHA..HEAD" | sort -u
161
+ ```
162
+
163
+ Diff-filter letters: `A` added, `C` copied, `D` deleted, `M` modified, `R` renamed, `T` type-changed (file↔symlink↔submodule), `U` unmerged, `B` pairing broken. Lowercase excludes — `--diff-filter=d` means "everything except deletions." See https://git-scm.com/docs/diff-options.
164
+
165
+ ### 2.5 Merges, cherry-picks, rebases, force-pushes
166
+
167
+ Trunk-based workflows with PR merges benefit from `--first-parent` to see one entry per merged PR rather than every topic-branch commit. Use `--no-merges` (equivalent to `--max-parents=1`) to exclude merge commits entirely.
168
+
169
+ **Cherry-picked commits** (backports inside the audit window) can double-count. Strip them with the patch-equivalence flags:
170
+
171
+ ```bash
172
+ git log --no-merges --cherry-pick --right-only "$LAST_SHA...HEAD"
173
+ ```
174
+
175
+ `A...B` (three dots) is symmetric difference; `--cherry-pick` omits commits that introduce the same change as one on the other side; `--right-only` keeps only the `HEAD` side. See https://git-scm.com/docs/git-log.
176
+
177
+ **Merge-commit diffs** default to **combined diff** (`--cc`), which **only lists files that differ from all parents**. A file changed on a topic branch but untouched during merge resolution may not appear. Either walk individual commits or use `--diff-merges=first-parent` / `-m`. See https://git-scm.com/docs/diff-format (combined format: `@@@ ... @@@` with `parents+1` `@` characters).
178
+
179
+ **Force-push / history rewrite survival.** The audit tool must never crash when `$LAST_SHA` is gone. The recovery ladder:
180
+
181
+ ```bash
182
+ # 1. Does it exist locally?
183
+ git rev-parse --verify --quiet "$LAST_SHA^{commit}" >/dev/null || MISSING=1
184
+
185
+ # 2. Is it still an ancestor of HEAD?
186
+ git merge-base --is-ancestor "$LAST_SHA" HEAD
187
+ # exit 0 = ancestor; exit 1 = not ancestor; any other non-zero = error
188
+
189
+ # 3. If diverged but both exist, find the common base
190
+ git merge-base HEAD "$LAST_SHA"
191
+
192
+ # 4. Shallow clone? Can't see history.
193
+ SHALLOW=$(git rev-parse --is-shallow-repository) # "true" | "false"
194
+ # Remedy: git fetch --unshallow
195
+
196
+ # 5. Last resort: time-based range
197
+ git log --since="$LAST_AUDIT_ISO" --name-only
198
+ ```
199
+
200
+ Note that `git merge-base --is-ancestor` documents exit codes precisely: 0 ancestor, 1 not ancestor, **non-zero and not 1** on error (typically 128). See https://git-scm.com/docs/git-merge-base.
201
+
202
+ **Reflog is local-only.** `gc.reflogExpire` defaults to 90 days (reachable) / 30 days (unreachable). Fresh clones and CI runners have no reflog — do not rely on it for cross-machine recovery.
203
+
204
+ ### 2.6 Rename detection
205
+
206
+ Git does not store renames; it infers them via similarity index. `git log --follow <path>` follows one file across renames but **accepts only a single pathname** — no globs, no directories (documented constraint in `git-log`). Prefer `git log --name-status -M` at the diff layer:
207
+
208
+ ```bash
209
+ git diff --name-status -M90% "$LAST_SHA..HEAD"
210
+ # R100 old/util.ts new/util.ts ← exact rename, no content change
211
+ # R095 components/Button.tsx ui/Button.tsx ← 95% similar
212
+ # M src/auth/login.ts
213
+ ```
214
+
215
+ `-M90%` means "treat a delete/add pair as a rename when ≥90% of the file is unchanged." Default similarity is 50%. See https://git-scm.com/docs/diff-options.
216
+
217
+ ### 2.7 Route discovery from filesystem
218
+
219
+ After pulling git-level changes, cross-reference with a fresh filesystem scan to find route files that exist today. Framework-specific conventions (all verified from official docs, April 2026):
220
+
221
+ | Framework | Root | Route-activating files | Dynamic | Excluded |
222
+ |---|---|---|---|---|
223
+ | Next.js App Router | `app/` or `src/app/` | `page.{js,jsx,ts,tsx}`, `route.{js,ts}` | `[id]`, `[...slug]`, `[[...slug]]` | `_folder/*`, `(group)` stripped, `@slot` parallel, `(.)`/`(..)`/`(...)` intercepting |
224
+ | Next.js Pages Router | `pages/` | any `.{js,jsx,ts,tsx,md,mdx}` | `[id].tsx`, `[...slug].tsx`, `[[...slug]].tsx` | `_app`, `_document`, `_error`, `404`, `500`, `api/**` |
225
+ | Remix / React Router v7 (fw) | `app/routes/` | flat: `posts.$postId.tsx`; folder: `folder/route.{ext}` | `$param`, `$` splat | `_index`, leading `_` pathless, `[brackets]` escapes |
226
+ | Astro | `src/pages/` | `.astro`, `.md`, `.mdx`, `.html`, `.js`, `.ts` | `[id].astro`, `[...slug].astro` | `_*` (private) |
227
+ | Nuxt 3/4 | `pages/` or `app/pages/` | `.vue`, `.{j,t}sx?` | `[id].vue`, `[...slug].vue`, `[[id]].vue` | `-prefixed` ignored |
228
+ | SvelteKit | `src/routes/` | `+page.svelte`, `+layout.svelte`, `+server.{js,ts}`, `+error.svelte` | `[id]`, `[[id]]`, `[...rest]`, `[p=matcher]` | non-`+` files; `(group)` stripped |
229
+ | SolidStart | `src/routes/` | `.{ts,tsx,js,jsx,md,mdx}` | `[id].tsx`, `[...slug].tsx` | `(group)` folders |
230
+ | Gatsby | `src/pages/` + `createPages` | `.{js,jsx,ts,tsx,md,mdx}` | `[id].js`, `[...].js` | `_*`, `api/*` functions |
231
+ | React Router library | none | `createBrowserRouter`, `<Route path>` | `:param`, `*` | AST required |
232
+ | Vue Router library | none | `createRouter({routes})` | `:param`, `:param?`, `:param*` | AST required; `unplugin-vue-router` adds fs |
233
+ | Angular | none | `Routes[]` / `provideRouter` / `RouterModule.forRoot` | `:param`, `**` | AST required |
234
+
235
+ **Framework detection heuristic (check in order):**
236
+
237
+ ```bash
238
+ [[ -f next.config.js || -f next.config.ts || -f next.config.mjs ]] && FRAMEWORK=next
239
+ [[ -d app/routes && -f remix.config.js ]] && FRAMEWORK=remix
240
+ [[ -f svelte.config.js && -d src/routes ]] && FRAMEWORK=sveltekit
241
+ [[ -f astro.config.mjs && -d src/pages ]] && FRAMEWORK=astro
242
+ [[ -f nuxt.config.ts || -f nuxt.config.js ]] && FRAMEWORK=nuxt
243
+ [[ -f app.config.ts && -d src/routes ]] && FRAMEWORK=solid-start
244
+ [[ -f gatsby-config.js || -f gatsby-config.ts ]] && FRAMEWORK=gatsby
245
+ [[ -f angular.json ]] && FRAMEWORK=angular
246
+ ```
247
+
248
+ For Next.js, distinguish App vs Pages by which of `app/` and `pages/` contains a `page` or route file; both can coexist during migration — treat the union.
249
+
250
+ **Dynamic routes get a representative instance.** For `[id]`, `[...slug]`, `:postId`, etc., don't try to enumerate — pick one canonical fixture (from fixtures file, test data, or the first seed value in `getStaticPaths`) and audit that. Record in state as `"/posts/[id]@example-123"` so the next audit re-uses the same instance.
251
+
252
+ ---
253
+
254
+ ## 3. Page-level change detection via content hashing
255
+
256
+ Three complementary signals; use all three for rich "what kind of change" semantics.
257
+
258
+ ### 3.1 HTML content hashing
259
+
260
+ SHA-256 of the page's fully-rendered HTML after network idle. Fastest, crudest signal; any whitespace or inline-script nonce changes it.
261
+
262
+ ```ts
263
+ import { createHash } from "node:crypto";
264
+
265
+ async function htmlHash(page) {
266
+ await page.goto(url, { waitUntil: "networkidle" });
267
+ const html = await page.content();
268
+ const normalized = html.replace(/\s+/g, " ").trim();
269
+ return createHash("sha256").update(normalized).digest("hex");
270
+ }
271
+ ```
272
+
273
+ **When to use.** Cheap first pass. If HTML hash is unchanged, pixel hash is almost certainly unchanged too — skip screenshot.
274
+ **False positives.** Nonces, timestamps, CSRF tokens, SSR hydration markers, Next.js `__NEXT_DATA__` with dynamic data. Strip these before hashing.
275
+ **False negatives.** CSS-only changes that don't touch HTML (global token tweak). That's why HTML hashing alone is insufficient.
276
+
277
+ ### 3.2 DOM structure hashing
278
+
279
+ Walk the DOM, emit a canonical `tag[sortedAttrs]` tree, **strip text content and volatile attributes**, then hash. Captures layout/structure changes while being robust to copy edits.
280
+
281
+ ```ts
282
+ async function domStructureHash(page) {
283
+ const serialized = await page.evaluate(() => {
284
+ const VOLATILE = new Set([
285
+ "nonce", "data-timestamp", "data-reactid",
286
+ "data-react-hydration", "data-next-hydrate"
287
+ ]);
288
+ function walk(node) {
289
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
290
+ const attrs = [...node.attributes]
291
+ .filter(a => !VOLATILE.has(a.name))
292
+ .map(a => `${a.name}=${a.value}`)
293
+ .sort()
294
+ .join(",");
295
+ const children = [...node.childNodes].map(walk).join("");
296
+ return `<${node.tagName.toLowerCase()}[${attrs}]${children}>`;
297
+ }
298
+ return walk(document.documentElement);
299
+ });
300
+ return createHash("sha256").update(serialized).digest("hex");
301
+ }
302
+ ```
303
+
304
+ For fuzzy similarity use **SimHash** over attribute-token shingles and compare with Hamming distance (same thresholds as perceptual hashes: ≤5 very similar, ≤10 probably similar on 64-bit). Libraries worth knowing: `diff-dom` produces a structural JSON diff (useful as the "why did it change" companion to a hash mismatch), and `hast-util-hash` works well on the unified/rehype HAST tree.
305
+
306
+ ### 3.3 Visual regression hashing
307
+
308
+ Pixel-level comparison of screenshots, or perceptual hashes (pHash/dHash) for robust "same-ish" detection.
309
+
310
+ **Pixel-exact with `pixelmatch`** (https://github.com/mapbox/pixelmatch). RGBA buffers in, diff buffer out, returns number of mismatched pixels. Default options per the README and `index.js`:
311
+
312
+ | Option | Default | Meaning |
313
+ |---|---|---|
314
+ | `threshold` | `0.1` | Per-pixel acceptance; squared YIQ distance ≤ `35215 * threshold²` passes |
315
+ | `includeAA` | `false` | Skip anti-aliased pixels (Vyšniauskas 2009 detector) |
316
+ | `alpha` | `0.1` | Blending for unchanged pixels in diff image |
317
+ | `aaColor` | `[255,255,0]` | Yellow marker for AA pixels |
318
+ | `diffColor` | `[255,0,0]` | Red marker for different pixels |
319
+ | `diffMask` | `false` | Transparent background instead of original |
320
+
321
+ ```js
322
+ import { PNG } from "pngjs";
323
+ import pixelmatch from "pixelmatch";
324
+
325
+ const a = PNG.sync.read(fs.readFileSync("before.png"));
326
+ const b = PNG.sync.read(fs.readFileSync("after.png"));
327
+ const diff = new PNG({ width: a.width, height: a.height });
328
+ const n = pixelmatch(a.data, b.data, diff.data, a.width, a.height, { threshold: 0.1 });
329
+ ```
330
+
331
+ The threshold is **perceptual**: pixelmatch converts color differences to YIQ color space (Kotsarenko & Ramos, 2010), and `maxDelta = 35215 * threshold * threshold` caps squared YIQ distance. 0.1 tolerates AA/compression noise; 0.2 (Playwright's default) absorbs more font noise.
332
+
333
+ **`odiff`** (https://github.com/dmtrKovalenko/odiff, v4.x, Zig + SIMD). Cross-format input, same YIQ semantics, **roughly 6× faster than pixelmatch** on author-run benchmarks — useful when hashing dozens of pages. CLI and Node API:
334
+
335
+ ```bash
336
+ odiff before.png after.png diff.png --threshold=0.1 --antialiasing --fail-on-layout
337
+ ```
338
+
339
+ ```js
340
+ const { compare } = require("odiff-bin");
341
+ const { match, diffPercentage, diffCount, reason } = await compare(
342
+ "a.png", "b.png", "diff.png",
343
+ { threshold: 0.1, antialiasing: true, failOnLayoutDiff: false }
344
+ );
345
+ ```
346
+
347
+ **`resemble.js`** (https://github.com/rsmbl/Resemble.js). Returns a percentage (`misMatchPercentage`), ships richer output styles (`errorType: 'movement'`), and supports `.ignoreAntialiasing()`, `.ignoreColors()`, rectangle masks. **Watch-out**: `outputSettings.largeImageThreshold` defaults to `1200` and **silently downsamples** images larger than that — set to `0` for faithful full-res comparison.
348
+
349
+ **`looks-same`** (https://github.com/gemini-testing/looks-same). Uses **CIEDE2000** perceptual color difference rather than YIQ, default `tolerance: 2.3` ΔE, `ignoreAntialiasing: true` and `ignoreCaret: true` by default. Best when text-heavy screenshots produce YIQ false positives.
350
+
351
+ **`BackstopJS`** wraps Resemble with a scenario/reference/test/approve workflow and ships an official Docker image for font consistency. Default `misMatchThreshold: 0.1` (0.1% of pixels).
352
+
353
+ **Percy** is conceptually different: SDKs capture a **serialized DOM + asset bundle** in your test browser, Percy's cloud re-renders server-side with JS disabled and performs deterministic pixel diffing. Trade-off: no local baseline store in the repo; requires network; auto-handles AA/font noise via their Visual Engine.
354
+
355
+ **Perceptual hashing (pHash/dHash/aHash).** Use when pixel-exact is too brittle and you want "same-looking" semantics. The foundational reference is Zauner's thesis *Implementation and Benchmarking of Perceptual Image Hash Functions* (2010), available at https://www.phash.org/docs/pubs/thesis_zauner.pdf — DCT-based pHash computes the low-frequency 8×8 DCT coefficients of a 32×32 grayscale downsample and takes a median-threshold bit vector. Neal Krawetz introduced aHash in "Looks Like It" (http://www.hackerfactor.com/blog/?/archives/432-Looks-Like-It.html) and dHash in "Kind of Like That" (http://www.hackerfactor.com/blog/?/archives/529-Kind-of-Like-That.html). **Hamming distance heuristics on 64-bit hashes**: 0 identical, ≤5 near-duplicate, ≤10 probably similar, >10 likely different. Production systems often tighten to ≤2 at scale. Node libraries: `sharp-phash`, `imghash`, `blockhash-core`, `sharp-blockhash`.
356
+
357
+ ### 3.4 Playwright screenshot API — the capture end
358
+
359
+ Key options (https://playwright.dev/docs/api/class-page, https://playwright.dev/docs/api/class-pageassertions):
360
+
361
+ ```ts
362
+ await page.screenshot({
363
+ path: "home.png",
364
+ fullPage: true,
365
+ animations: "disabled", // fast-forwards finite CSS animations, freezes infinite
366
+ caret: "hide",
367
+ mask: [ // solid-fill (default #FF00FF) overlay; covers dynamic content
368
+ page.locator(".date"),
369
+ page.locator("[data-dynamic]"),
370
+ page.locator(".ad-banner"),
371
+ ],
372
+ maskColor: "#000000",
373
+ scale: "css", // one-pixel-per-CSS-pixel; "device" uses DPR
374
+ style: "* { caret-color: transparent !important; }",
375
+ });
376
+ ```
377
+
378
+ At the **context** level set `reducedMotion: "reduce"` and a fixed `viewport` and `deviceScaleFactor` so screenshots are reproducible. Playwright's built-in `expect(page).toHaveScreenshot()` assertion defaults to `threshold: 0.2` (per-pixel YIQ), `animations: "disabled"`, `caret: "hide"`, and exposes `maxDiffPixels` and `maxDiffPixelRatio`. Baselines are stored per-platform+browser (`hero-chromium-linux.png`) — **don't cross-compare Linux ↔ macOS bitmaps**.
379
+
380
+ ### 3.5 Threshold tuning and dynamic masking
381
+
382
+ Why **~1% pixel threshold** is the common floor: for a 1280×800 screenshot (1,024,000 px), 1% = 10,240 px, enough headroom for anti-aliasing and sub-pixel text positioning while still catching a moved button or swapped icon.
383
+
384
+ Font rendering diverges substantially across platforms: Windows uses DirectWrite + ClearType subpixel RGB; macOS uses Core Text grayscale (no subpixel since Mojave); Linux depends on fontconfig and the `--font-render-hinting=none|medium|full` Chromium flag (see cypress-io/cypress#2920). Even within Linux, Debian vs Alpine and glibc vs musl shift glyph advance widths by 1–2 px. **Mitigations**: always baseline in CI (not locally), pin a Docker image (`mcr.microsoft.com/playwright:v1.x.y-jammy` or `backstopjs/backstopjs:<version>`), install a fixed font set (`fonts-liberation`, `fonts-noto`, `fonts-noto-color-emoji`), launch Chromium with `--font-render-hinting=none`, and set `reducedMotion: "reduce"` plus `animations: "disabled"`.
385
+
386
+ Mask dynamic regions via Playwright locators (preferred) or with `page.addStyleTag({ content: ".timestamp, .avatar { visibility: hidden }" })` injected before snapshot. Standard candidates: timestamps, avatars, A/B variant banners, third-party ads/chat widgets, CSRF-bearing nonce blocks.
387
+
388
+ ---
389
+
390
+ ## 4. Incremental audit scope decision tree
391
+
392
+ Given the change categories from §2.4 and hashes from §3, decide the scope. Implemented as a cascading decision — the first match wins, highest-impact first.
393
+
394
+ ```
395
+ ┌─────────────────────────────────────┐
396
+ │ Did theory_doc_sha change? │ ─ YES → FULL (reset heuristic findings)
397
+ └──────────────┬──────────────────────┘
398
+ NO
399
+ ┌──────────────▼──────────────────────┐
400
+ │ Did any token source change? │
401
+ │ (tailwind.config.*, @theme blocks, │ ─ YES → FULL (tokens are global)
402
+ │ *.tokens.json, :root --*) │
403
+ └──────────────┬──────────────────────┘
404
+ NO
405
+ ┌──────────────▼──────────────────────┐
406
+ │ Did package.json majors change? │
407
+ │ (react/next/tailwind/shadcn/...) │ ─ YES → FULL + bump tool cache
408
+ └──────────────┬──────────────────────┘
409
+ NO
410
+ ┌──────────────▼──────────────────────┐
411
+ │ Did skill_version major change? │ ─ YES → FULL
412
+ └──────────────┬──────────────────────┘
413
+ NO
414
+ ┌──────────────▼──────────────────────┐
415
+ │ Is last_audit_at > 90 days old? │ ─ YES → FULL (freshness)
416
+ └──────────────┬──────────────────────┘
417
+ NO
418
+ ┌──────────────▼──────────────────────┐
419
+ │ Did any components change? │
420
+ │ → build importer closure (§8) │ ─ YES → PARTIAL: {impacted pages}
421
+ └──────────────┬──────────────────────┘
422
+ ┌──────────────▼──────────────────────┐
423
+ │ New route files detected? │ ─ YES → PARTIAL: {new routes only} (append)
424
+ └──────────────┬──────────────────────┘
425
+ ┌──────────────▼──────────────────────┐
426
+ │ Deleted route files? │ ─ YES → remove from state, mark findings RESOLVED
427
+ └──────────────┬──────────────────────┘
428
+ ┌──────────────▼──────────────────────┐
429
+ │ Dependency non-major bump? │ ─ YES → PARTIAL: rerun Lighthouse+axe only
430
+ └──────────────┬──────────────────────┘
431
+ ┌──────────────▼──────────────────────┐
432
+ │ Content-only (.md/mdx, text copy)? │ ─ YES → PARTIAL: a11y + content checks only
433
+ └──────────────┬──────────────────────┘
434
+ ┌──────────────▼──────────────────────┐
435
+ │ Public images/fonts changed? │ ─ YES → PARTIAL: imagery audit on touched pages
436
+ └──────────────┬──────────────────────┘
437
+ NO
438
+ ┌────▼────────────┐
439
+ │ No audit needed │
440
+ │ Exit with note. │
441
+ └─────────────────┘
442
+ ```
443
+
444
+ Scope buckets unify as a **set-valued output**: `{ "pages": Set<url>, "agents": Set<"research"|"a11y"|"perf"|"imagery"|"heuristic"> }`. Research agent reruns if `research_at` > 90 days OR `market_analysis.md` changed OR deps changed.
445
+
446
+ ---
447
+
448
+ ## 5. State file schema
449
+
450
+ The canonical schema lives at `/docs/super-design/.audit-state.json`. **Commit it.** It is small, human-inspectable, merge-friendly JSON, and future skill invocations — including from fresh clones or different machines — need it.
451
+
452
+ ```jsonc
453
+ {
454
+ "schema_version": "1.0.0",
455
+ "skill_version": "0.3.1",
456
+ "last_audit_at": "2026-04-19T14:22:11Z",
457
+ "git_sha_at_audit": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
458
+ "git_branch": "main",
459
+ "is_shallow_clone": false,
460
+
461
+ "theory_doc_sha": "sha256:f2a7...",
462
+ "market_analysis_sha": "sha256:8c91...",
463
+
464
+ "tools": {
465
+ "axe-core": "4.11.2",
466
+ "lighthouse": "13.1.0",
467
+ "playwright": "1.59.1",
468
+ "playwright-mcp": "0.0.70",
469
+ "pa11y": "9.0.0"
470
+ },
471
+
472
+ "framework": {
473
+ "name": "next",
474
+ "router": "app",
475
+ "version": "15.2.0"
476
+ },
477
+
478
+ "route_map": [
479
+ "/",
480
+ "/about",
481
+ "/posts/[id]@fixture-123",
482
+ "/dashboard",
483
+ "/dashboard/settings"
484
+ ],
485
+
486
+ "pages_audited": [
487
+ {
488
+ "url": "/",
489
+ "route_file": "app/page.tsx",
490
+ "html_hash": "sha256:b1c2...",
491
+ "dom_structure_hash": "sha256:4d5e...",
492
+ "viewport_hashes": {
493
+ "mobile_375": "phash:f884c4d8d1193c07",
494
+ "tablet_768": "phash:f884c4d8d1193c07",
495
+ "desktop_1280":"phash:e774c4d8d1193c07"
496
+ },
497
+ "last_audited": "2026-04-19T14:22:11Z",
498
+ "findings_ids": ["F-001", "F-014", "F-022"]
499
+ }
500
+ ],
501
+
502
+ "components": {
503
+ "src/components/ui/Button.tsx": "xxh3:8f2a1c...",
504
+ "src/components/ui/Card.tsx": "xxh3:4b1e9d..."
505
+ },
506
+
507
+ "import_graph_sha": "sha256:9a8b...",
508
+
509
+ "findings_counts": {
510
+ "blockers": 2, "high": 7, "medium": 15, "nitpicks": 22
511
+ },
512
+
513
+ "research_at": "2026-04-01T10:00:00Z",
514
+
515
+ "ignored_paths": [
516
+ "*.lock", "package-lock.json", ".github/**", "**/*.test.*"
517
+ ]
518
+ }
519
+ ```
520
+
521
+ **Tradeoffs vs alternative schemas.**
522
+
523
+ - **Monolithic JSON (recommended above)** — single source of truth, easy git diff review, trivial to parse. Scales poorly past ~2,000 pages per app.
524
+ - **Frontmatter inside `overview.md`** — delightful colocation but YAML frontmatter fights with prose; nested fields get ugly; cannot be validated with JSON Schema tooling easily. **Reject.**
525
+ - **Directory of per-page JSON** (`pages/home.json`, `pages/dashboard.json`) — scales to huge sites; enables per-page ownership; adds filesystem walk overhead each run. **Use when `pages_audited.length > 500`.**
526
+ - **SQLite / DuckDB file** — best for very large sites with history (per-audit snapshots). Overkill for a first-release skill; consider only if history queries become common.
527
+ - **Git notes** (see §6) — survives clones if pushed, but invisible by default and conflicts are painful. Use as a **redundant anchor**, not primary storage.
528
+
529
+ **Schema evolution.** Bump `schema_version` major on breaking change; skill must read older versions (at least previous major) and either migrate in place or force a full re-audit with a clear note in `overview.md`.
530
+
531
+ ---
532
+
533
+ ## 6. GitHub / Git integration patterns
534
+
535
+ **Merge base recovery.** Always resolve the effective range start:
536
+
537
+ ```bash
538
+ resolve_range_start() {
539
+ local last="$1"
540
+ if ! git rev-parse --verify --quiet "${last}^{commit}" >/dev/null; then
541
+ echo "__MISSING__"; return
542
+ fi
543
+ if git merge-base --is-ancestor "$last" HEAD; then
544
+ echo "$last"
545
+ else
546
+ git merge-base HEAD "$last" 2>/dev/null || echo "__UNRELATED__"
547
+ fi
548
+ }
549
+ ```
550
+
551
+ **`__MISSING__` fallback ladder** (try in order, stop at first success):
552
+
553
+ 1. `git fetch --unshallow` if `is-shallow-repository` is true.
554
+ 2. Check local reflog for the SHA: `git reflog | grep -F "$last"` (local-only; often empty on CI).
555
+ 3. Fetch tags/notes from origin: `git fetch origin 'refs/notes/*:refs/notes/*'` — a prior `super-design` note anchored on that commit might still be retrievable.
556
+ 4. Fall back to `--since=$last_audit_at` range and warn the user that the anchor was lost.
557
+ 5. If all fail, treat as first audit; write a changelog note explaining why.
558
+
559
+ **`git notes` as redundant state anchor.** Notes are object-attached metadata on a specific commit; they survive force-pushes of branch refs because they're keyed by commit SHA, not ref name. Used correctly they let a fresh clone recover the audit history without the committed `.audit-state.json`.
560
+
561
+ ```bash
562
+ # Write on every successful audit — idempotent overwrite
563
+ git notes --ref=super-design add -f -m "$(jq -c '{audited_at, schema_version, sha: .git_sha_at_audit}' \
564
+ docs/super-design/.audit-state.json)" HEAD
565
+
566
+ # Push with the notes refspec
567
+ git push origin refs/notes/super-design
568
+
569
+ # Persist auto-fetch of notes for this clone
570
+ git config --add remote.origin.fetch '+refs/notes/super-design:refs/notes/super-design'
571
+ ```
572
+
573
+ Default ref is `refs/notes/commits`; a custom ref (`refs/notes/super-design`) avoids colliding with other tools. **Limitations**: notes are not fetched or pushed by default, conflicts happen when two branches add notes to the same commit (strategies: `manual`, `ours`, `theirs`, `union`, `cat_sort_uniq`), and if the underlying commit is rewritten the note becomes orphaned. See https://git-scm.com/docs/git-notes.
574
+
575
+ **Cross-clone state — commit vs gitignore policy.**
576
+
577
+ | Artifact | Recommended location | Why |
578
+ |---|---|---|
579
+ | `docs/super-design/overview.md` | **Committed** | It's the audit report; users read this in PRs |
580
+ | `docs/super-design/.audit-state.json` | **Committed** | Needed to detect deltas on teammate machines / CI |
581
+ | `docs/super-design/findings/*.md` | **Committed** | Per-issue durable record; enables `PERSISTED`/`RESOLVED` tracking |
582
+ | `docs/super-design/baseline-screenshots/*.png` | Committed, **Git LFS** | Needed for visual regression continuity |
583
+ | `docs/super-design/.cache/screenshots/*.png` (per-run captures) | **.gitignored** | Ephemeral; regeneratable |
584
+ | `docs/super-design/.cache/scratch/*` (agent scratch pads) | **.gitignored** | Ephemeral |
585
+ | `docs/super-design/.cache/lighthouse/*.json` | **.gitignored** (optional commit on CI) | Large and re-runnable |
586
+
587
+ Add to `.gitignore`:
588
+
589
+ ```
590
+ docs/super-design/.cache/
591
+ docs/super-design/baseline-screenshots/*.png.diff
592
+ ```
593
+
594
+ ---
595
+
596
+ ## 7. Detecting new vs modified vs deleted routes
597
+
598
+ The state stores `route_map: string[]`. On each run: rescan the filesystem per §2.7, then diff against the stored map.
599
+
600
+ ```ts
601
+ const prev = new Set(state.route_map);
602
+ const curr = new Set(await discoverRoutes(framework));
603
+
604
+ const added = [...curr].filter(r => !prev.has(r));
605
+ const removed = [...prev].filter(r => !curr.has(r));
606
+ const kept = [...curr].filter(r => prev.has(r));
607
+
608
+ // Modified: kept AND the route source file appears in git diff
609
+ const changedSources = new Set(
610
+ execSync(`git diff --name-only ${lastSha}..HEAD`).toString().split("\n")
611
+ );
612
+ const modified = kept.filter(r => changedSources.has(state.pages_audited.find(p => p.url === r)?.route_file));
613
+ ```
614
+
615
+ **Dynamic routes.** Store with a `@<fixture>` suffix so identity is stable:
616
+
617
+ ```
618
+ /posts/[id]@fixture-post-123
619
+ /users/[[...slug]]@fixture-users-alice
620
+ ```
621
+
622
+ Pick the fixture deterministically: first key of `getStaticParams`/`generateStaticParams`, first row of a seeds file, or an environment-injected `SUPER_DESIGN_FIXTURES` JSON.
623
+
624
+ **Renames** (e.g., `pages/old.tsx` → `pages/new.tsx`) surface as `removed + added` unless `git diff -M90%` is used to detect a rename at the file layer. Prefer rename-aware diffs and treat `R100` renames as "no content change — only rename the URL key in state; keep hashes and findings."
625
+
626
+ **Router-convention migrations** (Pages → App, Remix v1 → v2, Nuxt 3 → 4) are special: the `route_map` shape changes entirely. Detect via `framework.router` mismatch in state vs current; when hit, reset `route_map` and force full re-audit with a "Migration detected" banner.
627
+
628
+ ---
629
+
630
+ ## 8. Component-level change detection
631
+
632
+ **Hash every component source file.** Glob `src/components/**/*.{tsx,jsx,vue,svelte,astro}` (add your project's variants), hash each with a line-ending-normalized xxh3:
633
+
634
+ ```ts
635
+ import { xxh3 } from "@node-rs/xxhash";
636
+ import fg from "fast-glob";
637
+
638
+ const files = await fg([
639
+ "src/components/**/*.{tsx,jsx,ts,js,vue,svelte,astro}",
640
+ "components/**/*.{tsx,jsx,ts,js,vue,svelte,astro}",
641
+ "app/_components/**/*.{tsx,jsx,ts,js}"
642
+ ], { dot: false });
643
+
644
+ const components: Record<string, string> = {};
645
+ for (const f of files) {
646
+ const raw = await fs.readFile(f, "utf8");
647
+ const normalized = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
648
+ components[f] = "xxh3:" + xxh3.xxh128(Buffer.from(normalized)).toString(16);
649
+ }
650
+ ```
651
+
652
+ **Line-ending normalization is mandatory** — CRLF vs LF on Windows/Unix produces spurious diffs. Stripping a trailing BOM and collapsing trailing whitespace makes the hash semantically stable.
653
+
654
+ **Diff against state.** `Object.entries(components)` vs `state.components` yields added / modified / deleted component paths.
655
+
656
+ **Reverse index — which pages import which components.** Build the import graph once per audit.
657
+
658
+ ```bash
659
+ npx madge --json src/ > .cache/import-graph.json
660
+ ```
661
+
662
+ `madge` supports JS/TS/JSX/TSX (and CSS preprocessors) out of the box and reads `.madgerc` or `package.json#madge` for `tsConfig`, `webpackConfig`, `detectiveOptions`. Output is `{ "file.tsx": ["dep1.tsx", "dep2.tsx"], ... }`. See https://github.com/pahen/madge.
663
+
664
+ For higher throughput on large monorepos use `oxc-parser` (Rust, ESTree-compatible, 50–100× faster than Babel — https://oxc.rs/docs/guide/usage/parser.html) or `es-module-lexer` (WASM, import-positions only — https://github.com/guybedford/es-module-lexer) as the scanner backend. When you need JSX/type-info richness (Angular `Routes[]`, `<Route>` JSX extraction), fall back to `@babel/parser` or `@typescript-eslint/parser`.
665
+
666
+ **Propagation.** Build the importer closure up to N hops (default N=3 — stops runaway fanout on utility-component changes, covers 99% of real page impact):
667
+
668
+ ```ts
669
+ function importersOf(graph: Record<string, string[]>, file: string): Set<string> {
670
+ const reverse: Record<string, string[]> = {};
671
+ for (const [src, deps] of Object.entries(graph))
672
+ for (const d of deps) (reverse[d] ||= []).push(src);
673
+
674
+ const out = new Set<string>();
675
+ const queue = [file];
676
+ let depth = 0;
677
+ while (queue.length && depth < 3) {
678
+ const next: string[] = [];
679
+ for (const f of queue)
680
+ for (const imp of (reverse[f] || []))
681
+ if (!out.has(imp)) { out.add(imp); next.push(imp); }
682
+ queue.length = 0; queue.push(...next); depth++;
683
+ }
684
+ return out;
685
+ }
686
+
687
+ // Pages impacted by a changed component are importers that are route files
688
+ const pageImportersOf = (file: string) =>
689
+ [...importersOf(graph, file)].filter(f => isRouteFile(f, framework));
690
+ ```
691
+
692
+ If a component is imported by `layout.tsx`, the closure includes every child page — which is correct, and precisely why layout changes feel "global."
693
+
694
+ ---
695
+
696
+ ## 9. Design token change detection
697
+
698
+ Tokens are global state: a single color or spacing change re-skins every page. Any token mutation is a full re-audit trigger.
699
+
700
+ ### 9.1 Token sources to monitor
701
+
702
+ | Source | Location | Parser |
703
+ |---|---|---|
704
+ | Tailwind v3 config | `tailwind.config.{js,ts,mjs,cjs}` | `ts-morph` + `resolveConfig` |
705
+ | Tailwind v4 `@theme` | any CSS imported by `styles/globals.css` | PostCSS `walkAtRules('theme')` |
706
+ | CSS custom properties | any `:root`, `@layer base`, `[data-theme]` block | PostCSS `walkRules(':root')` |
707
+ | DTCG JSON | `**/*.tokens.json`, `**/*.tokens` | `JSON.parse` |
708
+ | Style Dictionary | `tokens/**/*.json` (user-configured) | `JSON.parse` + resolve aliases |
709
+ | Tokens Studio export | whatever path is configured in `$themes.json` | `JSON.parse` (DTCG-like with extensions) |
710
+
711
+ ### 9.2 Parse → canonicalize → hash
712
+
713
+ Goal: identical semantic tokens produce identical hashes regardless of formatting, key order, or comments.
714
+
715
+ **Tailwind v3** — parse AST, resolve, hash:
716
+
717
+ ```ts
718
+ import resolveConfig from "tailwindcss/resolveConfig";
719
+ import cfg from "../tailwind.config.js";
720
+
721
+ const resolved = resolveConfig(cfg as any);
722
+ const canonical = JSON.stringify(resolved.theme, Object.keys(resolved.theme).sort());
723
+ const hash = createHash("sha256").update(canonical).digest("hex");
724
+ ```
725
+
726
+ Use `ts-morph` if you want to **avoid executing** the config (side effects from plugins). See https://ts-morph.com — walk the default export's object literal, extract `theme`/`theme.extend`, recursively sort keys, stringify, hash.
727
+
728
+ **Tailwind v4 and plain CSS custom properties** — PostCSS sweep, one parser handles both:
729
+
730
+ ```ts
731
+ import postcss from "postcss";
732
+
733
+ function extractCssTokens(cssSource: string) {
734
+ const root = postcss.parse(cssSource);
735
+ const tokens: Record<string, string> = {};
736
+
737
+ root.walkRules(":root", rule => {
738
+ rule.walkDecls(/^--/, d => { tokens[d.prop] = d.value.trim(); });
739
+ });
740
+ root.walkAtRules("theme", at => {
741
+ at.walkDecls(/^--/, d => { tokens[d.prop] = d.value.trim(); });
742
+ });
743
+ return tokens;
744
+ }
745
+ ```
746
+
747
+ **DTCG tokens** — the spec (https://tr.designtokens.org/format/) defines `$value`, `$type` on leaves and supports alias references as `"{color.primary}"`. Resolve aliases before hashing so an alias graph reshuffle doesn't change the hash if the final values match:
748
+
749
+ ```ts
750
+ function resolveAliases(tokens: any, root = tokens): any {
751
+ return JSON.parse(JSON.stringify(tokens, (_, v) => {
752
+ if (typeof v === "string" && /^\{[^}]+\}$/.test(v)) {
753
+ const path = v.slice(1, -1).split(".");
754
+ let node = root;
755
+ for (const p of path) node = node?.[p];
756
+ return node?.$value ?? v;
757
+ }
758
+ return v;
759
+ }));
760
+ }
761
+ ```
762
+
763
+ Token types per the current editors draft: `color`, `dimension`, `fontFamily`, `fontWeight`, `duration`, `cubicBezier`, `number`, and composite types `shadow`, `gradient`, `typography`, `strokeStyle`, `border`, `transition`.
764
+
765
+ ### 9.3 Surfacing the specific change
766
+
767
+ A bare "tokens changed" verdict isn't enough for a good audit narrative. Diff the two token maps:
768
+
769
+ ```ts
770
+ function diffTokens(before: Record<string, string>, after: Record<string, string>) {
771
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
772
+ const added: string[] = [];
773
+ const removed: string[] = [];
774
+ const modified: Array<[string, string, string]> = [];
775
+ for (const k of keys) {
776
+ if (!(k in before)) added.push(k);
777
+ else if (!(k in after)) removed.push(k);
778
+ else if (before[k] !== after[k]) modified.push([k, before[k], after[k]]);
779
+ }
780
+ return { added, removed, modified };
781
+ }
782
+ ```
783
+
784
+ The report's changelog section then says "**`--color-primary`** changed from `#0066ff` to `#6600ff`" rather than a meaningless "tailwind.config.ts was modified."
785
+
786
+ ---
787
+
788
+ ## 10. User-triggered full re-audit
789
+
790
+ The user's entry command supports explicit override:
791
+
792
+ ```
793
+ /super-design # normal (incremental if state valid)
794
+ /super-design --force-full # ignore state, full re-audit
795
+ /super-design --refresh-research # partial: rerun only research agent
796
+ /super-design --scope=/posts/[id] # manual scope override
797
+ /super-design --dry-run # print what would be audited; write nothing
798
+ ```
799
+
800
+ **Automatic full-audit triggers** (any one):
801
+
802
+ | Trigger | Source |
803
+ |---|---|
804
+ | State file missing or corrupt | §1 presence check |
805
+ | `schema_version` major older than current | Skill startup |
806
+ | `--force-full` passed | User flag |
807
+ | `theory_doc_sha` mismatch | §9.1 |
808
+ | `skill_version` major bump | Compare package.json vs state |
809
+ | `last_audit_at` > 180 days | Staleness override of the 90-day soft threshold |
810
+ | Framework migration detected | §7 |
811
+ | Token source changed | §9 |
812
+
813
+ At every full audit, the skill prepends a banner to `overview.md` explaining why: "Full re-audit triggered by: theory doc updated (sha f2a7 → 8c91)." This makes the behavior inspectable and builds user trust.
814
+
815
+ ---
816
+
817
+ ## 11. Reporting incremental audits
818
+
819
+ Transparency is the single most valuable UX property of this skill. Every `overview.md` rewrite leads with a delta summary.
820
+
821
+ **Top-of-file changelog section:**
822
+
823
+ ```markdown
824
+ # Design audit overview
825
+
826
+ > **Incremental audit — 2026-04-19 14:22 UTC**
827
+ > Since last audit on 2026-04-01 (`a1b2c3d`):
828
+ > - 4 commits, 12 files changed
829
+ > - 2 components modified: `Button.tsx`, `Card.tsx`
830
+ > - 1 new route: `/dashboard/settings`
831
+ > - Tokens unchanged; research unchanged (refreshed 2026-04-01)
832
+ > - Re-audited: 3 pages (home, pricing, dashboard/settings)
833
+ > - Skipped: 17 pages (unchanged per HTML+DOM hashes)
834
+ ```
835
+
836
+ **Per-finding provenance.** Every finding carries an explicit status tag:
837
+
838
+ ```markdown
839
+ ## F-014 · Insufficient color contrast on primary button · **PERSISTED** (since 2026-04-01)
840
+ ## F-029 · Missing aria-label on icon button · **NEW** (this audit)
841
+ ## F-022 · Layout shift on pricing table ~~**RESOLVED**~~ (since last audit — commit b8c4e2d)
842
+ ```
843
+
844
+ Persistence model: each finding has a stable `id` (content-addressed hash of rule + selector + URL). On re-audit, the skill intersects the new findings set with the prior set:
845
+
846
+ - `new` = in current ∖ prior
847
+ - `persisted` = in current ∩ prior (preserve `first_seen_at`)
848
+ - `resolved` = in prior ∖ current (don't delete; move to "Resolved" section)
849
+
850
+ **Separate append-only history** at `/docs/super-design/audit-history.md`:
851
+
852
+ ```markdown
853
+ ## 2026-04-19 · a1b2c3d
854
+ - 2 components changed, 1 route added
855
+ - +2 blockers, -1 high, -3 nitpicks
856
+ - Full re-audit? No
857
+
858
+ ## 2026-04-01 · 9f8e7d6
859
+ - Initial audit
860
+ - 2 blockers, 7 high, 15 medium, 22 nitpicks
861
+ - Full re-audit? Yes (first run)
862
+ ```
863
+
864
+ ---
865
+
866
+ ## 12. Performance optimizations
867
+
868
+ **Skip the browser entirely when possible.** The single biggest speedup: if git diff shows zero design-relevant changes and token hashes match, don't launch Playwright at all. Report "No design-relevant changes since <date> (<sha>)" and exit in under a second.
869
+
870
+ **Use xxh3 for cache keys.** `@node-rs/xxhash` for native speed or `xxhash-wasm` for portable. 10–30× faster than SHA-256 for the per-file hashing loop (https://xxhash.com/). Reserve SHA-256 for cross-system integrity anchors (state file entries that are compared across machines) and use xxh3 for hot-path internal caches.
871
+
872
+ **Parallel hashing** with a bounded worker pool (`p-limit` or `Promise.all` with concurrency control — default concurrency = `os.cpus().length`). Component hashing a 500-file codebase drops from ~400 ms serial to ~50 ms at concurrency 8.
873
+
874
+ **Screenshot cache by hash.** Store screenshots under `.cache/screenshots/<sha256-of-url+viewport+dom_structure_hash>.png`. When the DOM structure hash matches the stored one, short-circuit capture and reuse the cached PNG.
875
+
876
+ **Lazy research.** Skip the research agent entirely when:
877
+ - `market-analysis.md` exists, and
878
+ - `package.json`, `README.md`, and `references/market-analysis.md` haven't changed since `research_at`, and
879
+ - `research_at` is within 90 days.
880
+
881
+ **`odiff` over `pixelmatch` for diff throughput.** Author-reported ~6× speedup on typical screenshots; important when diffing 20+ pages × 3 viewports = 60 comparisons per audit.
882
+
883
+ **Shallow-clone guardrail.** If `git rev-parse --is-shallow-repository` is `true`, skip long-history queries and fall back to filesystem-only hashes — don't spend two minutes fetching unshallowed history when you'll re-audit everything anyway.
884
+
885
+ ---
886
+
887
+ ## 13. Edge cases and failure modes
888
+
889
+ The skill must handle every row of this table gracefully.
890
+
891
+ | Edge case | Detection | Response |
892
+ |---|---|---|
893
+ | State exists, overview missing | fs stat | Rebuild overview from `findings/` using stored hashes; no re-audit |
894
+ | Overview exists, state corrupt | JSON parse / Zod validation fails | Rename to `.corrupt-<ts>`, do full audit, warn user |
895
+ | Git history rewritten, SHA gone | `git rev-parse --verify` fails | Recovery ladder §6; fall back to `--since=last_audit_at`, then full audit |
896
+ | Pages→App migration | `framework.router` changed; `app/page.tsx` exists and state says `pages` | Reset `route_map` + `pages_audited`, full audit, banner explains |
897
+ | Repo is shallow clone | `git rev-parse --is-shallow-repository` = true | Either `git fetch --unshallow` if CI permits, or force full audit with warning |
898
+ | No git at all | `git rev-parse --git-dir` exits non-zero | Fall back to filesystem mtime-based heuristic: re-audit files with `stat.mtime > state.last_audit_at` |
899
+ | Detached HEAD | `git symbolic-ref --quiet HEAD` exits 1 | Audit normally using commit SHA; leave `git_branch: null` in state |
900
+ | Empty repo (no commits) | `git rev-list -n1 --all` empty | Diff against empty tree SHA `4b825dc642cb6eb9a060e54bf8d69288fbee4904` (SHA-1 repos); first audit |
901
+ | Fresh clone, no local screenshot cache | `.cache/screenshots/` absent | Regenerate screenshots only for pages whose hash changed or are new; old pages reuse `baseline-screenshots/` |
902
+ | Monorepo with multiple apps | Multiple `package.json` files with distinct `name` under `apps/*` or `packages/*` | **Prefer state per app**: `apps/web/docs/super-design/.audit-state.json`, `apps/admin/docs/super-design/.audit-state.json`. Unified state only if apps share tokens and components; in that case keep one state at root with an `apps: { "web": {...}, "admin": {...} }` sub-key |
903
+ | Very large route count (>500) | `route_map.length > 500` | Switch to directory-of-per-page-JSON schema (§5 alt); parallelize audits in batches |
904
+ | Race condition: state written during audit | N/A in a single-process skill, but a concurrent run could corrupt | Write-then-rename (atomic): `.audit-state.json.tmp` → `.audit-state.json` |
905
+ | Playwright fails to launch (no browsers) | Exception | `npx playwright install chromium`; if still failing, degrade to static audit (no screenshots, no Lighthouse) and flag in overview |
906
+ | Lighthouse tool version differs majorly from state | semver compare | Full re-audit; update `tools.lighthouse`; note in changelog |
907
+
908
+ ---
909
+
910
+ ## 14. Orchestrator entry flow pseudocode
911
+
912
+ ```python
913
+ def super_design_entry(flags, cwd):
914
+ skill = load_skill_metadata()
915
+ state_path = f"{cwd}/docs/super-design/.audit-state.json"
916
+ overview_path = f"{cwd}/docs/super-design/overview.md"
917
+
918
+ # ── 0. Preflight ───────────────────────────────────────────────
919
+ has_git = run_ok("git rev-parse --git-dir")
920
+ is_shallow = has_git and run("git rev-parse --is-shallow-repository") == "true"
921
+ framework, router, fw_version = detect_framework(cwd)
922
+
923
+ # ── 1. Presence and schema validation ─────────────────────────
924
+ if flags.force_full or not exists(state_path):
925
+ return full_audit(reason="missing-state" if not exists(state_path) else "--force-full")
926
+
927
+ try:
928
+ state = validate_state(read_json(state_path))
929
+ except StateCorrupt as e:
930
+ move(state_path, f"{state_path}.corrupt-{now_iso()}")
931
+ log_warn(f"State file corrupt: {e}. Treating as first audit.")
932
+ return full_audit(reason="corrupt-state")
933
+
934
+ if semver_major(state.schema_version) < CURRENT_SCHEMA_MAJOR:
935
+ return full_audit(reason="schema-bump")
936
+
937
+ # ── 2. Invalidation checks (§1 + §10) ─────────────────────────
938
+ now = utcnow()
939
+ age_days = (now - parse_iso(state.last_audit_at)).days
940
+
941
+ if age_days > 180:
942
+ return full_audit(reason="stale-180-days")
943
+
944
+ theory_sha_now = sha256_of("references/design-theory.md")
945
+ if theory_sha_now != state.theory_doc_sha:
946
+ return full_audit(reason="theory-doc-changed")
947
+
948
+ current_tools = probe_tool_versions() # axe-core, lighthouse, playwright
949
+ if any(semver_major(current_tools[t]) != semver_major(state.tools.get(t, "0.0.0"))
950
+ for t in current_tools):
951
+ return full_audit(reason="tool-major-bump")
952
+
953
+ if router != state.framework.router or framework != state.framework.name:
954
+ return full_audit(reason="framework-migration",
955
+ note=f"{state.framework.name}/{state.framework.router} → {framework}/{router}")
956
+
957
+ # ── 3. Git range resolution (§2, §6) ───────────────────────────
958
+ last_sha = state.git_sha_at_audit
959
+ range_start = resolve_range_start(last_sha) # §6
960
+
961
+ if range_start == "__MISSING__":
962
+ if is_shallow and ci_allows_unshallow():
963
+ run("git fetch --unshallow")
964
+ range_start = resolve_range_start(last_sha)
965
+ if range_start == "__MISSING__":
966
+ since = state.last_audit_at
967
+ commits = git_log_since(since)
968
+ if not commits:
969
+ return no_op(reason="no-commits-since-last-audit-by-time")
970
+ log_warn(f"Lost anchor SHA {last_sha}; falling back to --since={since}")
971
+ changed_files = git_diff_since_time(since)
972
+ else:
973
+ changed_files = git_diff_range(range_start)
974
+ else:
975
+ changed_files = git_diff_range(range_start)
976
+
977
+ # ── 4. Classify changes (§2.4) ─────────────────────────────────
978
+ changes = classify(changed_files)
979
+ # {tokens, components, routes, imagery, deps, content, theory, ignored}
980
+
981
+ # ── 5. Token diff — global signal (§9) ─────────────────────────
982
+ token_hash_now = compute_token_hash(framework, cwd)
983
+ token_hash_prev = state.get("token_hash")
984
+ if token_hash_now != token_hash_prev:
985
+ return full_audit(reason="tokens-changed",
986
+ token_diff=diff_tokens(load_tokens_prev(), load_tokens_now()))
987
+
988
+ # ── 6. Dep major bump ──────────────────────────────────────────
989
+ if any_major_dep_bump(changes.deps, state.framework):
990
+ return full_audit(reason="framework-major-bump")
991
+
992
+ # ── 7. Component hashing + import graph (§8) ───────────────────
993
+ components_now = hash_components(cwd)
994
+ component_diff = diff_maps(state.components or {}, components_now)
995
+ import_graph = load_or_build_import_graph(cwd)
996
+
997
+ impacted_pages = set()
998
+ for ch in component_diff.modified + component_diff.removed + component_diff.added:
999
+ impacted_pages |= page_importers_of(import_graph, ch, max_hops=3)
1000
+
1001
+ # ── 8. Route diff (§7) ─────────────────────────────────────────
1002
+ route_map_now = discover_routes(framework, cwd)
1003
+ route_diff = diff_sets(set(state.route_map), set(route_map_now))
1004
+ impacted_pages |= set(route_diff.added)
1005
+ for url in route_diff.modified:
1006
+ impacted_pages.add(url)
1007
+
1008
+ # ── 9. Content/imagery only changes ────────────────────────────
1009
+ impacted_pages |= pages_touched_by_content_files(changes.content, import_graph)
1010
+ impacted_pages |= pages_touched_by_imagery(changes.imagery)
1011
+
1012
+ # ── 10. If nothing impacted, exit cleanly ──────────────────────
1013
+ if (not impacted_pages
1014
+ and not component_diff.any()
1015
+ and not route_diff.any()
1016
+ and not changes.deps
1017
+ and age_days <= 90):
1018
+ append_history(no_op=True, last_audit_at=state.last_audit_at)
1019
+ return no_op(reason="no-design-relevant-changes")
1020
+
1021
+ # ── 11. Decide agent set ───────────────────────────────────────
1022
+ agents = set()
1023
+ if impacted_pages:
1024
+ agents.update({"a11y", "perf", "heuristic", "imagery"})
1025
+ if changes.deps:
1026
+ agents.update({"a11y", "perf"})
1027
+ research_stale = (age_days > 90 or
1028
+ state.market_analysis_sha != sha256_of("references/market-analysis.md") or
1029
+ flags.refresh_research)
1030
+ if research_stale:
1031
+ agents.add("research")
1032
+
1033
+ # ── 12. Run incremental audit ──────────────────────────────────
1034
+ new_findings = incremental_audit(
1035
+ pages=impacted_pages,
1036
+ agents=agents,
1037
+ cache_dir=f"{cwd}/docs/super-design/.cache",
1038
+ prev_state=state
1039
+ )
1040
+
1041
+ # ── 13. Merge findings with prior (§11 provenance) ─────────────
1042
+ merged = merge_findings(state_findings=load_findings(state),
1043
+ new_findings=new_findings,
1044
+ touched_pages=impacted_pages)
1045
+
1046
+ # ── 14. Write outputs ──────────────────────────────────────────
1047
+ write_findings(merged)
1048
+ write_overview_with_changelog(
1049
+ impacted_pages=impacted_pages,
1050
+ component_diff=component_diff,
1051
+ route_diff=route_diff,
1052
+ token_diff=None,
1053
+ range_start=range_start,
1054
+ last_sha=last_sha
1055
+ )
1056
+ append_history(diff_summary=summary(impacted_pages, component_diff, route_diff))
1057
+
1058
+ # ── 15. Persist new state atomically ───────────────────────────
1059
+ new_state = build_state(
1060
+ prev=state, framework=framework, router=router,
1061
+ components=components_now, route_map=route_map_now,
1062
+ pages=page_hashes_now, token_hash=token_hash_now,
1063
+ research_at=now if research_stale else state.research_at
1064
+ )
1065
+ atomic_write_json(state_path, new_state)
1066
+
1067
+ # ── 16. Git notes anchor (§6) ──────────────────────────────────
1068
+ try:
1069
+ run(f'git notes --ref=super-design add -f -m {json_note(new_state)!r} HEAD')
1070
+ except Exception as e:
1071
+ log_warn(f"git notes write failed ({e}); continuing without notes anchor")
1072
+
1073
+ return success(impacted_pages=impacted_pages, agents=agents)
1074
+ ```
1075
+
1076
+ Key error-handling properties: every `run(...)` is wrapped to surface non-zero exits; any unexpected failure **degrades** rather than crashes (e.g., if `madge` fails, skip component-impact propagation and audit every page as a conservative fallback); every full-audit path records its `reason` in the changelog so the user can see why.
1077
+
1078
+ ---
1079
+
1080
+ ## 15. Helper scripts
1081
+
1082
+ All scripts shipped at `skills/super-design/scripts/`. Use `set -euo pipefail` uniformly; trap ERR for friendly diagnostics.
1083
+
1084
+ ### 15.1 `scripts/detect-changes.sh`
1085
+
1086
+ ```bash
1087
+ #!/usr/bin/env bash
1088
+ # Usage: detect-changes.sh <last_sha> [<state_last_iso>]
1089
+ # Emits JSON: { range_start, commits, changed_files: {tokens,components,routes,content,imagery,deps,ignored} }
1090
+ set -euo pipefail
1091
+
1092
+ LAST_SHA="${1:-}"
1093
+ LAST_ISO="${2:-}"
1094
+
1095
+ if [[ -z "$LAST_SHA" ]]; then
1096
+ echo '{"error":"missing last_sha"}'; exit 2
1097
+ fi
1098
+
1099
+ if ! git rev-parse --git-dir >/dev/null 2>&1; then
1100
+ echo '{"error":"not-a-git-repo"}'; exit 3
1101
+ fi
1102
+
1103
+ # Resolve range start
1104
+ if git rev-parse --verify --quiet "${LAST_SHA}^{commit}" >/dev/null; then
1105
+ if git merge-base --is-ancestor "$LAST_SHA" HEAD; then
1106
+ RANGE_START="$LAST_SHA"
1107
+ else
1108
+ RANGE_START="$(git merge-base HEAD "$LAST_SHA" 2>/dev/null || echo "")"
1109
+ fi
1110
+ else
1111
+ RANGE_START=""
1112
+ fi
1113
+
1114
+ if [[ -z "$RANGE_START" ]]; then
1115
+ if [[ -n "$LAST_ISO" ]]; then
1116
+ FILES="$(git log --since="$LAST_ISO" --name-only --pretty=format: 2>/dev/null | sort -u | sed '/^$/d' || true)"
1117
+ COMMITS="$(git log --since="$LAST_ISO" --pretty=format:'%H|%s|%an|%aI' 2>/dev/null || true)"
1118
+ MODE="since-time"
1119
+ else
1120
+ echo '{"error":"lost-anchor-no-fallback-time"}'; exit 4
1121
+ fi
1122
+ else
1123
+ FILES="$(git diff --name-only "${RANGE_START}..HEAD" \
1124
+ -- ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!yarn.lock' \
1125
+ ':!.github/**' ':!**/*.test.*' ':!**/*.spec.*' ':!**/*.stories.*' \
1126
+ | sort -u)"
1127
+ COMMITS="$(git log --no-merges --pretty=format:'%H|%s|%an|%aI' "${RANGE_START}..HEAD" 2>/dev/null || true)"
1128
+ MODE="sha-range"
1129
+ fi
1130
+
1131
+ # Classify
1132
+ classify() {
1133
+ local p="$1"
1134
+ case "$p" in
1135
+ tailwind.config.*|*.tokens.json|styles/tokens.css|styles/theme.css) echo tokens ;;
1136
+ components/*|src/components/*|app/_components/*) echo components ;;
1137
+ app/*/page.*|app/page.*|app/*/route.*|app/route.*|pages/*|src/pages/*|app/routes/*|src/routes/*) echo routes ;;
1138
+ public/*|src/assets/*|assets/*) echo imagery ;;
1139
+ package.json) echo deps ;;
1140
+ references/design-theory.md|references/market-analysis.md) echo theory ;;
1141
+ *.md|*.mdx) echo content ;;
1142
+ *) echo other ;;
1143
+ esac
1144
+ }
1145
+
1146
+ jq -Rn --arg mode "$MODE" --arg range_start "$RANGE_START" --arg last_iso "$LAST_ISO" \
1147
+ --argjson files "$(printf '%s\n' "$FILES" | jq -R . | jq -s .)" \
1148
+ --argjson commits "$(printf '%s\n' "$COMMITS" | jq -R . | jq -s .)" '
1149
+ {mode:$mode, range_start:$range_start, since_iso:$last_iso,
1150
+ commits:$commits, files:$files,
1151
+ classified: ($files | map({(.): "_"}) | add // {})
1152
+ }'
1153
+ ```
1154
+
1155
+ Example output (truncated):
1156
+
1157
+ ```json
1158
+ {
1159
+ "mode": "sha-range",
1160
+ "range_start": "a1b2c3d4...",
1161
+ "commits": ["9f8e7d6c...|Bump deps|Bot|2026-04-17T02:00:00+00:00", "..."],
1162
+ "files": ["src/components/ui/Button.tsx", "tailwind.config.ts", "app/about/page.tsx"]
1163
+ }
1164
+ ```
1165
+
1166
+ ### 15.2 `scripts/hash-pages.sh`
1167
+
1168
+ ```bash
1169
+ #!/usr/bin/env bash
1170
+ # Usage: hash-pages.sh <urls_file>
1171
+ # Reads one URL per line; uses Node + Playwright to capture HTML, DOM-structure, screenshot hashes.
1172
+ set -euo pipefail
1173
+
1174
+ URLS="$1"
1175
+ OUT_DIR="${OUT_DIR:-docs/super-design/.cache/hashes}"
1176
+ mkdir -p "$OUT_DIR"
1177
+
1178
+ node --experimental-vm-modules <<'JS'
1179
+ import { chromium } from "playwright";
1180
+ import { createHash } from "node:crypto";
1181
+ import { readFileSync, writeFileSync } from "node:fs";
1182
+
1183
+ const urls = readFileSync(process.env.URLS || process.argv[2], "utf8")
1184
+ .split("\n").map(s => s.trim()).filter(Boolean);
1185
+
1186
+ const browser = await chromium.launch();
1187
+ const ctx = await browser.newContext({
1188
+ viewport: { width: 1280, height: 800 },
1189
+ reducedMotion: "reduce",
1190
+ deviceScaleFactor: 1,
1191
+ });
1192
+ const page = await ctx.newPage();
1193
+ const sha = s => createHash("sha256").update(s).digest("hex");
1194
+
1195
+ const results = [];
1196
+ for (const url of urls) {
1197
+ await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
1198
+ const html = (await page.content()).replace(/\s+/g, " ").trim();
1199
+ const dom = await page.evaluate(() => {
1200
+ const V = new Set(["nonce","data-timestamp","data-reactid"]);
1201
+ const walk = n => n.nodeType !== 1 ? "" :
1202
+ `<${n.tagName.toLowerCase()}[${
1203
+ [...n.attributes].filter(a=>!V.has(a.name))
1204
+ .map(a=>`${a.name}=${a.value}`).sort().join(",")
1205
+ }]${[...n.childNodes].map(walk).join("")}>`;
1206
+ return walk(document.documentElement);
1207
+ });
1208
+ const buf = await page.screenshot({ fullPage: true, animations: "disabled", caret: "hide" });
1209
+ results.push({
1210
+ url,
1211
+ html_hash: "sha256:" + sha(html),
1212
+ dom_structure_hash: "sha256:" + sha(dom),
1213
+ screenshot_hash: "sha256:" + sha(buf),
1214
+ });
1215
+ }
1216
+ writeFileSync(process.env.OUT_DIR + "/hashes.json", JSON.stringify(results, null, 2));
1217
+ await browser.close();
1218
+ JS
1219
+ ```
1220
+
1221
+ Example output file:
1222
+
1223
+ ```json
1224
+ [
1225
+ { "url":"/", "html_hash":"sha256:b1c2...", "dom_structure_hash":"sha256:4d5e...",
1226
+ "screenshot_hash":"sha256:77ab..." }
1227
+ ]
1228
+ ```
1229
+
1230
+ ### 15.3 `scripts/diff-tokens.sh`
1231
+
1232
+ ```bash
1233
+ #!/usr/bin/env bash
1234
+ # Usage: diff-tokens.sh <previous_tokens.json> <current_tokens.json>
1235
+ # Emits a JSON diff: { added, removed, modified: [{key, before, after}] }
1236
+ set -euo pipefail
1237
+
1238
+ PREV="$1"; CURR="$2"
1239
+
1240
+ jq -n --slurpfile a "$PREV" --slurpfile b "$CURR" '
1241
+ ($a[0] // {}) as $before |
1242
+ ($b[0] // {}) as $after |
1243
+ {
1244
+ added: [$after | to_entries[] | select(.key as $k | ($before | has($k)) | not) | .key],
1245
+ removed: [$before | to_entries[] | select(.key as $k | ($after | has($k)) | not) | .key],
1246
+ modified: [
1247
+ $after | to_entries[] |
1248
+ select(.key as $k | ($before | has($k)) and (.value != $before[$k])) |
1249
+ { key: .key, before: $before[.key], after: .value }
1250
+ ]
1251
+ }
1252
+ '
1253
+ ```
1254
+
1255
+ Prior step: produce `previous_tokens.json` / `current_tokens.json` by invoking a Node script that walks `tailwind.config.*` via `ts-morph` + `resolveConfig` and `@theme` blocks via PostCSS (§9.2). Ship that as a companion `scripts/extract-tokens.mjs`.
1256
+
1257
+ ### 15.4 `scripts/discover-routes.sh`
1258
+
1259
+ ```bash
1260
+ #!/usr/bin/env bash
1261
+ # Usage: discover-routes.sh
1262
+ # Prints a JSON array of detected route URLs based on framework conventions.
1263
+ set -euo pipefail
1264
+
1265
+ detect_framework() {
1266
+ if [[ -f next.config.js || -f next.config.ts || -f next.config.mjs ]]; then echo "next"
1267
+ elif [[ -f remix.config.js || -d app/routes ]]; then echo "remix"
1268
+ elif [[ -f svelte.config.js && -d src/routes ]]; then echo "sveltekit"
1269
+ elif [[ -f astro.config.mjs || -f astro.config.ts ]]; then echo "astro"
1270
+ elif [[ -f nuxt.config.ts || -f nuxt.config.js ]]; then echo "nuxt"
1271
+ elif [[ -f app.config.ts && -d src/routes ]]; then echo "solid-start"
1272
+ elif [[ -f gatsby-config.js || -f gatsby-config.ts ]]; then echo "gatsby"
1273
+ elif [[ -f angular.json ]]; then echo "angular"
1274
+ else echo "unknown"
1275
+ fi
1276
+ }
1277
+
1278
+ FW="$(detect_framework)"
1279
+ case "$FW" in
1280
+ next)
1281
+ # App router: folders containing page.{ext} or route.{ext}
1282
+ APP_ROUTES="$(find app src/app -type f \( -name 'page.tsx' -o -name 'page.ts' \
1283
+ -o -name 'page.jsx' -o -name 'page.js' -o -name 'page.md' -o -name 'page.mdx' \
1284
+ -o -name 'route.ts' -o -name 'route.js' \) 2>/dev/null \
1285
+ | sed -E 's|(^|/)(app|src/app)/||; s|/page\.[a-z]+$||; s|/route\.[a-z]+$||; s|^$|/|' \
1286
+ | sed -E 's|\([^)]+\)/||g; /(^|\/)_/d' \
1287
+ | sort -u || true)"
1288
+ # Pages router
1289
+ PG_ROUTES="$(find pages src/pages -type f \( -name '*.tsx' -o -name '*.ts' \
1290
+ -o -name '*.jsx' -o -name '*.js' -o -name '*.md' -o -name '*.mdx' \) 2>/dev/null \
1291
+ | grep -vE '(^|/)(pages|src/pages)/(_app|_document|_error|404|500|api/)' \
1292
+ | sed -E 's|(^|/)(pages|src/pages)/||; s|\.(tsx|ts|jsx|js|md|mdx)$||; s|index$||' \
1293
+ | sort -u || true)"
1294
+ printf '%s\n%s\n' "$APP_ROUTES" "$PG_ROUTES" | awk 'NF' | jq -Rn '[inputs | sub("^"; "/")]' ;;
1295
+
1296
+ sveltekit)
1297
+ find src/routes -type f -name '+page.svelte' 2>/dev/null \
1298
+ | sed -E 's|^src/routes||; s|/\+page\.svelte$||; s|\([^)]+\)/||g' \
1299
+ | awk 'NF==0 {print "/"; next} {print}' | sort -u | jq -Rn '[inputs]' ;;
1300
+
1301
+ astro)
1302
+ find src/pages -type f \( -name '*.astro' -o -name '*.md' -o -name '*.mdx' \) 2>/dev/null \
1303
+ | sed -E 's|^src/pages||; s|\.(astro|md|mdx)$||; s|/index$||' | sort -u | jq -Rn '[inputs]' ;;
1304
+
1305
+ nuxt)
1306
+ find pages app/pages -type f -name '*.vue' 2>/dev/null \
1307
+ | sed -E 's|^(app/)?pages||; s|\.vue$||; s|/index$||' | sort -u | jq -Rn '[inputs]' ;;
1308
+
1309
+ remix)
1310
+ find app/routes -type f \( -name '*.tsx' -o -name '*.ts' -o -name '*.jsx' -o -name '*.js' \
1311
+ -o -name '*.md' -o -name '*.mdx' \) 2>/dev/null \
1312
+ | sed -E 's|^app/routes/||; s|\.(tsx|ts|jsx|js|md|mdx)$||; s|\.|/|g; s|_index$||; s|^_|/|; s|^|/|' \
1313
+ | sort -u | jq -Rn '[inputs]' ;;
1314
+
1315
+ solid-start)
1316
+ find src/routes -type f \( -name '*.tsx' -o -name '*.ts' -o -name '*.jsx' -o -name '*.js' \) 2>/dev/null \
1317
+ | sed -E 's|^src/routes||; s|\.(tsx|ts|jsx|js)$||; s|/index$||; s|\([^)]+\)/||g' \
1318
+ | sort -u | jq -Rn '[inputs]' ;;
1319
+
1320
+ *) echo '[]' ;;
1321
+ esac
1322
+ ```
1323
+
1324
+ Example output for a Next.js App Router app:
1325
+
1326
+ ```json
1327
+ ["/", "/about", "/posts/[id]", "/dashboard", "/dashboard/settings"]
1328
+ ```
1329
+
1330
+ ### 15.5 `scripts/validate-state.sh`
1331
+
1332
+ ```bash
1333
+ #!/usr/bin/env bash
1334
+ # Usage: validate-state.sh [path]
1335
+ # Exits 0 if valid and fresh, 1 if stale, 2 if missing/corrupt.
1336
+ set -euo pipefail
1337
+
1338
+ STATE="${1:-docs/super-design/.audit-state.json}"
1339
+
1340
+ if [[ ! -f "$STATE" ]]; then
1341
+ echo '{"status":"missing"}'; exit 2
1342
+ fi
1343
+
1344
+ jq -e '
1345
+ (.schema_version | type == "string") and
1346
+ (.last_audit_at | fromdateiso8601 | . > 0) and
1347
+ (.git_sha_at_audit | test("^[0-9a-f]{7,64}$")) and
1348
+ (.skill_version | type == "string") and
1349
+ (.tools | type == "object")
1350
+ ' "$STATE" >/dev/null 2>&1 || { echo '{"status":"corrupt"}'; exit 2; }
1351
+
1352
+ AGE_DAYS=$(( ( $(date -u +%s) - $(jq -r '.last_audit_at | fromdateiso8601' "$STATE") ) / 86400 ))
1353
+ if (( AGE_DAYS > 180 )); then echo "{\"status\":\"stale-force-full\",\"age_days\":$AGE_DAYS}"; exit 1
1354
+ elif (( AGE_DAYS > 90 )); then echo "{\"status\":\"stale-refresh-research\",\"age_days\":$AGE_DAYS}"; exit 1
1355
+ else echo "{\"status\":\"fresh\",\"age_days\":$AGE_DAYS}"; exit 0
1356
+ fi
1357
+ ```
1358
+
1359
+ ---
1360
+
1361
+ ## 16. Bonus — visual regression integration
1362
+
1363
+ When the user opts in, super-design doubles as a visual regression tool. The baselines are committed under `docs/super-design/baseline-screenshots/` with Git LFS (PNGs are large, binary, and would bloat pack files). Config extends the state file:
1364
+
1365
+ ```jsonc
1366
+ {
1367
+ "visual_regression": {
1368
+ "enabled": true,
1369
+ "engine": "odiff", // "pixelmatch" | "odiff" | "resemble" | "looks-same"
1370
+ "threshold": 0.1, // per-pixel YIQ (pixelmatch/odiff) or ΔE (looks-same uses 2.3 default)
1371
+ "max_diff_pixel_ratio": 0.01, // 1% of pixels allowed
1372
+ "antialiasing": true,
1373
+ "viewports": [
1374
+ { "label": "mobile", "width": 375, "height": 812 },
1375
+ { "label": "tablet", "width": 768, "height": 1024 },
1376
+ { "label": "desktop", "width": 1280, "height": 800 }
1377
+ ],
1378
+ "mask_selectors": [
1379
+ ".timestamp", "[data-dynamic]", ".avatar",
1380
+ ".ad-banner", "[data-testid=session-id]"
1381
+ ],
1382
+ "docker_image": "mcr.microsoft.com/playwright:v1.59.1-jammy"
1383
+ }
1384
+ }
1385
+ ```
1386
+
1387
+ **Baseline workflow.**
1388
+
1389
+ 1. `super-design --update-baselines` — capture new screenshots, save to `baseline-screenshots/<url>-<viewport>.png`, commit via LFS.
1390
+ 2. `super-design` — capture current screenshots to `.cache/screenshots/`, diff against baselines using the configured engine.
1391
+ 3. Findings with `visual-regression` rule ID list diffing pages with above-threshold pixel diffs; the diff PNG is stored at `.cache/screenshots/<url>-<viewport>.diff.png` and linked from `overview.md`.
1392
+
1393
+ **CI-friendly exit codes.**
1394
+
1395
+ ```bash
1396
+ super-design --ci \
1397
+ && echo "OK" \
1398
+ || { echo "Visual diffs exceed threshold"; exit 1; }
1399
+ ```
1400
+
1401
+ In `--ci` mode the skill writes a JUnit XML report and exits non-zero when any visual regression exceeds `max_diff_pixel_ratio`.
1402
+
1403
+ **Masking implementation** — Playwright's `mask` option is the right primitive. Each locator in `mask_selectors` is converted to `page.locator(sel)` and passed to `page.screenshot({ mask: [...] })`; Playwright overlays a solid-color box (default `#FF00FF`, configurable via `maskColor`) over each matched element before snapshotting. Prefer a neutral mask color (black or white depending on theme) so the masked area doesn't dominate the diff output.
1404
+
1405
+ **Threshold guidance by engine.**
1406
+
1407
+ | Engine | Default threshold | Tighter for UI-heavy sites | Looser for text-heavy sites |
1408
+ |---|---|---|---|
1409
+ | pixelmatch | `threshold: 0.1` | `0.05` | `0.15` |
1410
+ | odiff | `--threshold=0.1` | `0.05` | `0.15` |
1411
+ | Playwright `toHaveScreenshot` | `threshold: 0.2` | `0.1` + `maxDiffPixels: 200` | default + `maxDiffPixelRatio: 0.01` |
1412
+ | looks-same (CIEDE2000 ΔE) | `2.3` | `1.0` | `3.5` |
1413
+ | BackstopJS (Resemble %) | `misMatchThreshold: 0.1` | `0.05` | `0.3` |
1414
+
1415
+ **Known gotchas to document for users.**
1416
+
1417
+ - Resemble silently downsamples images wider than 1200px unless `largeImageThreshold: 0`.
1418
+ - Playwright 1.42.1 didn't honor `maxDiffPixelRatio`; require 1.43+ (confirmed fixed in microsoft/playwright#30112).
1419
+ - Baselines are **per platform + browser**: regenerate when upgrading the Docker image, not on a dev laptop.
1420
+ - `animations: "disabled"` only fast-forwards finite animations — infinite loops reset to initial state and may still flake if the capture lands mid-keyframe; combine with `reducedMotion: "reduce"` and explicit `waitFor` on a content-ready selector.
1421
+
1422
+ ---
1423
+
1424
+ ## Conclusion
1425
+
1426
+ The durable insight: **delta detection for design audits is a cascade of hashes, not a single git query.** Git tells you what files changed; it can't tell you whether the rendered page actually looks different. You need four complementary signals — commit range (what files), token hash (global redraws), component hash + import graph (local blast radius), and page-level HTML/DOM/pixel hashes (reality check) — each answering a different question.
1427
+
1428
+ Three anti-patterns are fatal and bear repeating: **never assume the last SHA still exists** (force-pushes and squash-merges will bite); **never skip token detection** (a single changed CSS variable invalidates every page); and **never trust HTML hashing alone** (CSS-only changes leave HTML byte-identical while repainting the world). The decision tree in §4 and the orchestrator flow in §14 encode these failure modes explicitly, and the fallback ladder in §6 makes them recoverable rather than fatal.
1429
+
1430
+ What this playbook deliberately doesn't solve: cross-device visual consistency (Windows ClearType vs macOS grayscale font rendering is an ecosystem problem — pin a Docker image and move on), and semantic equivalence of design changes (a 1px padding shift is "the same" to a human but a 40k-pixel diff to a machine — perceptual hashing with Hamming distance ≤5 is the closest practical proxy, not a solution). Both are worth revisiting if the skill ever graduates from "audit the delta" to "evaluate the intent."
1431
+
1432
+ The playbook's most underrated leverage point is the **skip path**: an incremental audit that runs in under a second when there are no design-relevant changes teaches users to invoke the skill freely — turning it from a once-a-quarter ritual into a continuous design-quality signal.