fix(design): run the DOM dump in the page on both engines; align doctrine with the catalog

The DOM-dump script is an arrow function, not a self-calling IIFE: Aside's
`pg.evaluate($_DUMP)` receives the function and runs it in the page (the IIFE
form executed in the repl sandbox, where `document` does not exist), and the
fallback engine calls it with `$B js "($_DUMP)()" --out --raw`. Hygiene widens
to every URL-bearing attribute (src, srcset per candidate, poster, action,
formaction, data, ping, cite lose their query strings and fragments) and to
data: URLs inside existing <style> nodes. The persist and scan blocks restate
REPORT_DIR and RUN_ID literally instead of relying on a shell variable from an
earlier block; the baseline's targetSet is defined per mode (repo-relative
paths in source mode, page slugs in DOM mode) so DOM-mode deltas can match; the
PR-body Detector line lists the states the probe can actually print. The DOM
fixture is re-captured with the new script from outside the repo (the engine
walks up from cwd for DESIGN.md, which the metadata now records).

Doctrine contradictions the design specialist found: the landing-page motion
rule matches the one-authored-moment reflex; the background rule names the
catalog's halo/spotlight/stripe/grid slop instead of asking for gradients; the
universal font rule is scoped to the display voice with the body/UI exceptions;
"two typefaces max" allows the mono; the methodology's banned-font line renders
BANNED_FONTS; Courier New is banned outright; the Brutalist, Retro-Futuristic,
and Playful menu entries stop recommending system stacks, glow, and bounce; the
coherence nudge uses the decoration vocabulary; Path A's gate names the display
voice; font-loading prose points at the source the procedure verified;
centered-everything is MEDIUM (an aggregate heuristic); the mockup guard reads
"Never by default (unless the brief above asks for it)". The checklist's
AUTO-FIX list renders the catalog's auto-fix rules; category 9 and the Hard
Rules pointer count from the same partition helpers (detectorSlopEntries,
judgmentTellEntries); the handoff list renders from HANDOFF_COMMANDS; a missing
catalog id fails gen-skill-docs by name. gstack's own DESIGN.md gains border
tokens and Decisions Log rows for its live-feed pulse and 11px mono labels.
frontend-scope is case-sensitive like the bash arm. gen-skill-docs shares one
emitGenerated helper for sections and lib-derived assets; renderCatalog keeps
the one style with a caller. Tests: shared sliceBetween that fails on a missing
end marker, the slop-gate fixture's real end marker, an isolated browse daemon
for the DOM-mode E2E, the DOM hygiene test gated to CI or opt-in, docs notes
for the two superseded plan sentences.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-08 17:31:30 +00:00
co-authored by Claude Fable 5.1
parent b4d88a0126
commit da6f0ff2f6
32 changed files with 413 additions and 219 deletions
+27 -26
View File
@@ -29,12 +29,9 @@ export type Confidence = 'HIGH' | 'MEDIUM' | 'LOW';
export type ReviewTier = 'auto-fix' | 'ask' | 'possible';
export type Impact = 'high' | 'medium' | 'polish';
export type FontRole = 'display' | 'body' | 'ui' | 'mono';
export type Handoff = 'typeset' | 'layout' | 'colorize' | 'harden' | 'clarify' | 'polish' | 'animate' | 'quieter';
/** The `/impeccable <cmd>` commands a deferred finding may hand off to. */
export const HANDOFF_COMMANDS: readonly Handoff[] = [
'typeset', 'layout', 'colorize', 'harden', 'clarify', 'polish', 'animate', 'quieter',
];
/** The `/impeccable <cmd>` commands a deferred finding may hand off to (one source for the type and the prose). */
export const HANDOFF_COMMANDS = ['typeset', 'layout', 'colorize', 'harden', 'clarify', 'polish', 'animate', 'quieter'] as const;
export type Handoff = (typeof HANDOFF_COMMANDS)[number];
export interface DesignSlopEntry {
/** kebab-case; equals impeccableId when the detector knows the rule */
@@ -103,7 +100,7 @@ export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [
{
id: 'centered-everything', name: 'Centered everything',
prose: 'Centered everything (`text-align: center` on all headings, descriptions, cards)',
category: 'layout', kind: 'slop', detect: ['grep', 'llm'], confidence: 'HIGH', tier: 'ask', impact: 'medium',
category: 'layout', kind: 'slop', detect: ['grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
heuristic: 'Grep for `text-align: center` density: if more than 60% of text containers center, flag it.',
handoff: 'layout', source: 'gstack', legacyBlacklist: true,
},
@@ -666,7 +663,7 @@ export const OVERUSED_FONTS_DISPLAY: readonly string[] = OVERUSED_DISPLAY;
/** Never, in any role. */
export const BANNED_FONTS: readonly string[] = [
'Papyrus', 'Comic Sans', 'Lobster', 'Impact', 'Jokerman', 'Bleeding Cowboys', 'Permanent Marker',
'Bradley Hand', 'Brush Script', 'Hobo', 'Trajan', 'Raleway', 'Clash Display', 'Courier New (for body)',
'Bradley Hand', 'Brush Script', 'Hobo', 'Trajan', 'Raleway', 'Clash Display', 'Courier New',
];
/** On the overused list, yet fine as body or UI on an Operate or Read surface when the proposal says so. */
@@ -705,33 +702,37 @@ export function entryForImpeccableId(impeccableId: string): DesignSlopEntry | un
export interface RenderCatalogOptions {
kind?: 'slop' | 'quality';
category?: SlopCategory;
/** drop entries whose impact is in this list (e.g. ['polish'] for a shorter list) */
omitImpact?: Impact[];
/**
* bullets: `- prose` (design-consultation, design-shotgun: prose only, no ids)
* ids: `- [impeccableId] prose`, bracket only when the detector knows the id
* compact: `id: name; id: name; ...` on one line
*/
style: 'bullets' | 'ids' | 'compact';
}
export function selectCatalog(o: Omit<RenderCatalogOptions, 'style'>): DesignSlopEntry[] {
export function selectCatalog(o: RenderCatalogOptions): DesignSlopEntry[] {
return DESIGN_SLOP_CATALOG.filter(e =>
(!o.kind || e.kind === o.kind)
&& (!o.category || e.category === o.category)
&& !(o.omitImpact && o.omitImpact.includes(e.impact)),
);
}
/** `- prose` bullets, no ids: the register the proposal skills render (design-consultation, design-shotgun). */
export function renderCatalog(o: RenderCatalogOptions): string {
const entries = selectCatalog(o);
switch (o.style) {
case 'bullets':
return entries.map(e => `- ${e.prose}`).join('\n');
case 'ids':
return entries.map(e => (e.impeccableId ? `- [${e.impeccableId}] ${e.prose}` : `- ${e.prose}`)).join('\n');
case 'compact':
return entries.map(e => `${e.impeccableId ?? e.id}: ${e.name}`).join('; ');
}
return selectCatalog(o).map(e => `- ${e.prose}`).join('\n');
}
/** Slop the detector knows, minus the 11 legacy lines: what design doctrine renders as bracketed ids. */
export function detectorSlopEntries(o: { omitPolish?: boolean } = {}): DesignSlopEntry[] {
return DESIGN_SLOP_CATALOG.filter(e => e.kind === 'slop' && e.impeccableId && !e.legacyBlacklist && !(o.omitPolish && e.impact === 'polish'));
}
/** gstack-only slop tells (no detector rule), minus the legacy lines: the LLM pass is the detector. */
export function judgmentTellEntries(o: { omitPolish?: boolean } = {}): DesignSlopEntry[] {
return DESIGN_SLOP_CATALOG.filter(e => e.kind === 'slop' && !e.impeccableId && !e.legacyBlacklist && !(o.omitPolish && e.impact === 'polish'));
}
/** Catalog entries by id, throwing with the id when one is missing (a rename must fail loudly at gen time). */
export function catalogEntries(ids: string[]): DesignSlopEntry[] {
return ids.map(id => {
const e = BY_ID.get(id);
if (!e) throw new Error(`lib/design-catalog.ts: no entry with id "${id}"`);
return e;
});
}
+20 -10
View File
@@ -8,9 +8,11 @@
// test/impeccable-fixtures.test.ts pins that the committed dump came from THIS script
//
// Contract (one script, two engines):
// - An IIFE expression: Aside's `pg.evaluate(...)` and `$B eval <file>` both
// wrap an expression, and every `aside repl '...'` body is a single-quoted
// bash string, so the text contains NO single-quote characters and no `${`.
// - An arrow-FUNCTION expression, never a self-calling IIFE: Aside's
// `pg.evaluate(fn)` receives the function and runs it in the page (an IIFE
// would execute in the repl sandbox, where there is no `document`), and the
// fallback engine calls it with `$B js "($_DUMP)()"`. Both splice the file's
// text into bash, so it contains NO single quotes, no backticks, and no `${`.
// - Works on a CLONE of document.documentElement, never the live page.
// - Inlines only the stylesheets a <link> owns (inline <style> nodes are
// already in the markup; re-serializing them double-counts) as one
@@ -25,13 +27,15 @@
// - Hygiene before the file leaves the browser: <script> bodies emptied,
// <input>/<textarea> values dropped, `value=` and `data-*` attributes over
// 32 chars emptied, <meta content> emptied (charset and viewport kept: they
// carry no user data and the viewport hint is layout-relevant), href query
// strings cut, data: URLs over 1 KB replaced by a placeholder in attributes
// and in the inlined CSS.
// carry no user data and the viewport hint is layout-relevant), query
// strings cut from every URL-bearing attribute (href, src, srcset, poster,
// action, formaction, data, ping, cite: signed CDN and form URLs carry
// tokens), data: URLs over 1 KB replaced by a placeholder in attributes, in
// the inlined CSS, and in existing <style> nodes.
// - The trailing comment names what the dump cannot contain (shadow DOM,
// constructed stylesheets, runtime-injected styles when scripts were
// stripped) so the report can say so once.
export const DOM_DUMP_SCRIPT = String.raw`(() => {
export const DOM_DUMP_SCRIPT = String.raw`() => {
const root = document.documentElement.cloneNode(true);
const head = root.querySelector("head") || root;
const inlined = [];
@@ -49,10 +53,10 @@ export const DOM_DUMP_SCRIPT = String.raw`(() => {
crossOrigin.push(sheet.href || "(unknown)");
}
});
const dataUrl = new RegExp("url\\((\"?)data:[^)]{1024,}\\)", "g");
if (inlined.length) {
const style = document.createElement("style");
style.setAttribute("data-gstack-dom-css", "");
const dataUrl = new RegExp("url\\((\"?)data:[^)]{1024,}\\)", "g");
const rgb = new RegExp("rgb\\((\\d+), (\\d+), (\\d+)\\)", "g");
const hex = (n) => Number(n).toString(16).padStart(2, "0");
style.textContent = inlined.join("\n")
@@ -60,6 +64,11 @@ export const DOM_DUMP_SCRIPT = String.raw`(() => {
.replace(rgb, (m, r, g, b) => "#" + hex(r) + hex(g) + hex(b));
head.appendChild(style);
}
for (const el of Array.from(root.querySelectorAll("style"))) {
if (el.getAttribute("data-gstack-dom-css") === null && el.textContent) el.textContent = el.textContent.replace(dataUrl, "url(data:,gstack-stripped)");
}
const urlAttrs = ["href", "src", "poster", "action", "formaction", "data", "ping", "cite"];
const cutQuery = (v) => v.split("?")[0].split("#")[0];
let scripts = 0;
for (const el of Array.from(root.querySelectorAll("script"))) {
if (el.textContent) { el.textContent = ""; scripts += 1; }
@@ -72,7 +81,8 @@ export const DOM_DUMP_SCRIPT = String.raw`(() => {
if (name === "value" && (el.nodeName === "INPUT" || el.nodeName === "TEXTAREA")) el.setAttribute(name, "");
else if ((name === "value" || name.indexOf("data-") === 0) && value.length > 32) el.setAttribute(name, "");
else if (name === "content" && el.nodeName === "META" && el.getAttribute("name") !== "viewport") el.setAttribute(name, "");
else if (name === "href" && value.indexOf("?") !== -1) el.setAttribute(name, value.split("?")[0]);
else if (name === "srcset") el.setAttribute(name, value.split(",").map((c) => { const parts = c.trim().split(/\s+/); parts[0] = cutQuery(parts[0] || ""); return parts.join(" "); }).join(", "));
else if (urlAttrs.indexOf(name) !== -1 && (value.indexOf("?") !== -1 || value.indexOf("#") !== -1) && value.indexOf("data:") !== 0) el.setAttribute(name, cutQuery(value));
else if (value.indexOf("data:") === 0 && value.length > 1024) el.setAttribute(name, "data:,gstack-stripped");
}
}
@@ -80,7 +90,7 @@ export const DOM_DUMP_SCRIPT = String.raw`(() => {
if (crossOrigin.length) notes.push("cross-origin stylesheets not resolved: " + crossOrigin.join(" "));
if (scripts) notes.push("scripts stripped: " + scripts + "; styles injected at runtime not captured");
return "<!DOCTYPE html>\n" + root.outerHTML + "\n<!-- gstack-dom-dump: " + notes.join("; ") + " -->\n";
})()`;
}`;
/**
* Committed copy of DOM_DUMP_SCRIPT for the browser engines to load at runtime
+10 -4
View File
@@ -1,4 +1,4 @@
(() => {
() => {
const root = document.documentElement.cloneNode(true);
const head = root.querySelector("head") || root;
const inlined = [];
@@ -16,10 +16,10 @@
crossOrigin.push(sheet.href || "(unknown)");
}
});
const dataUrl = new RegExp("url\\((\"?)data:[^)]{1024,}\\)", "g");
if (inlined.length) {
const style = document.createElement("style");
style.setAttribute("data-gstack-dom-css", "");
const dataUrl = new RegExp("url\\((\"?)data:[^)]{1024,}\\)", "g");
const rgb = new RegExp("rgb\\((\\d+), (\\d+), (\\d+)\\)", "g");
const hex = (n) => Number(n).toString(16).padStart(2, "0");
style.textContent = inlined.join("\n")
@@ -27,6 +27,11 @@
.replace(rgb, (m, r, g, b) => "#" + hex(r) + hex(g) + hex(b));
head.appendChild(style);
}
for (const el of Array.from(root.querySelectorAll("style"))) {
if (el.getAttribute("data-gstack-dom-css") === null && el.textContent) el.textContent = el.textContent.replace(dataUrl, "url(data:,gstack-stripped)");
}
const urlAttrs = ["href", "src", "poster", "action", "formaction", "data", "ping", "cite"];
const cutQuery = (v) => v.split("?")[0].split("#")[0];
let scripts = 0;
for (const el of Array.from(root.querySelectorAll("script"))) {
if (el.textContent) { el.textContent = ""; scripts += 1; }
@@ -39,7 +44,8 @@
if (name === "value" && (el.nodeName === "INPUT" || el.nodeName === "TEXTAREA")) el.setAttribute(name, "");
else if ((name === "value" || name.indexOf("data-") === 0) && value.length > 32) el.setAttribute(name, "");
else if (name === "content" && el.nodeName === "META" && el.getAttribute("name") !== "viewport") el.setAttribute(name, "");
else if (name === "href" && value.indexOf("?") !== -1) el.setAttribute(name, value.split("?")[0]);
else if (name === "srcset") el.setAttribute(name, value.split(",").map((c) => { const parts = c.trim().split(/\s+/); parts[0] = cutQuery(parts[0] || ""); return parts.join(" "); }).join(", "));
else if (urlAttrs.indexOf(name) !== -1 && (value.indexOf("?") !== -1 || value.indexOf("#") !== -1) && value.indexOf("data:") !== 0) el.setAttribute(name, cutQuery(value));
else if (value.indexOf("data:") === 0 && value.length > 1024) el.setAttribute(name, "data:,gstack-stripped");
}
}
@@ -47,4 +53,4 @@
if (crossOrigin.length) notes.push("cross-origin stylesheets not resolved: " + crossOrigin.join(" "));
if (scripts) notes.push("scripts stripped: " + scripts + "; styles injected at runtime not captured");
return "<!DOCTYPE html>\n" + root.outerHTML + "\n<!-- gstack-dom-dump: " + notes.join("; ") + " -->\n";
})()
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function isFrontendPath(relPath: string): boolean {
const rel = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
const base = rel.slice(rel.lastIndexOf('/') + 1);
const dot = base.lastIndexOf('.');
const ext = dot >= 0 ? base.slice(dot).toLowerCase() : '';
const ext = dot >= 0 ? base.slice(dot) : ''; // case-sensitive, exactly like gstack-diff-scope's globs
if (EXTENSIONS.has(ext)) return true;
if (BASENAME_PREFIXES.some(p => base.startsWith(p))) return true;
if (rel.startsWith('app/views/')) return true;