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.
- package/package.json +1 -1
- package/src/commands/handoff.js +357 -203
- package/src/commands/help.js +2 -2
- package/src/commands/prototype.js +291 -18
- package/src/commands/review.js +298 -1
- package/src/commands/start.js +23 -1
- package/src/lib/anti-slop-lint.js +210 -0
- package/src/lib/component-contract.js +142 -0
- package/src/lib/design-input-contract.js +271 -11
- package/src/lib/design-paths.js +24 -0
- package/src/lib/prototype-briefs.js +125 -0
- package/src/lib/prototype-component-map.js +219 -0
- package/src/lib/prototype-density.js +377 -0
- package/src/lib/prototype-renderer.js +382 -0
- package/src/lib/screen-briefs.js +340 -0
package/src/commands/review.js
CHANGED
|
@@ -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,
|
package/src/commands/start.js
CHANGED
|
@@ -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/
|
|
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
|
+
};
|