sdtk-design-kit 0.3.0 → 0.3.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.
@@ -1,515 +1,515 @@
1
- "use strict";
2
-
3
- const fs = require("fs");
4
- const path = require("path");
5
- const { parseFlags } = require("../lib/args");
6
- const { lintArtifactTree, summarizeLintFindings } = require("../lib/anti-slop-lint");
7
- const { describeDesignPaths, isPathInsideOrEqual, resolveProjectPath } = require("../lib/design-paths");
8
- const { ValidationError } = require("../lib/errors");
9
-
10
- const REVIEW_FLAG_DEFS = {
11
- help: { type: "boolean" },
12
- artifact: { type: "string" },
13
- "project-path": { type: "string" },
14
- force: { type: "boolean" },
15
- };
16
-
17
- function cmdReviewHelp() {
18
- console.log(`SDTK-DESIGN Review
19
-
20
- Usage:
21
- sdtk-design review --artifact docs/design/wireframes/LANDING.md [--project-path <path>] [--force]
22
- sdtk-design review --artifact docs/design/prototype/index.html [--project-path <path>] [--force]
23
-
24
- Example:
25
- sdtk-design review --artifact docs/design/wireframes/LANDING.md
26
- sdtk-design review --artifact docs/design/prototype/index.html
27
-
28
- Reads:
29
- A project-local markdown or static HTML design artifact.
30
-
31
- Creates:
32
- docs/design/reviews/DESIGN_REVIEW_YYYYMMDD.md
33
-
34
- Safety:
35
- Local artifact review only.
36
- Existing same-day review report is not overwritten unless --force is explicit.
37
- No URL, browser, screenshot, vision, or DOM review.
38
- No .sdtk/atlas creation or mutation.
39
- No SDTK-WIKI output mutation.
40
- No network call, Pro entitlement, or production app code generation.`);
41
- return 0;
42
- }
43
-
44
- function formatDateYYYYMMDD(date = new Date()) {
45
- return date.toISOString().slice(0, 10).replace(/-/g, "");
46
- }
47
-
48
- function toPosix(value) {
49
- return String(value || "").replace(/\\/g, "/");
50
- }
51
-
52
- function resolveArtifactPath(artifact, projectPath) {
53
- const raw = String(artifact || "").trim();
54
- if (!raw) {
55
- throw new ValidationError('Missing required --artifact "<path>". No project files were changed.');
56
- }
57
- const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(projectPath, raw);
58
- if (!isPathInsideOrEqual(resolved, projectPath)) {
59
- throw new ValidationError(`Refusing to review artifact outside project root: ${resolved}. No project files were changed.`);
60
- }
61
- return resolved;
62
- }
63
-
64
- function hasHeading(content, heading) {
65
- return content.toLowerCase().includes(heading.toLowerCase());
66
- }
67
-
68
- function isPrototypeHtmlArtifact(artifactRelativePath, artifactContent) {
69
- const lowerPath = toPosix(artifactRelativePath).toLowerCase();
70
- const lowerContent = String(artifactContent || "").toLowerCase();
71
- return lowerPath.endsWith(".html") || lowerContent.includes("<!doctype html") || lowerContent.includes("<html");
72
- }
73
-
74
- function includesAny(content, terms) {
75
- const lower = String(content || "").toLowerCase();
76
- return terms.some((term) => lower.includes(term.toLowerCase()));
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 listPrototypeScreenFiles(paths) {
91
- if (!fs.existsSync(paths.prototypeScreensPath) || !fs.statSync(paths.prototypeScreensPath).isDirectory()) {
92
- return [];
93
- }
94
- return fs
95
- .readdirSync(paths.prototypeScreensPath)
96
- .filter((name) => name.toLowerCase().endsWith(".html"))
97
- .sort()
98
- .map((name) => path.join(paths.prototypeScreensPath, name));
99
- }
100
-
101
- function resolveAntiSlopLintMode(env = process.env) {
102
- const value = String(env.SDTK_DESIGN_ANTI_SLOP_LINT || "on").toLowerCase();
103
- return value === "off" ? "off" : "on";
104
- }
105
-
106
- function collectPrototypeHtmlMap({ artifactPath, artifactContent, paths }) {
107
- const map = new Map();
108
- const add = (screenId, html) => {
109
- if (!map.has(screenId)) map.set(screenId, html);
110
- };
111
- for (const filePath of listPrototypeScreenFiles(paths)) {
112
- add(path.basename(filePath, ".html"), fs.readFileSync(filePath, "utf-8"));
113
- }
114
- if (map.size === 0 && path.resolve(path.dirname(artifactPath)) === path.resolve(paths.prototypeScreensPath)) {
115
- add(path.basename(artifactPath, ".html") || "artifact", artifactContent);
116
- }
117
- return map;
118
- }
119
-
120
- function applyAntiSlopVerdict({ structuralVerdict, lintFindings, lintMode }) {
121
- const lintSummary = summarizeLintFindings(lintFindings);
122
- const lintBlocking = lintMode !== "off" && lintSummary.p0 > 0;
123
- return {
124
- verdict: lintBlocking ? "FAIL_WITH_RENDERER_SLOP" : structuralVerdict,
125
- lintSummary,
126
- lintBlocking,
127
- };
128
- }
129
-
130
- function extractTagContent(html, tagName) {
131
- const match = String(html || "").match(new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "i"));
132
- return match ? match[1] : "";
133
- }
134
-
135
- function stripTags(value) {
136
- return String(value || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
137
- }
138
-
139
- function countTag(html, tagName) {
140
- return (String(html || "").match(new RegExp(`<${tagName}\\b`, "gi")) || []).length;
141
- }
142
-
143
- function collectDesignTokenReferences(paths) {
144
- if (!fs.existsSync(paths.designTokensPath)) return [];
145
- try {
146
- const payload = JSON.parse(fs.readFileSync(paths.designTokensPath, "utf-8"));
147
- const values = [];
148
- const visit = (value) => {
149
- if (value == null) return;
150
- if (typeof value === "string" || typeof value === "number") {
151
- const token = String(value).trim();
152
- if (token) values.push(token.toLowerCase());
153
- return;
154
- }
155
- if (Array.isArray(value)) {
156
- value.forEach(visit);
157
- return;
158
- }
159
- if (typeof value === "object") Object.values(value).forEach(visit);
160
- };
161
- visit(payload);
162
- return values.filter((value) => value.length >= 3);
163
- } catch (_err) {
164
- return [];
165
- }
166
- }
167
-
168
- function hasDesignTokenReference(html, tokenReferences) {
169
- const lower = String(html || "").toLowerCase();
170
- if (/var\(--[a-z0-9-]+\)/i.test(lower)) return true;
171
- return tokenReferences.some((token) => lower.includes(token));
172
- }
173
-
174
- function screenStructuralFindings({ screenId, html, tokenReferences }) {
175
- const findings = [];
176
- const lower = String(html || "").toLowerCase();
177
- const title = stripTags(extractTagContent(html, "title"));
178
- const body = extractTagContent(html, "body");
179
- if (!lower.trimStart().startsWith("<!doctype html>")) findings.push("missing doctype");
180
- if (!/<html\b/i.test(html)) findings.push("missing html element");
181
- if (!/<head\b/i.test(html)) findings.push("missing head element");
182
- if (!/<body\b/i.test(html)) findings.push("missing body element");
183
- if (!title) findings.push("missing or empty title");
184
- const h1Count = countTag(html, "h1");
185
- if (h1Count !== 1) findings.push(`expected exactly one h1, found ${h1Count}`);
186
- if (!stripTags(body)) findings.push("empty body");
187
- if (!hasDesignTokenReference(html, tokenReferences)) findings.push("missing design token reference");
188
- return {
189
- screenId,
190
- pass: findings.length === 0,
191
- findings,
192
- };
193
- }
194
-
195
- function evaluateHighFidelityGate({ statePayload, projectPath, paths }) {
196
- const screens = statePayload.screenModel.screens || [];
197
- const profile = statePayload.profileSelection || "none";
198
- const hasTokens = fs.existsSync(paths.designTokensPath);
199
- const hasComponentLibrary = fs.existsSync(paths.componentPatternLibraryPath);
200
- const tokenReferences = collectDesignTokenReferences(paths);
201
- const sectionRows = [];
202
- const blockers = [];
203
- for (const screen of screens) {
204
- const id = String(screen.screenId || "");
205
- const title = String(screen.title || id);
206
- const filePath = path.join(paths.prototypeScreensPath, `${id}.html`);
207
- if (!fs.existsSync(filePath)) {
208
- const missing = [`missing screen file: docs/design/prototype/screens/${id}.html`];
209
- blockers.push(`${title}: ${missing.join("; ")}`);
210
- sectionRows.push({ title, role: id, missing, pass: false });
211
- continue;
212
- }
213
- const structural = screenStructuralFindings({
214
- screenId: id,
215
- html: fs.readFileSync(filePath, "utf-8"),
216
- tokenReferences,
217
- });
218
- if (!structural.pass) blockers.push(`${title}: ${structural.findings.join("; ")}`);
219
- sectionRows.push({ title, role: id, missing: structural.findings, pass: structural.pass });
220
- }
221
-
222
- let verdict = "PASS_HIGH_FIDELITY_READY";
223
- if (screens.length === 0) {
224
- verdict = "FAIL_STRUCTURE";
225
- blockers.push("explicit screen model is empty");
226
- }
227
- if (!hasComponentLibrary) {
228
- verdict = "FAIL_STRUCTURE";
229
- blockers.push("component pattern library missing");
230
- }
231
- if (!hasTokens) {
232
- verdict = "FAIL_STRUCTURE";
233
- blockers.push("design tokens missing");
234
- }
235
- if (sectionRows.some((row) => !row.pass)) verdict = "FAIL_STRUCTURE";
236
- return {
237
- verdict,
238
- blockers,
239
- profile,
240
- sectionRows,
241
- hasTokens,
242
- hasComponentLibrary,
243
- projectPath,
244
- };
245
- }
246
-
247
- function fidelityReviewContent(result) {
248
- return [
249
- "# Design Fidelity Review",
250
- "",
251
- `- Verdict: ${result.verdict}`,
252
- `- Profile: ${result.profile}`,
253
- `- Component library: ${result.hasComponentLibrary ? "present" : "missing"}`,
254
- `- Design tokens: ${result.hasTokens ? "present" : "missing"}`,
255
- "",
256
- "## Screen-By-Screen Structural Sanity",
257
- "",
258
- "| Screen | Role | Result | Actionable notes |",
259
- "|---|---|---|---|",
260
- ...result.sectionRows.map((row) => `| ${row.title} | ${row.role} | ${row.pass ? "PASS" : "FAIL"} | ${row.pass ? "coverage complete" : row.missing.join("; ")} |`),
261
- "",
262
- "## Gate Findings",
263
- "",
264
- ...(result.blockers.length > 0 ? result.blockers.map((item) => `- ${item}`) : ["- No blocking fidelity gaps found."]),
265
- "",
266
- "## Anti-Slop Lint Findings",
267
- "",
268
- `- Mode: ${result.lintMode || "warn"}`,
269
- `- Summary: P0=${result.lintSummary ? result.lintSummary.p0 : 0}, P1=${result.lintSummary ? result.lintSummary.p1 : 0}, P2=${result.lintSummary ? result.lintSummary.p2 : 0}`,
270
- "",
271
- "| Severity | ID | Screen | Message | Fix | Snippet |",
272
- "|---|---|---|---|---|---|",
273
- ...(Array.isArray(result.lintFindings) && result.lintFindings.length > 0
274
- ? result.lintFindings.map((item) => `| ${item.severity} | ${item.id} | ${item.screenId || "artifact"} | ${item.message} | ${item.fix} | ${String(item.snippet || "").replace(/\|/g, "/")} |`)
275
- : ["| - | - | - | No lint findings. | - | - |"]),
276
- "",
277
- "## Actions",
278
- "",
279
- "- Fix failed screen HTML documents so each has doctype, html/head/body, title, one h1, non-empty body, and token references.",
280
- "- Ensure design tokens and component pattern library are generated before review.",
281
- "- Remove anti-slop P0 findings from customer-facing prototype output.",
282
- "- Re-run the design-prototype skill and `sdtk-design review --artifact docs/design/prototype/index.html`.",
283
- "",
284
- ].join("\n");
285
- }
286
-
287
- function visualPolishChecks({ artifactRelativePath, artifactContent, prototypeHtml }) {
288
- const lower = String(artifactContent || "").toLowerCase();
289
- const hasResponsive = lower.includes("@media") || lower.includes("viewport") || lower.includes("mobile");
290
- const hasCta = includesAny(artifactContent, ["btn-primary", "Primary CTA", "Create workspace", "Add lead", "Continue", "Start", "button", "href="]);
291
- const hasAccessibility = includesAny(artifactContent, ["accessibility", "aria-", "button", "label", "44px", "focus"]);
292
- const hasSpacing = includesAny(artifactContent, ["gap:", "padding:", "spacing", "density", "grid", "margin"]);
293
- const modeEvidence = prototypeHtml
294
- ? "Prototype HTML visual polish uses standalone HTML signals; structural readiness is checked separately by the fidelity gate."
295
- : "Markdown artifact is checked for implementation-ready visual direction.";
296
-
297
- const rows = [];
298
- if (prototypeHtml) {
299
- const hasSemanticLandmarks = includesAny(artifactContent, ["<main", "<header", "<nav", "<section", "role=", "aria-label"]);
300
- const h1Count = (String(artifactContent || "").match(/<h1\b/gi) || []).length;
301
- const hasSupportingHeading = /<h[2-3]\b/i.test(String(artifactContent || ""));
302
- const hasHeadingOutline = h1Count === 1 && hasSupportingHeading;
303
- const hasTokenUsage = /var\(--[a-z0-9-]+\)/i.test(String(artifactContent || ""));
304
- const hasStubRisk = includesAny(artifactContent, ["placeholder text", "sample content", "lorem ipsum", "wireframe"]);
305
- rows.push(
306
- `| HTML Semantics | ${hasSemanticLandmarks ? "Pass" : "Needs attention"} | ${hasSemanticLandmarks ? "Prototype includes semantic landmarks or labelled regions." : "Add semantic landmarks such as main, nav, header, section, or labelled regions."} |`,
307
- `| Heading Outline | ${hasHeadingOutline ? "Pass" : "Needs attention"} | ${hasHeadingOutline ? "Prototype has one h1 and supporting h2/h3 structure." : "Use one h1 with supporting section headings."} |`,
308
- `| Responsive / Mobile Risk | ${hasResponsive ? "Pass" : "Needs attention"} | ${hasResponsive ? "Responsive viewport, media, or mobile behavior is represented." : "Add viewport or mobile breakpoint behavior before implementation."} |`,
309
- `| Token Usage | ${hasTokenUsage ? "Pass" : "Needs attention"} | ${hasTokenUsage ? "Prototype references CSS custom properties for visual tokens." : "Reference design tokens via CSS custom properties in generated HTML."} |`,
310
- `| CTA Clarity | ${hasCta ? "Pass" : "Needs attention"} | ${hasCta ? "A concrete action link or button is visible in the artifact." : "Add one dominant action link or button with concrete copy."} |`,
311
- `| Stub / Wireframe Risk | ${!hasStubRisk ? "Pass" : "Needs attention"} | ${!hasStubRisk ? "No obvious placeholder or wireframe copy is present in the reviewed HTML." : "Replace placeholder or wireframe copy with generated product content."} |`
312
- );
313
- } else {
314
- const hasLanding = lower.includes("landing");
315
- const hasOnboarding = lower.includes("onboarding");
316
- const hasDashboard = lower.includes("dashboard");
317
- const hasDashboardDensity = hasDashboard && includesAny(artifactContent, ["metric", "kpi", "pipeline", "status", "lead", "table", "card"]);
318
- rows.push(
319
- `| Hierarchy | ${hasLanding && hasOnboarding && hasDashboard ? "Pass" : "Needs attention"} | ${hasLanding && hasOnboarding && hasDashboard ? "Landing, onboarding, and dashboard sections create a clear read order." : "Ensure the artifact clearly separates landing, onboarding, and dashboard intent."} |`,
320
- `| Spacing / Density | ${hasSpacing ? "Pass" : "Needs attention"} | ${hasSpacing ? "Artifact includes grid, gap, padding, margin, or spacing guidance." : "Add spacing and density rules so the implementation does not regress into a flat wireframe."} |`,
321
- `| CTA Clarity | ${hasCta ? "Pass" : "Needs attention"} | ${hasCta ? "A concrete primary action is visible in the artifact." : "Add one dominant CTA with concrete action copy."} |`,
322
- `| Dashboard Information Density | ${hasDashboardDensity ? "Pass" : "Needs attention"} | ${hasDashboardDensity ? "Dashboard direction includes metrics, pipeline/status, cards, lists, or table surfaces." : "Dashboard needs enough KPI, status, list, or table detail to guide a real MVP screen."} |`,
323
- `| Responsive / Mobile Risk | ${hasResponsive ? "Pass" : "Needs attention"} | ${hasResponsive ? "Responsive or mobile behavior is represented." : "Add mobile breakpoint or narrow-screen behavior before implementation."} |`,
324
- `| Accessibility Baseline | ${hasAccessibility ? "Pass" : "Needs attention"} | ${hasAccessibility ? "Artifact includes accessible-control or baseline accessibility signals." : "Add labels, focus, touch-target, and color-not-alone guidance."} |`
325
- );
326
- }
327
-
328
- return [
329
- "## Visual Polish Checks",
330
- "",
331
- "| Check | Result | Evidence |",
332
- "|---|---|---|",
333
- ...rows,
334
- "",
335
- `- Reviewed visual artifact: \`${artifactRelativePath}\`.`,
336
- `- ${modeEvidence}`,
337
- "- Keep the output local-first: no browser, screenshot, network, Pro entitlement, or app generation is required for this review.",
338
- "",
339
- ];
340
- }
341
-
342
- function reviewContent({ artifactRelativePath, artifactContent }) {
343
- const prototypeHtml = isPrototypeHtmlArtifact(artifactRelativePath, artifactContent);
344
- const reviewMode = prototypeHtml ? "local prototype HTML review" : "local markdown artifact review";
345
- const hasPrimaryCta = hasHeading(artifactContent, "Primary CTA") || artifactContent.toLowerCase().includes("cta");
346
- const hasMobile = hasHeading(artifactContent, "Mobile Notes") || artifactContent.toLowerCase().includes("mobile");
347
- const hasStates = hasHeading(artifactContent, "State Handling") || ["empty", "success", "error"].every((term) => artifactContent.toLowerCase().includes(term));
348
- const hasAcceptance = hasHeading(artifactContent, "Acceptance Criteria");
349
-
350
- return [
351
- "# Design Review",
352
- "",
353
- "## Reviewed Artifact",
354
- "",
355
- `- Artifact: \`${artifactRelativePath}\``,
356
- `- Review mode: ${reviewMode}`,
357
- "- No URL, browser, screenshot, vision, or DOM review was used.",
358
- "",
359
- "## Findings By Severity",
360
- "",
361
- "| Severity | Finding | Recommended action |",
362
- "|---|---|---|",
363
- `| High | ${hasPrimaryCta ? "No high-severity CTA blocker found in the artifact." : "Primary CTA is missing or not explicit."} | ${hasPrimaryCta ? "Keep the CTA visually dominant during implementation." : "Add one explicit primary CTA before handoff."} |`,
364
- `| Medium | ${hasMobile ? "Mobile notes are present." : "Mobile behavior is not documented."} | ${hasMobile ? "Validate mobile layout during implementation." : "Add mobile layout notes for narrow screens."} |`,
365
- `| Medium | ${hasStates ? "State handling is represented." : "Empty, success, or error states are incomplete."} | ${hasStates ? "Carry the states into implementation acceptance criteria." : "Document empty, success, and error handling."} |`,
366
- `| Low | ${hasAcceptance ? "Acceptance criteria are present." : "Acceptance criteria are missing."} | ${hasAcceptance ? "Use criteria as SDTK-CODE test obligations." : "Add concrete acceptance criteria before coding."} |`,
367
- "",
368
- ...visualPolishChecks({ artifactRelativePath, artifactContent, prototypeHtml }),
369
- "## UX Issues",
370
- "",
371
- "- Keep the screen focused on one job and one dominant next action.",
372
- "- Avoid adding enterprise configuration or secondary flows before the MVP action works.",
373
- "- Ensure empty states teach the user what to do next instead of only saying no data exists.",
374
- "",
375
- "## CTA / Conversion Notes",
376
- "",
377
- "- The Primary CTA should be visible in the first viewport and repeated only where it helps the user finish the workflow.",
378
- "- CTA copy should use a concrete verb, such as Add, Continue, Save, or Review.",
379
- "- Supporting copy should reduce uncertainty before asking for setup or data entry.",
380
- "",
381
- "## Mobile / Accessibility Notes",
382
- "",
383
- "- Mobile layout should stay single-column with no horizontal scrolling.",
384
- "- Interactive targets should be at least 44px by 44px where practical.",
385
- "- Do not rely on color alone for status; pair it with visible text.",
386
- "- Preserve keyboard focus and accessible names for icon-only controls.",
387
- "",
388
- "## Actionable Fixes",
389
- "",
390
- "- Confirm the screen has one visually dominant primary CTA.",
391
- "- Confirm empty, success, and error states are visible in the artifact or downstream implementation plan.",
392
- "- Confirm mobile notes are present before SDTK-CODE implementation starts.",
393
- "- Confirm the artifact stays within SDTK-DESIGN scope and does not imply generated production app code.",
394
- "",
395
- "## Boundaries",
396
- "",
397
- "- No Figma, Lovable, v0, or full app-builder replacement behavior is claimed.",
398
- "- No network call, Pro entitlement, `.sdtk/atlas`, or SDTK-WIKI output is required.",
399
- "",
400
- ].join("\n");
401
- }
402
-
403
- function runDesignReview({ artifact, projectPath, force = false }) {
404
- const resolvedProjectPath = resolveProjectPath(projectPath || process.cwd());
405
- if (!fs.existsSync(resolvedProjectPath) || !fs.statSync(resolvedProjectPath).isDirectory()) {
406
- throw new ValidationError(`--project-path is not a valid directory: ${resolvedProjectPath}. No project files were changed.`);
407
- }
408
-
409
- const artifactPath = resolveArtifactPath(artifact, resolvedProjectPath);
410
- if (!fs.existsSync(artifactPath) || !fs.statSync(artifactPath).isFile()) {
411
- throw new ValidationError(`--artifact is not a readable file: ${artifactPath}. No project files were changed.`);
412
- }
413
-
414
- const paths = describeDesignPaths(resolvedProjectPath);
415
- const reportName = `DESIGN_REVIEW_${formatDateYYYYMMDD()}.md`;
416
- const reportPath = path.join(paths.reviewsPath, reportName);
417
- if (fs.existsSync(reportPath) && !force) {
418
- throw new ValidationError(`docs/design/reviews/${reportName} already exists. Re-run with --force to replace this managed review report.`);
419
- }
420
-
421
- const artifactContent = fs.readFileSync(artifactPath, "utf-8");
422
- const artifactRelativePath = toPosix(path.relative(resolvedProjectPath, artifactPath));
423
- const statePayload = readInputContractState(paths);
424
- const shouldRunFidelityGate =
425
- isPrototypeHtmlArtifact(artifactRelativePath, artifactContent) &&
426
- statePayload &&
427
- statePayload.mode === "from-spec" &&
428
- statePayload.analysisStatus === "INPUT_CONTRACT_READY" &&
429
- statePayload.screenModel &&
430
- Array.isArray(statePayload.screenModel.screens) &&
431
- statePayload.screenModel.screens.length >= 4;
432
- fs.mkdirSync(paths.reviewsPath, { recursive: true });
433
- fs.writeFileSync(reportPath, reviewContent({ artifactRelativePath, artifactContent }), "utf-8");
434
-
435
- let fidelityReportRelativePath = null;
436
- let fidelityVerdict = null;
437
- let antiSlopLint = null;
438
- if (shouldRunFidelityGate) {
439
- const fidelityResult = evaluateHighFidelityGate({
440
- statePayload,
441
- projectPath: resolvedProjectPath,
442
- paths,
443
- });
444
- const lintMode = resolveAntiSlopLintMode();
445
- const lintFindings = lintMode === "off" ? [] : lintArtifactTree(collectPrototypeHtmlMap({ artifactPath, artifactContent, paths }));
446
- const lintVerdict = applyAntiSlopVerdict({
447
- structuralVerdict: fidelityResult.verdict,
448
- lintFindings,
449
- lintMode,
450
- });
451
- fidelityResult.verdict = lintVerdict.verdict;
452
- fidelityResult.lintMode = lintMode;
453
- fidelityResult.lintFindings = lintFindings;
454
- fidelityResult.lintSummary = lintVerdict.lintSummary;
455
- fidelityResult.lintBlocking = lintVerdict.lintBlocking;
456
- antiSlopLint = { mode: lintMode, findings: lintFindings, summary: lintVerdict.lintSummary, blocking: lintVerdict.lintBlocking };
457
- const fidelityName = `DESIGN_FIDELITY_REVIEW_${formatDateYYYYMMDD()}.md`;
458
- const fidelityPath = path.join(paths.reviewsPath, fidelityName);
459
- if (fs.existsSync(fidelityPath) && !force) {
460
- throw new ValidationError(`docs/design/reviews/${fidelityName} already exists. Re-run with --force to replace this managed fidelity review report.`);
461
- }
462
- fs.writeFileSync(fidelityPath, fidelityReviewContent(fidelityResult), "utf-8");
463
- fidelityReportRelativePath = `docs/design/reviews/${fidelityName}`;
464
- fidelityVerdict = fidelityResult.verdict;
465
- }
466
-
467
- return {
468
- projectPath: resolvedProjectPath,
469
- relativeReportPath: `docs/design/reviews/${reportName}`,
470
- fidelityReportRelativePath,
471
- fidelityVerdict,
472
- antiSlopLint,
473
- forced: Boolean(force),
474
- };
475
- }
476
-
477
- function cmdReview(args) {
478
- const { flags } = parseFlags(args || [], REVIEW_FLAG_DEFS);
479
- if (flags.help) return cmdReviewHelp();
480
-
481
- const result = runDesignReview({
482
- artifact: flags.artifact,
483
- projectPath: flags["project-path"],
484
- force: Boolean(flags.force),
485
- });
486
-
487
- console.log(`[design] Wrote ${result.relativeReportPath}: ${result.projectPath}`);
488
- if (result.fidelityReportRelativePath) {
489
- console.log(`[design] Wrote ${result.fidelityReportRelativePath}: ${result.projectPath}`);
490
- console.log(`[design] Fidelity verdict: ${result.fidelityVerdict}`);
491
- if (result.antiSlopLint) {
492
- if (result.antiSlopLint.mode === "off") {
493
- console.log("[design] Anti-slop lint: OFF (emergency rollback)");
494
- } else if (result.antiSlopLint.summary.p0 > 0) {
495
- console.log(`[design] Anti-slop lint: ${result.antiSlopLint.summary.p0} P0 findings; see review report`);
496
- } else {
497
- console.log(`[design] Anti-slop lint: PASS (P1=${result.antiSlopLint.summary.p1}, P2=${result.antiSlopLint.summary.p2})`);
498
- }
499
- }
500
- }
501
- console.log(`[design] Review mode: local artifact`);
502
- console.log(`[design] Overwrite: ${result.forced ? "enabled by --force" : "not needed"}`);
503
- console.log("[design] No URL, browser, screenshot, vision, network, .sdtk/atlas, or SDTK-WIKI output was used.");
504
- return result.antiSlopLint && result.antiSlopLint.blocking ? 1 : 0;
505
- }
506
-
507
- module.exports = {
508
- cmdReview,
509
- cmdReviewHelp,
510
- formatDateYYYYMMDD,
511
- resolveAntiSlopLintMode,
512
- isPrototypeHtmlArtifact,
513
- reviewContent,
514
- runDesignReview,
515
- };
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { parseFlags } = require("../lib/args");
6
+ const { lintArtifactTree, summarizeLintFindings } = require("../lib/anti-slop-lint");
7
+ const { describeDesignPaths, isPathInsideOrEqual, resolveProjectPath } = require("../lib/design-paths");
8
+ const { ValidationError } = require("../lib/errors");
9
+
10
+ const REVIEW_FLAG_DEFS = {
11
+ help: { type: "boolean" },
12
+ artifact: { type: "string" },
13
+ "project-path": { type: "string" },
14
+ force: { type: "boolean" },
15
+ };
16
+
17
+ function cmdReviewHelp() {
18
+ console.log(`SDTK-DESIGN Review
19
+
20
+ Usage:
21
+ sdtk-design review --artifact docs/design/wireframes/LANDING.md [--project-path <path>] [--force]
22
+ sdtk-design review --artifact docs/design/prototype/index.html [--project-path <path>] [--force]
23
+
24
+ Example:
25
+ sdtk-design review --artifact docs/design/wireframes/LANDING.md
26
+ sdtk-design review --artifact docs/design/prototype/index.html
27
+
28
+ Reads:
29
+ A project-local markdown or static HTML design artifact.
30
+
31
+ Creates:
32
+ docs/design/reviews/DESIGN_REVIEW_YYYYMMDD.md
33
+
34
+ Safety:
35
+ Local artifact review only.
36
+ Existing same-day review report is not overwritten unless --force is explicit.
37
+ No URL, browser, screenshot, vision, or DOM review.
38
+ No .sdtk/atlas creation or mutation.
39
+ No SDTK-WIKI output mutation.
40
+ No network call, Pro entitlement, or production app code generation.`);
41
+ return 0;
42
+ }
43
+
44
+ function formatDateYYYYMMDD(date = new Date()) {
45
+ return date.toISOString().slice(0, 10).replace(/-/g, "");
46
+ }
47
+
48
+ function toPosix(value) {
49
+ return String(value || "").replace(/\\/g, "/");
50
+ }
51
+
52
+ function resolveArtifactPath(artifact, projectPath) {
53
+ const raw = String(artifact || "").trim();
54
+ if (!raw) {
55
+ throw new ValidationError('Missing required --artifact "<path>". No project files were changed.');
56
+ }
57
+ const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(projectPath, raw);
58
+ if (!isPathInsideOrEqual(resolved, projectPath)) {
59
+ throw new ValidationError(`Refusing to review artifact outside project root: ${resolved}. No project files were changed.`);
60
+ }
61
+ return resolved;
62
+ }
63
+
64
+ function hasHeading(content, heading) {
65
+ return content.toLowerCase().includes(heading.toLowerCase());
66
+ }
67
+
68
+ function isPrototypeHtmlArtifact(artifactRelativePath, artifactContent) {
69
+ const lowerPath = toPosix(artifactRelativePath).toLowerCase();
70
+ const lowerContent = String(artifactContent || "").toLowerCase();
71
+ return lowerPath.endsWith(".html") || lowerContent.includes("<!doctype html") || lowerContent.includes("<html");
72
+ }
73
+
74
+ function includesAny(content, terms) {
75
+ const lower = String(content || "").toLowerCase();
76
+ return terms.some((term) => lower.includes(term.toLowerCase()));
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 listPrototypeScreenFiles(paths) {
91
+ if (!fs.existsSync(paths.prototypeScreensPath) || !fs.statSync(paths.prototypeScreensPath).isDirectory()) {
92
+ return [];
93
+ }
94
+ return fs
95
+ .readdirSync(paths.prototypeScreensPath)
96
+ .filter((name) => name.toLowerCase().endsWith(".html"))
97
+ .sort()
98
+ .map((name) => path.join(paths.prototypeScreensPath, name));
99
+ }
100
+
101
+ function resolveAntiSlopLintMode(env = process.env) {
102
+ const value = String(env.SDTK_DESIGN_ANTI_SLOP_LINT || "on").toLowerCase();
103
+ return value === "off" ? "off" : "on";
104
+ }
105
+
106
+ function collectPrototypeHtmlMap({ artifactPath, artifactContent, paths }) {
107
+ const map = new Map();
108
+ const add = (screenId, html) => {
109
+ if (!map.has(screenId)) map.set(screenId, html);
110
+ };
111
+ for (const filePath of listPrototypeScreenFiles(paths)) {
112
+ add(path.basename(filePath, ".html"), fs.readFileSync(filePath, "utf-8"));
113
+ }
114
+ if (map.size === 0 && path.resolve(path.dirname(artifactPath)) === path.resolve(paths.prototypeScreensPath)) {
115
+ add(path.basename(artifactPath, ".html") || "artifact", artifactContent);
116
+ }
117
+ return map;
118
+ }
119
+
120
+ function applyAntiSlopVerdict({ structuralVerdict, lintFindings, lintMode }) {
121
+ const lintSummary = summarizeLintFindings(lintFindings);
122
+ const lintBlocking = lintMode !== "off" && lintSummary.p0 > 0;
123
+ return {
124
+ verdict: lintBlocking ? "FAIL_WITH_RENDERER_SLOP" : structuralVerdict,
125
+ lintSummary,
126
+ lintBlocking,
127
+ };
128
+ }
129
+
130
+ function extractTagContent(html, tagName) {
131
+ const match = String(html || "").match(new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "i"));
132
+ return match ? match[1] : "";
133
+ }
134
+
135
+ function stripTags(value) {
136
+ return String(value || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
137
+ }
138
+
139
+ function countTag(html, tagName) {
140
+ return (String(html || "").match(new RegExp(`<${tagName}\\b`, "gi")) || []).length;
141
+ }
142
+
143
+ function collectDesignTokenReferences(paths) {
144
+ if (!fs.existsSync(paths.designTokensPath)) return [];
145
+ try {
146
+ const payload = JSON.parse(fs.readFileSync(paths.designTokensPath, "utf-8"));
147
+ const values = [];
148
+ const visit = (value) => {
149
+ if (value == null) return;
150
+ if (typeof value === "string" || typeof value === "number") {
151
+ const token = String(value).trim();
152
+ if (token) values.push(token.toLowerCase());
153
+ return;
154
+ }
155
+ if (Array.isArray(value)) {
156
+ value.forEach(visit);
157
+ return;
158
+ }
159
+ if (typeof value === "object") Object.values(value).forEach(visit);
160
+ };
161
+ visit(payload);
162
+ return values.filter((value) => value.length >= 3);
163
+ } catch (_err) {
164
+ return [];
165
+ }
166
+ }
167
+
168
+ function hasDesignTokenReference(html, tokenReferences) {
169
+ const lower = String(html || "").toLowerCase();
170
+ if (/var\(--[a-z0-9-]+\)/i.test(lower)) return true;
171
+ return tokenReferences.some((token) => lower.includes(token));
172
+ }
173
+
174
+ function screenStructuralFindings({ screenId, html, tokenReferences }) {
175
+ const findings = [];
176
+ const lower = String(html || "").toLowerCase();
177
+ const title = stripTags(extractTagContent(html, "title"));
178
+ const body = extractTagContent(html, "body");
179
+ if (!lower.trimStart().startsWith("<!doctype html>")) findings.push("missing doctype");
180
+ if (!/<html\b/i.test(html)) findings.push("missing html element");
181
+ if (!/<head\b/i.test(html)) findings.push("missing head element");
182
+ if (!/<body\b/i.test(html)) findings.push("missing body element");
183
+ if (!title) findings.push("missing or empty title");
184
+ const h1Count = countTag(html, "h1");
185
+ if (h1Count !== 1) findings.push(`expected exactly one h1, found ${h1Count}`);
186
+ if (!stripTags(body)) findings.push("empty body");
187
+ if (!hasDesignTokenReference(html, tokenReferences)) findings.push("missing design token reference");
188
+ return {
189
+ screenId,
190
+ pass: findings.length === 0,
191
+ findings,
192
+ };
193
+ }
194
+
195
+ function evaluateHighFidelityGate({ statePayload, projectPath, paths }) {
196
+ const screens = statePayload.screenModel.screens || [];
197
+ const profile = statePayload.profileSelection || "none";
198
+ const hasTokens = fs.existsSync(paths.designTokensPath);
199
+ const hasComponentLibrary = fs.existsSync(paths.componentPatternLibraryPath);
200
+ const tokenReferences = collectDesignTokenReferences(paths);
201
+ const sectionRows = [];
202
+ const blockers = [];
203
+ for (const screen of screens) {
204
+ const id = String(screen.screenId || "");
205
+ const title = String(screen.title || id);
206
+ const filePath = path.join(paths.prototypeScreensPath, `${id}.html`);
207
+ if (!fs.existsSync(filePath)) {
208
+ const missing = [`missing screen file: docs/design/prototype/screens/${id}.html`];
209
+ blockers.push(`${title}: ${missing.join("; ")}`);
210
+ sectionRows.push({ title, role: id, missing, pass: false });
211
+ continue;
212
+ }
213
+ const structural = screenStructuralFindings({
214
+ screenId: id,
215
+ html: fs.readFileSync(filePath, "utf-8"),
216
+ tokenReferences,
217
+ });
218
+ if (!structural.pass) blockers.push(`${title}: ${structural.findings.join("; ")}`);
219
+ sectionRows.push({ title, role: id, missing: structural.findings, pass: structural.pass });
220
+ }
221
+
222
+ let verdict = "PASS_HIGH_FIDELITY_READY";
223
+ if (screens.length === 0) {
224
+ verdict = "FAIL_STRUCTURE";
225
+ blockers.push("explicit screen model is empty");
226
+ }
227
+ if (!hasComponentLibrary) {
228
+ verdict = "FAIL_STRUCTURE";
229
+ blockers.push("component pattern library missing");
230
+ }
231
+ if (!hasTokens) {
232
+ verdict = "FAIL_STRUCTURE";
233
+ blockers.push("design tokens missing");
234
+ }
235
+ if (sectionRows.some((row) => !row.pass)) verdict = "FAIL_STRUCTURE";
236
+ return {
237
+ verdict,
238
+ blockers,
239
+ profile,
240
+ sectionRows,
241
+ hasTokens,
242
+ hasComponentLibrary,
243
+ projectPath,
244
+ };
245
+ }
246
+
247
+ function fidelityReviewContent(result) {
248
+ return [
249
+ "# Design Fidelity Review",
250
+ "",
251
+ `- Verdict: ${result.verdict}`,
252
+ `- Profile: ${result.profile}`,
253
+ `- Component library: ${result.hasComponentLibrary ? "present" : "missing"}`,
254
+ `- Design tokens: ${result.hasTokens ? "present" : "missing"}`,
255
+ "",
256
+ "## Screen-By-Screen Structural Sanity",
257
+ "",
258
+ "| Screen | Role | Result | Actionable notes |",
259
+ "|---|---|---|---|",
260
+ ...result.sectionRows.map((row) => `| ${row.title} | ${row.role} | ${row.pass ? "PASS" : "FAIL"} | ${row.pass ? "coverage complete" : row.missing.join("; ")} |`),
261
+ "",
262
+ "## Gate Findings",
263
+ "",
264
+ ...(result.blockers.length > 0 ? result.blockers.map((item) => `- ${item}`) : ["- No blocking fidelity gaps found."]),
265
+ "",
266
+ "## Anti-Slop Lint Findings",
267
+ "",
268
+ `- Mode: ${result.lintMode || "warn"}`,
269
+ `- Summary: P0=${result.lintSummary ? result.lintSummary.p0 : 0}, P1=${result.lintSummary ? result.lintSummary.p1 : 0}, P2=${result.lintSummary ? result.lintSummary.p2 : 0}`,
270
+ "",
271
+ "| Severity | ID | Screen | Message | Fix | Snippet |",
272
+ "|---|---|---|---|---|---|",
273
+ ...(Array.isArray(result.lintFindings) && result.lintFindings.length > 0
274
+ ? result.lintFindings.map((item) => `| ${item.severity} | ${item.id} | ${item.screenId || "artifact"} | ${item.message} | ${item.fix} | ${String(item.snippet || "").replace(/\|/g, "/")} |`)
275
+ : ["| - | - | - | No lint findings. | - | - |"]),
276
+ "",
277
+ "## Actions",
278
+ "",
279
+ "- Fix failed screen HTML documents so each has doctype, html/head/body, title, one h1, non-empty body, and token references.",
280
+ "- Ensure design tokens and component pattern library are generated before review.",
281
+ "- Remove anti-slop P0 findings from customer-facing prototype output.",
282
+ "- Re-run the design-prototype skill and `sdtk-design review --artifact docs/design/prototype/index.html`.",
283
+ "",
284
+ ].join("\n");
285
+ }
286
+
287
+ function visualPolishChecks({ artifactRelativePath, artifactContent, prototypeHtml }) {
288
+ const lower = String(artifactContent || "").toLowerCase();
289
+ const hasResponsive = lower.includes("@media") || lower.includes("viewport") || lower.includes("mobile");
290
+ const hasCta = includesAny(artifactContent, ["btn-primary", "Primary CTA", "Create workspace", "Add lead", "Continue", "Start", "button", "href="]);
291
+ const hasAccessibility = includesAny(artifactContent, ["accessibility", "aria-", "button", "label", "44px", "focus"]);
292
+ const hasSpacing = includesAny(artifactContent, ["gap:", "padding:", "spacing", "density", "grid", "margin"]);
293
+ const modeEvidence = prototypeHtml
294
+ ? "Prototype HTML visual polish uses standalone HTML signals; structural readiness is checked separately by the fidelity gate."
295
+ : "Markdown artifact is checked for implementation-ready visual direction.";
296
+
297
+ const rows = [];
298
+ if (prototypeHtml) {
299
+ const hasSemanticLandmarks = includesAny(artifactContent, ["<main", "<header", "<nav", "<section", "role=", "aria-label"]);
300
+ const h1Count = (String(artifactContent || "").match(/<h1\b/gi) || []).length;
301
+ const hasSupportingHeading = /<h[2-3]\b/i.test(String(artifactContent || ""));
302
+ const hasHeadingOutline = h1Count === 1 && hasSupportingHeading;
303
+ const hasTokenUsage = /var\(--[a-z0-9-]+\)/i.test(String(artifactContent || ""));
304
+ const hasStubRisk = includesAny(artifactContent, ["placeholder text", "sample content", "lorem ipsum", "wireframe"]);
305
+ rows.push(
306
+ `| HTML Semantics | ${hasSemanticLandmarks ? "Pass" : "Needs attention"} | ${hasSemanticLandmarks ? "Prototype includes semantic landmarks or labelled regions." : "Add semantic landmarks such as main, nav, header, section, or labelled regions."} |`,
307
+ `| Heading Outline | ${hasHeadingOutline ? "Pass" : "Needs attention"} | ${hasHeadingOutline ? "Prototype has one h1 and supporting h2/h3 structure." : "Use one h1 with supporting section headings."} |`,
308
+ `| Responsive / Mobile Risk | ${hasResponsive ? "Pass" : "Needs attention"} | ${hasResponsive ? "Responsive viewport, media, or mobile behavior is represented." : "Add viewport or mobile breakpoint behavior before implementation."} |`,
309
+ `| Token Usage | ${hasTokenUsage ? "Pass" : "Needs attention"} | ${hasTokenUsage ? "Prototype references CSS custom properties for visual tokens." : "Reference design tokens via CSS custom properties in generated HTML."} |`,
310
+ `| CTA Clarity | ${hasCta ? "Pass" : "Needs attention"} | ${hasCta ? "A concrete action link or button is visible in the artifact." : "Add one dominant action link or button with concrete copy."} |`,
311
+ `| Stub / Wireframe Risk | ${!hasStubRisk ? "Pass" : "Needs attention"} | ${!hasStubRisk ? "No obvious placeholder or wireframe copy is present in the reviewed HTML." : "Replace placeholder or wireframe copy with generated product content."} |`
312
+ );
313
+ } else {
314
+ const hasLanding = lower.includes("landing");
315
+ const hasOnboarding = lower.includes("onboarding");
316
+ const hasDashboard = lower.includes("dashboard");
317
+ const hasDashboardDensity = hasDashboard && includesAny(artifactContent, ["metric", "kpi", "pipeline", "status", "lead", "table", "card"]);
318
+ rows.push(
319
+ `| Hierarchy | ${hasLanding && hasOnboarding && hasDashboard ? "Pass" : "Needs attention"} | ${hasLanding && hasOnboarding && hasDashboard ? "Landing, onboarding, and dashboard sections create a clear read order." : "Ensure the artifact clearly separates landing, onboarding, and dashboard intent."} |`,
320
+ `| Spacing / Density | ${hasSpacing ? "Pass" : "Needs attention"} | ${hasSpacing ? "Artifact includes grid, gap, padding, margin, or spacing guidance." : "Add spacing and density rules so the implementation does not regress into a flat wireframe."} |`,
321
+ `| CTA Clarity | ${hasCta ? "Pass" : "Needs attention"} | ${hasCta ? "A concrete primary action is visible in the artifact." : "Add one dominant CTA with concrete action copy."} |`,
322
+ `| Dashboard Information Density | ${hasDashboardDensity ? "Pass" : "Needs attention"} | ${hasDashboardDensity ? "Dashboard direction includes metrics, pipeline/status, cards, lists, or table surfaces." : "Dashboard needs enough KPI, status, list, or table detail to guide a real MVP screen."} |`,
323
+ `| Responsive / Mobile Risk | ${hasResponsive ? "Pass" : "Needs attention"} | ${hasResponsive ? "Responsive or mobile behavior is represented." : "Add mobile breakpoint or narrow-screen behavior before implementation."} |`,
324
+ `| Accessibility Baseline | ${hasAccessibility ? "Pass" : "Needs attention"} | ${hasAccessibility ? "Artifact includes accessible-control or baseline accessibility signals." : "Add labels, focus, touch-target, and color-not-alone guidance."} |`
325
+ );
326
+ }
327
+
328
+ return [
329
+ "## Visual Polish Checks",
330
+ "",
331
+ "| Check | Result | Evidence |",
332
+ "|---|---|---|",
333
+ ...rows,
334
+ "",
335
+ `- Reviewed visual artifact: \`${artifactRelativePath}\`.`,
336
+ `- ${modeEvidence}`,
337
+ "- Keep the output local-first: no browser, screenshot, network, Pro entitlement, or app generation is required for this review.",
338
+ "",
339
+ ];
340
+ }
341
+
342
+ function reviewContent({ artifactRelativePath, artifactContent }) {
343
+ const prototypeHtml = isPrototypeHtmlArtifact(artifactRelativePath, artifactContent);
344
+ const reviewMode = prototypeHtml ? "local prototype HTML review" : "local markdown artifact review";
345
+ const hasPrimaryCta = hasHeading(artifactContent, "Primary CTA") || artifactContent.toLowerCase().includes("cta");
346
+ const hasMobile = hasHeading(artifactContent, "Mobile Notes") || artifactContent.toLowerCase().includes("mobile");
347
+ const hasStates = hasHeading(artifactContent, "State Handling") || ["empty", "success", "error"].every((term) => artifactContent.toLowerCase().includes(term));
348
+ const hasAcceptance = hasHeading(artifactContent, "Acceptance Criteria");
349
+
350
+ return [
351
+ "# Design Review",
352
+ "",
353
+ "## Reviewed Artifact",
354
+ "",
355
+ `- Artifact: \`${artifactRelativePath}\``,
356
+ `- Review mode: ${reviewMode}`,
357
+ "- No URL, browser, screenshot, vision, or DOM review was used.",
358
+ "",
359
+ "## Findings By Severity",
360
+ "",
361
+ "| Severity | Finding | Recommended action |",
362
+ "|---|---|---|",
363
+ `| High | ${hasPrimaryCta ? "No high-severity CTA blocker found in the artifact." : "Primary CTA is missing or not explicit."} | ${hasPrimaryCta ? "Keep the CTA visually dominant during implementation." : "Add one explicit primary CTA before handoff."} |`,
364
+ `| Medium | ${hasMobile ? "Mobile notes are present." : "Mobile behavior is not documented."} | ${hasMobile ? "Validate mobile layout during implementation." : "Add mobile layout notes for narrow screens."} |`,
365
+ `| Medium | ${hasStates ? "State handling is represented." : "Empty, success, or error states are incomplete."} | ${hasStates ? "Carry the states into implementation acceptance criteria." : "Document empty, success, and error handling."} |`,
366
+ `| Low | ${hasAcceptance ? "Acceptance criteria are present." : "Acceptance criteria are missing."} | ${hasAcceptance ? "Use criteria as SDTK-CODE test obligations." : "Add concrete acceptance criteria before coding."} |`,
367
+ "",
368
+ ...visualPolishChecks({ artifactRelativePath, artifactContent, prototypeHtml }),
369
+ "## UX Issues",
370
+ "",
371
+ "- Keep the screen focused on one job and one dominant next action.",
372
+ "- Avoid adding enterprise configuration or secondary flows before the MVP action works.",
373
+ "- Ensure empty states teach the user what to do next instead of only saying no data exists.",
374
+ "",
375
+ "## CTA / Conversion Notes",
376
+ "",
377
+ "- The Primary CTA should be visible in the first viewport and repeated only where it helps the user finish the workflow.",
378
+ "- CTA copy should use a concrete verb, such as Add, Continue, Save, or Review.",
379
+ "- Supporting copy should reduce uncertainty before asking for setup or data entry.",
380
+ "",
381
+ "## Mobile / Accessibility Notes",
382
+ "",
383
+ "- Mobile layout should stay single-column with no horizontal scrolling.",
384
+ "- Interactive targets should be at least 44px by 44px where practical.",
385
+ "- Do not rely on color alone for status; pair it with visible text.",
386
+ "- Preserve keyboard focus and accessible names for icon-only controls.",
387
+ "",
388
+ "## Actionable Fixes",
389
+ "",
390
+ "- Confirm the screen has one visually dominant primary CTA.",
391
+ "- Confirm empty, success, and error states are visible in the artifact or downstream implementation plan.",
392
+ "- Confirm mobile notes are present before SDTK-CODE implementation starts.",
393
+ "- Confirm the artifact stays within SDTK-DESIGN scope and does not imply generated production app code.",
394
+ "",
395
+ "## Boundaries",
396
+ "",
397
+ "- No Figma, Lovable, v0, or full app-builder replacement behavior is claimed.",
398
+ "- No network call, Pro entitlement, `.sdtk/atlas`, or SDTK-WIKI output is required.",
399
+ "",
400
+ ].join("\n");
401
+ }
402
+
403
+ function runDesignReview({ artifact, projectPath, force = false }) {
404
+ const resolvedProjectPath = resolveProjectPath(projectPath || process.cwd());
405
+ if (!fs.existsSync(resolvedProjectPath) || !fs.statSync(resolvedProjectPath).isDirectory()) {
406
+ throw new ValidationError(`--project-path is not a valid directory: ${resolvedProjectPath}. No project files were changed.`);
407
+ }
408
+
409
+ const artifactPath = resolveArtifactPath(artifact, resolvedProjectPath);
410
+ if (!fs.existsSync(artifactPath) || !fs.statSync(artifactPath).isFile()) {
411
+ throw new ValidationError(`--artifact is not a readable file: ${artifactPath}. No project files were changed.`);
412
+ }
413
+
414
+ const paths = describeDesignPaths(resolvedProjectPath);
415
+ const reportName = `DESIGN_REVIEW_${formatDateYYYYMMDD()}.md`;
416
+ const reportPath = path.join(paths.reviewsPath, reportName);
417
+ if (fs.existsSync(reportPath) && !force) {
418
+ throw new ValidationError(`docs/design/reviews/${reportName} already exists. Re-run with --force to replace this managed review report.`);
419
+ }
420
+
421
+ const artifactContent = fs.readFileSync(artifactPath, "utf-8");
422
+ const artifactRelativePath = toPosix(path.relative(resolvedProjectPath, artifactPath));
423
+ const statePayload = readInputContractState(paths);
424
+ const shouldRunFidelityGate =
425
+ isPrototypeHtmlArtifact(artifactRelativePath, artifactContent) &&
426
+ statePayload &&
427
+ statePayload.mode === "from-spec" &&
428
+ statePayload.analysisStatus === "INPUT_CONTRACT_READY" &&
429
+ statePayload.screenModel &&
430
+ Array.isArray(statePayload.screenModel.screens) &&
431
+ statePayload.screenModel.screens.length >= 4;
432
+ fs.mkdirSync(paths.reviewsPath, { recursive: true });
433
+ fs.writeFileSync(reportPath, reviewContent({ artifactRelativePath, artifactContent }), "utf-8");
434
+
435
+ let fidelityReportRelativePath = null;
436
+ let fidelityVerdict = null;
437
+ let antiSlopLint = null;
438
+ if (shouldRunFidelityGate) {
439
+ const fidelityResult = evaluateHighFidelityGate({
440
+ statePayload,
441
+ projectPath: resolvedProjectPath,
442
+ paths,
443
+ });
444
+ const lintMode = resolveAntiSlopLintMode();
445
+ const lintFindings = lintMode === "off" ? [] : lintArtifactTree(collectPrototypeHtmlMap({ artifactPath, artifactContent, paths }));
446
+ const lintVerdict = applyAntiSlopVerdict({
447
+ structuralVerdict: fidelityResult.verdict,
448
+ lintFindings,
449
+ lintMode,
450
+ });
451
+ fidelityResult.verdict = lintVerdict.verdict;
452
+ fidelityResult.lintMode = lintMode;
453
+ fidelityResult.lintFindings = lintFindings;
454
+ fidelityResult.lintSummary = lintVerdict.lintSummary;
455
+ fidelityResult.lintBlocking = lintVerdict.lintBlocking;
456
+ antiSlopLint = { mode: lintMode, findings: lintFindings, summary: lintVerdict.lintSummary, blocking: lintVerdict.lintBlocking };
457
+ const fidelityName = `DESIGN_FIDELITY_REVIEW_${formatDateYYYYMMDD()}.md`;
458
+ const fidelityPath = path.join(paths.reviewsPath, fidelityName);
459
+ if (fs.existsSync(fidelityPath) && !force) {
460
+ throw new ValidationError(`docs/design/reviews/${fidelityName} already exists. Re-run with --force to replace this managed fidelity review report.`);
461
+ }
462
+ fs.writeFileSync(fidelityPath, fidelityReviewContent(fidelityResult), "utf-8");
463
+ fidelityReportRelativePath = `docs/design/reviews/${fidelityName}`;
464
+ fidelityVerdict = fidelityResult.verdict;
465
+ }
466
+
467
+ return {
468
+ projectPath: resolvedProjectPath,
469
+ relativeReportPath: `docs/design/reviews/${reportName}`,
470
+ fidelityReportRelativePath,
471
+ fidelityVerdict,
472
+ antiSlopLint,
473
+ forced: Boolean(force),
474
+ };
475
+ }
476
+
477
+ function cmdReview(args) {
478
+ const { flags } = parseFlags(args || [], REVIEW_FLAG_DEFS);
479
+ if (flags.help) return cmdReviewHelp();
480
+
481
+ const result = runDesignReview({
482
+ artifact: flags.artifact,
483
+ projectPath: flags["project-path"],
484
+ force: Boolean(flags.force),
485
+ });
486
+
487
+ console.log(`[design] Wrote ${result.relativeReportPath}: ${result.projectPath}`);
488
+ if (result.fidelityReportRelativePath) {
489
+ console.log(`[design] Wrote ${result.fidelityReportRelativePath}: ${result.projectPath}`);
490
+ console.log(`[design] Fidelity verdict: ${result.fidelityVerdict}`);
491
+ if (result.antiSlopLint) {
492
+ if (result.antiSlopLint.mode === "off") {
493
+ console.log("[design] Anti-slop lint: OFF (emergency rollback)");
494
+ } else if (result.antiSlopLint.summary.p0 > 0) {
495
+ console.log(`[design] Anti-slop lint: ${result.antiSlopLint.summary.p0} P0 findings; see review report`);
496
+ } else {
497
+ console.log(`[design] Anti-slop lint: PASS (P1=${result.antiSlopLint.summary.p1}, P2=${result.antiSlopLint.summary.p2})`);
498
+ }
499
+ }
500
+ }
501
+ console.log(`[design] Review mode: local artifact`);
502
+ console.log(`[design] Overwrite: ${result.forced ? "enabled by --force" : "not needed"}`);
503
+ console.log("[design] No URL, browser, screenshot, vision, network, .sdtk/atlas, or SDTK-WIKI output was used.");
504
+ return result.antiSlopLint && result.antiSlopLint.blocking ? 1 : 0;
505
+ }
506
+
507
+ module.exports = {
508
+ cmdReview,
509
+ cmdReviewHelp,
510
+ formatDateYYYYMMDD,
511
+ resolveAntiSlopLintMode,
512
+ isPrototypeHtmlArtifact,
513
+ reviewContent,
514
+ runDesignReview,
515
+ };