sdtk-design-kit 0.1.2 → 0.2.1

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.
@@ -3,6 +3,7 @@
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
5
  const { parseFlags } = require("../lib/args");
6
+ const { lintArtifactTree, summarizeLintFindings } = require("../lib/anti-slop-lint");
6
7
  const { describeDesignPaths, isPathInsideOrEqual, resolveProjectPath } = require("../lib/design-paths");
7
8
  const { ValidationError } = require("../lib/errors");
8
9
 
@@ -75,6 +76,238 @@ function includesAny(content, terms) {
75
76
  return terms.some((term) => lower.includes(term.toLowerCase()));
76
77
  }
77
78
 
79
+ function readInputContractState(paths) {
80
+ if (!fs.existsSync(paths.designStartInputStatePath) || !fs.statSync(paths.designStartInputStatePath).isFile()) {
81
+ return null;
82
+ }
83
+ try {
84
+ return JSON.parse(fs.readFileSync(paths.designStartInputStatePath, "utf-8"));
85
+ } catch (_err) {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function normalizeScreenRole(screen) {
91
+ const token = `${screen.screenId || ""} ${screen.title || ""}`.toLowerCase();
92
+ if (token.includes("home")) return "home";
93
+ if (token.includes("category")) return "category";
94
+ if (token.includes("product-detail") || token.includes("product detail") || token.includes("pdp")) return "product-detail";
95
+ if (token.includes("search")) return "search";
96
+ if (token.includes("cart")) return "cart";
97
+ if (token.includes("checkout")) return "checkout";
98
+ if (token.includes("order-history") || token.includes("order history")) return "order-history";
99
+ if (token.includes("order-detail") || token.includes("order detail")) return "order-detail";
100
+ if (token.includes("account")) return "account-info";
101
+ if (token.includes("configurator") || token.includes("mode-b")) return "mode-b-configurator";
102
+ return screen.screenId || "screen";
103
+ }
104
+
105
+ function requiredRoleMarkers(role) {
106
+ const map = {
107
+ home: ["hero", "featured products", "configurator"],
108
+ category: ["filter sidebar", "product grid"],
109
+ "product-detail": ["spec-table", "add to cart"],
110
+ search: ["search-toolbar", "no-result"],
111
+ cart: ["cart-table", "checkout"],
112
+ checkout: ["checkout-stepper", "summary card"],
113
+ "order-history": ["order history list"],
114
+ "order-detail": ["timeline", "detail summary"],
115
+ "account-info": ["account sidebar", "view or edit form"],
116
+ "mode-b-configurator": ["configurator wizard", "preview panel", "bom table", "recalculate", "add bom to cart"],
117
+ };
118
+ return map[role] || [];
119
+ }
120
+
121
+ function listPrototypeScreenFiles(paths) {
122
+ if (!fs.existsSync(paths.prototypeScreensPath) || !fs.statSync(paths.prototypeScreensPath).isDirectory()) {
123
+ return [];
124
+ }
125
+ return fs
126
+ .readdirSync(paths.prototypeScreensPath)
127
+ .filter((name) => name.toLowerCase().endsWith(".html"))
128
+ .sort()
129
+ .map((name) => path.join(paths.prototypeScreensPath, name));
130
+ }
131
+
132
+ function combinedPrototypeReviewContent({ artifactPath, artifactContent, paths }) {
133
+ const screenFiles = listPrototypeScreenFiles(paths);
134
+ if (screenFiles.length === 0) {
135
+ return artifactContent;
136
+ }
137
+ const parts = [artifactContent];
138
+ if (path.resolve(artifactPath) !== path.resolve(paths.prototypeIndexPath) && fs.existsSync(paths.prototypeIndexPath)) {
139
+ parts.push(fs.readFileSync(paths.prototypeIndexPath, "utf-8"));
140
+ }
141
+ for (const filePath of screenFiles) {
142
+ if (path.resolve(filePath) === path.resolve(artifactPath)) {
143
+ continue;
144
+ }
145
+ parts.push(fs.readFileSync(filePath, "utf-8"));
146
+ }
147
+ return parts.join("\n");
148
+ }
149
+
150
+ function resolveAntiSlopLintMode(env = process.env) {
151
+ const value = String(env.SDTK_DESIGN_ANTI_SLOP_LINT || "warn").toLowerCase();
152
+ return value === "on" || value === "off" || value === "warn" ? value : "warn";
153
+ }
154
+
155
+ function collectPrototypeHtmlMap({ artifactPath, artifactContent, paths }) {
156
+ const map = new Map();
157
+ const add = (screenId, html) => {
158
+ if (!map.has(screenId)) map.set(screenId, html);
159
+ };
160
+ add(path.basename(artifactPath, ".html") || "artifact", artifactContent);
161
+ if (fs.existsSync(paths.prototypeIndexPath)) {
162
+ add("index", fs.readFileSync(paths.prototypeIndexPath, "utf-8"));
163
+ }
164
+ for (const filePath of listPrototypeScreenFiles(paths)) {
165
+ add(path.basename(filePath, ".html"), fs.readFileSync(filePath, "utf-8"));
166
+ }
167
+ return map;
168
+ }
169
+
170
+ function applyAntiSlopVerdict({ structuralVerdict, lintFindings, lintMode }) {
171
+ const lintSummary = summarizeLintFindings(lintFindings);
172
+ const lintBlocking = lintMode === "on" && lintSummary.p0 > 0;
173
+ return {
174
+ verdict: lintBlocking ? "FAIL_WITH_RENDERER_SLOP" : structuralVerdict,
175
+ lintSummary,
176
+ lintBlocking,
177
+ };
178
+ }
179
+
180
+ function evaluateHighFidelityGate({ artifactContent, statePayload, projectPath, paths }) {
181
+ const html = String(artifactContent || "").toLowerCase();
182
+ const screens = statePayload.screenModel.screens || [];
183
+ const profile = statePayload.profileSelection || "none";
184
+ const hasTokens = fs.existsSync(paths.designTokensPath);
185
+ const hasComponentLibrary = fs.existsSync(paths.componentPatternLibraryPath);
186
+ const hasPre = html.includes("<pre");
187
+ const hasRawJsonRisk = html.includes("json.stringify") || html.includes("raw json") || html.includes("{\"") || html.includes("not_found");
188
+ const hasNav = html.includes('aria-label="screen navigation"') || html.includes("screen navigation");
189
+ const hasStateChips = html.includes('data-chip-type="state"') || html.includes("chip");
190
+ const sectionRows = [];
191
+ let missingCount = 0;
192
+ for (const screen of screens) {
193
+ const id = String(screen.screenId || "");
194
+ const title = String(screen.title || id);
195
+ const role = normalizeScreenRole(screen);
196
+ const idMarker = `id="screen-${id}"`;
197
+ const navMarkers = [`href="#screen-${id}"`, `href="screens/${id}.html"`, `href="${id}.html"`];
198
+ const roleMarkers = requiredRoleMarkers(role);
199
+ const missing = [];
200
+ if (!html.includes(idMarker.toLowerCase())) missing.push("missing screen section id");
201
+ if (!navMarkers.some((marker) => html.includes(marker.toLowerCase()))) missing.push("missing navigation link");
202
+ for (const marker of roleMarkers) {
203
+ if (!html.includes(marker)) missing.push(`missing component marker: ${marker}`);
204
+ }
205
+ if (missing.length > 0) missingCount += 1;
206
+ sectionRows.push({
207
+ title,
208
+ role,
209
+ missing,
210
+ pass: missing.length === 0,
211
+ });
212
+ }
213
+
214
+ let verdict = "PASS_HIGH_FIDELITY_READY";
215
+ const blockers = [];
216
+ if (screens.length === 0) {
217
+ verdict = "FAIL_WITH_PRODUCT_GAPS";
218
+ blockers.push("explicit screen model is empty");
219
+ }
220
+ if (!hasComponentLibrary) {
221
+ verdict = "FAIL_WITH_PRODUCT_GAPS";
222
+ blockers.push("component pattern library missing");
223
+ }
224
+ if (!hasTokens) {
225
+ verdict = "FAIL_WITH_PRODUCT_GAPS";
226
+ blockers.push("design tokens missing");
227
+ }
228
+ if (!hasNav) {
229
+ verdict = "FAIL_WITH_PRODUCT_GAPS";
230
+ blockers.push("inter-screen navigation contract missing");
231
+ }
232
+ if (!hasStateChips) {
233
+ verdict = "FAIL_WITH_PRODUCT_GAPS";
234
+ blockers.push("state chip coverage missing");
235
+ }
236
+ if (missingCount > 0) {
237
+ verdict = "FAIL_WITH_PRODUCT_GAPS";
238
+ blockers.push(`${missingCount} screen(s) missing required component markers`);
239
+ }
240
+ if (hasPre || hasRawJsonRisk) {
241
+ verdict = "FAIL_WITH_PRODUCT_GAPS";
242
+ blockers.push("debug/raw JSON UI risk detected");
243
+ }
244
+ if (profile === "b2b-commerce" && !html.includes("configurator wizard")) {
245
+ verdict = "FAIL_WITH_PRODUCT_GAPS";
246
+ blockers.push("configurator completeness missing");
247
+ }
248
+ if (html.includes("main layout</h3>") && html.includes("primary action</h3>")) {
249
+ verdict = "FAIL_HIGH_FIDELITY_NOT_READY";
250
+ blockers.push("generic scaffold markers detected (Main layout / Primary action)");
251
+ }
252
+ return {
253
+ verdict,
254
+ blockers,
255
+ profile,
256
+ sectionRows,
257
+ hasTokens,
258
+ hasComponentLibrary,
259
+ hasNav,
260
+ hasStateChips,
261
+ hasPre,
262
+ hasRawJsonRisk,
263
+ projectPath,
264
+ };
265
+ }
266
+
267
+ function fidelityReviewContent(result) {
268
+ return [
269
+ "# Design Fidelity Review",
270
+ "",
271
+ `- Verdict: ${result.verdict}`,
272
+ `- Profile: ${result.profile}`,
273
+ `- Component library: ${result.hasComponentLibrary ? "present" : "missing"}`,
274
+ `- Design tokens: ${result.hasTokens ? "present" : "missing"}`,
275
+ `- Navigation contract: ${result.hasNav ? "present" : "missing"}`,
276
+ `- State coverage markers: ${result.hasStateChips ? "present" : "missing"}`,
277
+ `- Raw <pre> debug risk: ${result.hasPre ? "detected" : "not detected"}`,
278
+ `- Raw JSON debug risk: ${result.hasRawJsonRisk ? "detected" : "not detected"}`,
279
+ "",
280
+ "## Screen-By-Screen Coverage",
281
+ "",
282
+ "| Screen | Role | Result | Actionable notes |",
283
+ "|---|---|---|---|",
284
+ ...result.sectionRows.map((row) => `| ${row.title} | ${row.role} | ${row.pass ? "PASS" : "FAIL"} | ${row.pass ? "coverage complete" : row.missing.join("; ")} |`),
285
+ "",
286
+ "## Gate Findings",
287
+ "",
288
+ ...(result.blockers.length > 0 ? result.blockers.map((item) => `- ${item}`) : ["- No blocking fidelity gaps found."]),
289
+ "",
290
+ "## Anti-Slop Lint Findings",
291
+ "",
292
+ `- Mode: ${result.lintMode || "warn"}`,
293
+ `- Summary: P0=${result.lintSummary ? result.lintSummary.p0 : 0}, P1=${result.lintSummary ? result.lintSummary.p1 : 0}, P2=${result.lintSummary ? result.lintSummary.p2 : 0}`,
294
+ "",
295
+ "| Severity | ID | Screen | Message | Fix | Snippet |",
296
+ "|---|---|---|---|---|---|",
297
+ ...(Array.isArray(result.lintFindings) && result.lintFindings.length > 0
298
+ ? result.lintFindings.map((item) => `| ${item.severity} | ${item.id} | ${item.screenId || "artifact"} | ${item.message} | ${item.fix} | ${String(item.snippet || "").replace(/\|/g, "/")} |`)
299
+ : ["| - | - | - | No lint findings. | - | - |"]),
300
+ "",
301
+ "## Actions",
302
+ "",
303
+ "- Fill missing component markers per failed screen.",
304
+ "- Ensure design tokens and component pattern library are generated before review.",
305
+ "- Remove raw debug UI surfaces (`<pre>` and JSON dumps) from customer-facing prototype output.",
306
+ "- Re-run `sdtk-design prototype` and `sdtk-design review --artifact docs/design/prototype/index.html`.",
307
+ "",
308
+ ].join("\n");
309
+ }
310
+
78
311
  function visualPolishChecks({ artifactRelativePath, artifactContent, prototypeHtml }) {
79
312
  const lower = String(artifactContent || "").toLowerCase();
80
313
  const hasLanding = lower.includes("section-landing") || lower.includes("landing");
@@ -191,12 +424,62 @@ function runDesignReview({ artifact, projectPath, force = false }) {
191
424
 
192
425
  const artifactContent = fs.readFileSync(artifactPath, "utf-8");
193
426
  const artifactRelativePath = toPosix(path.relative(resolvedProjectPath, artifactPath));
427
+ const statePayload = readInputContractState(paths);
428
+ const shouldRunFidelityGate =
429
+ isPrototypeHtmlArtifact(artifactRelativePath, artifactContent) &&
430
+ statePayload &&
431
+ statePayload.mode === "from-spec" &&
432
+ statePayload.analysisStatus === "INPUT_CONTRACT_READY" &&
433
+ statePayload.screenModel &&
434
+ Array.isArray(statePayload.screenModel.screens) &&
435
+ statePayload.screenModel.screens.length >= 4;
194
436
  fs.mkdirSync(paths.reviewsPath, { recursive: true });
195
437
  fs.writeFileSync(reportPath, reviewContent({ artifactRelativePath, artifactContent }), "utf-8");
196
438
 
439
+ let fidelityReportRelativePath = null;
440
+ let fidelityVerdict = null;
441
+ let antiSlopLint = null;
442
+ if (shouldRunFidelityGate) {
443
+ const fidelityArtifactContent = combinedPrototypeReviewContent({
444
+ artifactPath,
445
+ artifactContent,
446
+ paths,
447
+ });
448
+ const fidelityResult = evaluateHighFidelityGate({
449
+ artifactContent: fidelityArtifactContent,
450
+ statePayload,
451
+ projectPath: resolvedProjectPath,
452
+ paths,
453
+ });
454
+ const lintMode = resolveAntiSlopLintMode();
455
+ const lintFindings = lintMode === "off" ? [] : lintArtifactTree(collectPrototypeHtmlMap({ artifactPath, artifactContent, paths }));
456
+ const lintVerdict = applyAntiSlopVerdict({
457
+ structuralVerdict: fidelityResult.verdict,
458
+ lintFindings,
459
+ lintMode,
460
+ });
461
+ fidelityResult.verdict = lintVerdict.verdict;
462
+ fidelityResult.lintMode = lintMode;
463
+ fidelityResult.lintFindings = lintFindings;
464
+ fidelityResult.lintSummary = lintVerdict.lintSummary;
465
+ fidelityResult.lintBlocking = lintVerdict.lintBlocking;
466
+ antiSlopLint = { mode: lintMode, findings: lintFindings, summary: lintVerdict.lintSummary, blocking: lintVerdict.lintBlocking };
467
+ const fidelityName = `DESIGN_FIDELITY_REVIEW_${formatDateYYYYMMDD()}.md`;
468
+ const fidelityPath = path.join(paths.reviewsPath, fidelityName);
469
+ if (fs.existsSync(fidelityPath) && !force) {
470
+ throw new ValidationError(`docs/design/reviews/${fidelityName} already exists. Re-run with --force to replace this managed fidelity review report.`);
471
+ }
472
+ fs.writeFileSync(fidelityPath, fidelityReviewContent(fidelityResult), "utf-8");
473
+ fidelityReportRelativePath = `docs/design/reviews/${fidelityName}`;
474
+ fidelityVerdict = fidelityResult.verdict;
475
+ }
476
+
197
477
  return {
198
478
  projectPath: resolvedProjectPath,
199
479
  relativeReportPath: `docs/design/reviews/${reportName}`,
480
+ fidelityReportRelativePath,
481
+ fidelityVerdict,
482
+ antiSlopLint,
200
483
  forced: Boolean(force),
201
484
  };
202
485
  }
@@ -212,16 +495,30 @@ function cmdReview(args) {
212
495
  });
213
496
 
214
497
  console.log(`[design] Wrote ${result.relativeReportPath}: ${result.projectPath}`);
498
+ if (result.fidelityReportRelativePath) {
499
+ console.log(`[design] Wrote ${result.fidelityReportRelativePath}: ${result.projectPath}`);
500
+ console.log(`[design] Fidelity verdict: ${result.fidelityVerdict}`);
501
+ if (result.antiSlopLint) {
502
+ if (result.antiSlopLint.mode === "off") {
503
+ console.log("[design] Anti-slop lint: OFF (deprecated emergency rollback)");
504
+ } else if (result.antiSlopLint.summary.p0 > 0) {
505
+ console.log(`[design] Anti-slop lint: ${result.antiSlopLint.summary.p0} P0 findings; see review report`);
506
+ } else {
507
+ console.log(`[design] Anti-slop lint: PASS (P1=${result.antiSlopLint.summary.p1}, P2=${result.antiSlopLint.summary.p2})`);
508
+ }
509
+ }
510
+ }
215
511
  console.log(`[design] Review mode: local artifact`);
216
512
  console.log(`[design] Overwrite: ${result.forced ? "enabled by --force" : "not needed"}`);
217
513
  console.log("[design] No URL, browser, screenshot, vision, network, .sdtk/atlas, or SDTK-WIKI output was used.");
218
- return 0;
514
+ return result.antiSlopLint && result.antiSlopLint.blocking ? 1 : 0;
219
515
  }
220
516
 
221
517
  module.exports = {
222
518
  cmdReview,
223
519
  cmdReviewHelp,
224
520
  formatDateYYYYMMDD,
521
+ resolveAntiSlopLintMode,
225
522
  isPrototypeHtmlArtifact,
226
523
  reviewContent,
227
524
  runDesignReview,
@@ -10,6 +10,8 @@ const { parseFlags } = require("../lib/args");
10
10
  const { describeDesignPaths, resolveProjectPath } = require("../lib/design-paths");
11
11
  const { availableProfileNames } = require("../lib/design-profiles");
12
12
  const { buildInputContractState, writeInputContractState } = require("../lib/design-input-contract");
13
+ const { writeScreenBriefArtifacts } = require("../lib/screen-briefs");
14
+ const { writeComponentContractArtifacts } = require("../lib/component-contract");
13
15
  const { ValidationError } = require("../lib/errors");
14
16
  const { DEFAULT_STYLE, availableStyleNames, resolveStyleName } = require("../lib/style-presets");
15
17
 
@@ -37,7 +39,7 @@ Usage:
37
39
  Example:
38
40
  sdtk-design start --idea "I want to build a lightweight CRM for solo consultants to track leads."
39
41
  sdtk-design start --idea "ClientPulse for consultants" --style premium-dashboard
40
- sdtk-design start --from-spec . --reference-dir ./docs/ui/claude-design-export --profile b2b-commerce
42
+ sdtk-design start --from-spec . --reference-dir ./docs/design/reference-export --profile b2b-commerce
41
43
 
42
44
  Style presets:
43
45
  ${availableStyleNames().join(", ")}
@@ -62,6 +64,10 @@ Creates:
62
64
  docs/design/DESIGN_SYSTEM.md
63
65
  from-spec mode:
64
66
  .sdtk/design/START_INPUT_STATE.json
67
+ docs/design/screens/*_DESIGN_BRIEF.md (when INPUT_CONTRACT_READY)
68
+ .sdtk/design/screen-briefs/*.json (managed sidecars for deterministic renderer intake)
69
+ docs/design/COMPONENT_PATTERN_LIBRARY.md (when INPUT_CONTRACT_READY)
70
+ docs/design/DESIGN_TOKENS.json (when INPUT_CONTRACT_READY)
65
71
 
66
72
  Safety:
67
73
  Local files only.
@@ -137,10 +143,18 @@ function runDesignStartFromSpec({
137
143
  profile,
138
144
  });
139
145
  const statePath = writeInputContractState(contractState.projectPath, contractState);
146
+ let screenBriefs = null;
147
+ let componentContract = null;
148
+ if (contractState.analysisStatus === "INPUT_CONTRACT_READY") {
149
+ screenBriefs = writeScreenBriefArtifacts(contractState.projectPath, contractState);
150
+ componentContract = writeComponentContractArtifacts(contractState.projectPath, contractState);
151
+ }
140
152
  return {
141
153
  ...contractState,
142
154
  statePath,
143
155
  stateRelativePath: ".sdtk/design/START_INPUT_STATE.json",
156
+ screenBriefs,
157
+ componentContract,
144
158
  };
145
159
  }
146
160
 
@@ -165,6 +179,14 @@ function cmdStart(args) {
165
179
  console.log(`[design] Started SDTK-DESIGN package: ${contractResult.projectPath}`);
166
180
  console.log(`[design] Mode: from-spec`);
167
181
  console.log(`[design] Wrote ${contractResult.stateRelativePath}`);
182
+ if (contractResult.screenBriefs) {
183
+ console.log(`[design] Wrote per-screen briefs: ${contractResult.screenBriefs.generatedCount} artifact(s)`);
184
+ }
185
+ if (contractResult.componentContract) {
186
+ console.log(
187
+ `[design] Wrote component/token contract: ${contractResult.componentContract.componentLibraryRelativePath}, ${contractResult.componentContract.designTokensRelativePath}`
188
+ );
189
+ }
168
190
  console.log(`[design] Screen model: ${contractResult.screenModel.totalScreens} explicit screen(s), readiness=${contractResult.screenModel.readiness}`);
169
191
  if (contractResult.referenceDirectory && contractResult.referenceDirectory.provided) {
170
192
  console.log(
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+
3
+ function stripHtmlComments(html) {
4
+ return String(html || "").replace(/<!--[\s\S]*?-->/g, "");
5
+ }
6
+
7
+ function stripTags(html) {
8
+ return String(html || "")
9
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
10
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
11
+ .replace(/<[^>]+>/g, " ")
12
+ .replace(/\s+/g, " ")
13
+ .trim();
14
+ }
15
+
16
+ function snippet(value, index = 0, length = 100) {
17
+ const text = stripTags(value);
18
+ if (text) return text.slice(0, length);
19
+ return String(value || "").slice(Math.max(0, index - 30), index + length).replace(/\s+/g, " ").trim();
20
+ }
21
+
22
+ function finding(severity, id, message, fix, text, screenId) {
23
+ return { severity, id, message, fix, snippet: snippet(text), ...(screenId ? { screenId } : {}) };
24
+ }
25
+
26
+ function addRegexFindings(findings, clean, regex, severity, id, message, fix, screenId) {
27
+ let match;
28
+ const pattern = new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : `${regex.flags}g`);
29
+ while ((match = pattern.exec(clean))) {
30
+ findings.push(finding(severity, id, message, fix, match[0], screenId));
31
+ }
32
+ }
33
+
34
+ function articleTexts(clean) {
35
+ const result = [];
36
+ const regex = /<article\b[^>]*>([\s\S]*?)<\/article>/gi;
37
+ let match;
38
+ while ((match = regex.exec(clean))) {
39
+ const text = stripTags(match[1]).toLowerCase();
40
+ if (text) result.push(text);
41
+ }
42
+ return result;
43
+ }
44
+
45
+ function articleBodyTexts(clean) {
46
+ const result = [];
47
+ const regex = /<article\b[^>]*>([\s\S]*?)<\/article>/gi;
48
+ let match;
49
+ while ((match = regex.exec(clean))) {
50
+ const bodyOnly = match[1].replace(/<h[1-6]\b[\s\S]*?<\/h[1-6]>/i, "");
51
+ const text = stripTags(bodyOnly).toLowerCase().replace(/\s+/g, " ").trim();
52
+ if (text.length >= 20) result.push(text);
53
+ }
54
+ return result;
55
+ }
56
+
57
+ function lintArtifactHtml(html, options = {}) {
58
+ const screenId = options.screenId;
59
+ const clean = stripHtmlComments(html);
60
+ const findings = [];
61
+
62
+ addRegexFindings(
63
+ findings,
64
+ clean,
65
+ /\b(line\s*item|product\s*card|material|result\s*item|feature)\s+(one|two|three|[1-9]|\d+)\b|\blorem\s+ipsum\b|\bdolor\s+sit\s+amet\b|\bsample\s+content\b|\bplaceholder\s+text\b/i,
66
+ "P0",
67
+ "filler-copy",
68
+ "Auto-numbered filler copy detected.",
69
+ "Replace auto-numbered filler with brief-derived data_slot placeholders or grey blocks.",
70
+ screenId
71
+ );
72
+ addRegexFindings(
73
+ findings,
74
+ clean,
75
+ /#[ ]*(6366f1|4f46e5|4338ca|3730a3|8b5cf6|7c3aed|a855f7)\b/i,
76
+ "P0",
77
+ "ai-default-indigo",
78
+ "AI-default indigo/purple hex detected.",
79
+ "Replace with var(--primary), var(--accent), or an explicit brand token override.",
80
+ screenId
81
+ );
82
+ addRegexFindings(
83
+ findings,
84
+ clean,
85
+ /<h[1-6]\b[^>]*>[^<]*✨[^<]*<\/h[1-6]>|<button\b[^>]*>[^<]*✨[^<]*<\/button>|<a\b[^>]*class="[^"]*\bbtn\b[^"]*"[^>]*>[^<]*✨[^<]*<\/a>/i,
86
+ "P0",
87
+ "slop-emoji",
88
+ "Decorative emoji used in heading, button, CTA, or icon surface.",
89
+ "Use a 1.6-1.8px stroke monoline SVG with currentColor, or remove the icon.",
90
+ screenId
91
+ );
92
+ addRegexFindings(
93
+ findings,
94
+ clean,
95
+ /<(h[1-6]|button|a\b[^>]*class="[^"]*\bbtn\b[^"]*"|[^>]*class="[^"]*\bicon\b[^"]*")[^>]*>[\s\S]*?(✨|笨ィ|噫|識|笞。|櫨|庁)[\s\S]*?<\/\1>/i,
96
+ "P0",
97
+ "slop-emoji",
98
+ "Decorative emoji used in heading, button, CTA, or icon surface.",
99
+ "Use a 1.6-1.8px stroke monoline SVG with currentColor, or remove the icon.",
100
+ screenId
101
+ );
102
+ addRegexFindings(
103
+ findings,
104
+ clean,
105
+ /\b\d+x\s+(faster|better|easier)\b|\b99\.\d+%\s+uptime\b|\bzero[- ]downtime\b|\b3x\s+more\s+(productive|efficient)\b/i,
106
+ "P0",
107
+ "invented-metric",
108
+ "Unsourced marketing metric detected.",
109
+ "Pull the metric from a real source or remove the claim.",
110
+ screenId
111
+ );
112
+ addRegexFindings(
113
+ findings,
114
+ clean,
115
+ /class="[^"]*\bdensity-evidence\b[^"]*"/i,
116
+ "P0",
117
+ "density-evidence-residue",
118
+ "Legacy density-evidence filler residue detected.",
119
+ "Remove filler section emitters from the renderer.",
120
+ screenId
121
+ );
122
+
123
+ const counts = new Map();
124
+ for (const text of articleTexts(clean)) counts.set(text, (counts.get(text) || 0) + 1);
125
+ const fullDuplicates = new Set();
126
+ for (const [text, count] of counts.entries()) {
127
+ if (count >= 3) {
128
+ fullDuplicates.add(text);
129
+ findings.push(finding("P0", "duplicate-article-text", "Three or more article nodes share identical text.", "Replace duplicated articles with brief-derived data or a single labelled state card.", text, screenId));
130
+ }
131
+ }
132
+ if (fullDuplicates.size === 0) {
133
+ const bodyCounts = new Map();
134
+ for (const text of articleBodyTexts(clean)) bodyCounts.set(text, (bodyCounts.get(text) || 0) + 1);
135
+ for (const [text, count] of bodyCounts.entries()) {
136
+ if (count >= 3) {
137
+ findings.push(finding("P1", "near-duplicate-article-body", "Three or more article elements share identical body text while headings differ.", "Pull each article body from a distinct data_slot purpose or remove the repeated paragraph.", text, screenId));
138
+ }
139
+ }
140
+ }
141
+
142
+ const accentCount = (clean.match(/var\(--(accent|primary)\)/gi) || []).length;
143
+ if (accentCount >= 6) {
144
+ findings.push(finding("P1", "accent-overuse", "Primary/accent token is overused in one page.", "Reduce accent use to primary actions and key state markers.", `accent count ${accentCount}`, screenId));
145
+ }
146
+ const withoutRoot = clean.replace(/:root\s*\{[\s\S]*?\}/gi, "");
147
+ const rawHexCount = (withoutRoot.match(/#[0-9a-fA-F]{3,8}\b/g) || []).length;
148
+ if (rawHexCount > 12) {
149
+ findings.push(finding("P1", "raw-hex-outside-root", "Too many raw hex values outside :root.", "Move colors into design tokens or CSS variables.", `raw hex count ${rawHexCount}`, screenId));
150
+ }
151
+ if (options.tokens && options.tokens.typography && /serif/i.test(String(options.tokens.typography.displayFamily || ""))) {
152
+ addRegexFindings(
153
+ findings,
154
+ clean,
155
+ /<h[1-3]\b[^>]*style="[^"]*font-family:\s*(Inter|Roboto|Arial|system-ui|-apple-system)[^"]*"/i,
156
+ "P1",
157
+ "display-sans-on-serif-binding",
158
+ "Heading overrides serif display token with sans font.",
159
+ "Use the active display typography token for headings.",
160
+ screenId
161
+ );
162
+ }
163
+ addRegexFindings(
164
+ findings,
165
+ clean,
166
+ /<section\b(?![^>]*(data-section-id|data-slot-id|data-component-id|aria-label|id=))[^>]*>/i,
167
+ "P2",
168
+ "missing-data-od-id",
169
+ "Section is missing a stable target attribute.",
170
+ "Add data-section-id or an analogous stable target attribute.",
171
+ screenId
172
+ );
173
+ addRegexFindings(
174
+ findings,
175
+ clean,
176
+ /<svg\b(?![^>]*class="[^"]*(icon|semantic)[^"]*")[\s\S]*?<path\b[^>]*d="[^"]*Q[^"]*Q[^"]*"/i,
177
+ "P2",
178
+ "decorative-blob-svg",
179
+ "Decorative blob SVG detected.",
180
+ "Use semantic icons or remove decorative blob geometry.",
181
+ screenId
182
+ );
183
+ return findings;
184
+ }
185
+
186
+ function lintArtifactTree(perScreenHtmlMap, options = {}) {
187
+ const findings = [];
188
+ for (const [screenId, html] of perScreenHtmlMap.entries()) {
189
+ findings.push(...lintArtifactHtml(html, { ...options, screenId }));
190
+ }
191
+ return findings;
192
+ }
193
+
194
+ function summarizeLintFindings(findings) {
195
+ const summary = { p0: 0, p1: 0, p2: 0, blocking: false };
196
+ for (const item of Array.isArray(findings) ? findings : []) {
197
+ if (item.severity === "P0") summary.p0 += 1;
198
+ if (item.severity === "P1") summary.p1 += 1;
199
+ if (item.severity === "P2") summary.p2 += 1;
200
+ }
201
+ summary.blocking = summary.p0 > 0;
202
+ return summary;
203
+ }
204
+
205
+ module.exports = {
206
+ lintArtifactHtml,
207
+ lintArtifactTree,
208
+ stripHtmlComments,
209
+ summarizeLintFindings,
210
+ };