sdtk-design-kit 0.2.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdtk-design-kit",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Local-first MVP design planner and reviewer for SDTK workspaces.",
5
5
  "bin": {
6
6
  "sdtk-design": "bin/sdtk-design.js"
@@ -736,11 +736,25 @@ function cmdPrototype(args) {
736
736
  console.log(`[design] Style: ${result.style}`);
737
737
  console.log(`[design] Mode: ${result.mode} (${result.screenCount} screen(s))`);
738
738
  if (result.densityReport) {
739
- console.log(
740
- `[design] Density: ${result.densityReport.pass ? "PASS" : "FAIL"} total=${result.densityReport.totalBytes}/${result.densityReport.minTotalBytes} bytes`
741
- );
742
- for (const page of result.densityReport.pages) {
743
- console.log(`[design] Density page: ${page.screenId} ${page.byteLength}/${page.minBytes} bytes ${page.pass ? "PASS" : "FAIL"}`);
739
+ if (result.densityReport.mode === "bytes") {
740
+ console.log("[design] Density mode: bytes (deprecated rollback; report shape only, no filler emission)");
741
+ console.log(
742
+ `[design] Density: ${result.densityReport.pass ? "PASS" : "FAIL"} total=${result.densityReport.totalBytes}/${result.densityReport.minTotalBytes} bytes`
743
+ );
744
+ for (const page of result.densityReport.pages) {
745
+ console.log(`[design] Density page: ${page.screenId} ${page.byteLength}/${page.minBytes} bytes ${page.pass ? "PASS" : "FAIL"}`);
746
+ }
747
+ } else {
748
+ const aggregate = result.densityReport.aggregate || {};
749
+ console.log(
750
+ `[design] Density: ${result.densityReport.pass ? "PASS" : "FAIL"} mode=semantic sections=${aggregate.sectionCount || 0} components=${aggregate.componentCount || 0} dataSlots=${aggregate.dataSlotCount || 0} states=${aggregate.stateCount || 0}`
751
+ );
752
+ for (const page of result.densityReport.pages) {
753
+ const missing = Array.isArray(page.findings) && page.findings.length > 0 ? ` (${page.findings.join("; ")})` : "";
754
+ console.log(
755
+ `[design] Density page: ${page.screenId} sections=${page.sectionCount} components=${page.componentCount} dataSlots=${page.dataSlotCount} states=${page.stateCount} ${page.pass ? "PASS" : "FAIL"}${missing}`
756
+ );
757
+ }
744
758
  }
745
759
  }
746
760
  if (Array.isArray(result.warnings) && result.warnings.length > 0) {
@@ -750,7 +764,7 @@ function cmdPrototype(args) {
750
764
  console.log("[design] Prototype mode: static HTML/CSS preview only.");
751
765
  console.log("[design] No app code outside docs/design/prototype, server, network, .sdtk/atlas, or SDTK-WIKI output was modified.");
752
766
  console.log("[design] Next: sdtk-design review --artifact docs/design/prototype/index.html");
753
- return 0;
767
+ return result.densityReport && result.densityReport.mode !== "bytes" && !result.densityReport.pass ? 1 : 0;
754
768
  }
755
769
 
756
770
  module.exports = {
@@ -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
 
@@ -146,6 +147,36 @@ function combinedPrototypeReviewContent({ artifactPath, artifactContent, paths }
146
147
  return parts.join("\n");
147
148
  }
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
+
149
180
  function evaluateHighFidelityGate({ artifactContent, statePayload, projectPath, paths }) {
150
181
  const html = String(artifactContent || "").toLowerCase();
151
182
  const screens = statePayload.screenModel.screens || [];
@@ -256,6 +287,17 @@ function fidelityReviewContent(result) {
256
287
  "",
257
288
  ...(result.blockers.length > 0 ? result.blockers.map((item) => `- ${item}`) : ["- No blocking fidelity gaps found."]),
258
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
+ "",
259
301
  "## Actions",
260
302
  "",
261
303
  "- Fill missing component markers per failed screen.",
@@ -396,6 +438,7 @@ function runDesignReview({ artifact, projectPath, force = false }) {
396
438
 
397
439
  let fidelityReportRelativePath = null;
398
440
  let fidelityVerdict = null;
441
+ let antiSlopLint = null;
399
442
  if (shouldRunFidelityGate) {
400
443
  const fidelityArtifactContent = combinedPrototypeReviewContent({
401
444
  artifactPath,
@@ -408,6 +451,19 @@ function runDesignReview({ artifact, projectPath, force = false }) {
408
451
  projectPath: resolvedProjectPath,
409
452
  paths,
410
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 };
411
467
  const fidelityName = `DESIGN_FIDELITY_REVIEW_${formatDateYYYYMMDD()}.md`;
412
468
  const fidelityPath = path.join(paths.reviewsPath, fidelityName);
413
469
  if (fs.existsSync(fidelityPath) && !force) {
@@ -423,6 +479,7 @@ function runDesignReview({ artifact, projectPath, force = false }) {
423
479
  relativeReportPath: `docs/design/reviews/${reportName}`,
424
480
  fidelityReportRelativePath,
425
481
  fidelityVerdict,
482
+ antiSlopLint,
426
483
  forced: Boolean(force),
427
484
  };
428
485
  }
@@ -441,17 +498,27 @@ function cmdReview(args) {
441
498
  if (result.fidelityReportRelativePath) {
442
499
  console.log(`[design] Wrote ${result.fidelityReportRelativePath}: ${result.projectPath}`);
443
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
+ }
444
510
  }
445
511
  console.log(`[design] Review mode: local artifact`);
446
512
  console.log(`[design] Overwrite: ${result.forced ? "enabled by --force" : "not needed"}`);
447
513
  console.log("[design] No URL, browser, screenshot, vision, network, .sdtk/atlas, or SDTK-WIKI output was used.");
448
- return 0;
514
+ return result.antiSlopLint && result.antiSlopLint.blocking ? 1 : 0;
449
515
  }
450
516
 
451
517
  module.exports = {
452
518
  cmdReview,
453
519
  cmdReviewHelp,
454
520
  formatDateYYYYMMDD,
521
+ resolveAntiSlopLintMode,
455
522
  isPrototypeHtmlArtifact,
456
523
  reviewContent,
457
524
  runDesignReview,
@@ -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
+ };
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ function slugify(value, fallback = "item") {
7
+ const slug = String(value == null ? "" : value)
8
+ .trim()
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9]+/g, "-")
11
+ .replace(/^-+|-+$/g, "");
12
+ return slug || fallback;
13
+ }
14
+
15
+ function normalizeSlot(slot, fallbackId) {
16
+ if (slot && typeof slot === "object") {
17
+ const id = slugify(slot.id || slot.key || slot.name || slot.label || fallbackId, fallbackId);
18
+ return {
19
+ id,
20
+ label: String(slot.label || slot.name || slot.title || slot.id || id),
21
+ };
22
+ }
23
+ const label = String(slot == null ? fallbackId : slot);
24
+ return { id: slugify(label, fallbackId), label };
25
+ }
26
+
27
+ function normalizeComponent(component, fallbackId) {
28
+ if (component && typeof component === "object") {
29
+ const id = slugify(component.id || component.type || component.name || component.label || fallbackId, fallbackId);
30
+ return {
31
+ id,
32
+ label: String(component.label || component.name || component.title || component.type || component.id || id),
33
+ };
34
+ }
35
+ const label = String(component == null ? fallbackId : component);
36
+ return { id: slugify(label, fallbackId), label };
37
+ }
38
+
39
+ function normalizeSection(section, index) {
40
+ const source = section && typeof section === "object" ? section : {};
41
+ const title = source.title || source.name || `Section ${index + 1}`;
42
+ const id = slugify(source.id || title, `section-${index + 1}`);
43
+ const slots = source.data_slots || source.dataSlots || [];
44
+ const components = source.components || [];
45
+ return {
46
+ id,
47
+ title: String(title),
48
+ purpose: String(source.purpose || source.description || "Brief-defined section."),
49
+ components: (Array.isArray(components) ? components : []).map((component, componentIndex) =>
50
+ normalizeComponent(component, `${id}-component-${componentIndex + 1}`)
51
+ ),
52
+ states: Array.isArray(source.states) ? source.states.map((state) => String(state)) : [],
53
+ interactions: Array.isArray(source.interactions) ? source.interactions.map((interaction) => String(interaction)) : [],
54
+ data_slots: (Array.isArray(slots) ? slots : []).map((slot, slotIndex) => normalizeSlot(slot, `${id}-slot-${slotIndex + 1}`)),
55
+ };
56
+ }
57
+
58
+ function normalizeBrief(raw, filePath) {
59
+ const source = raw && typeof raw === "object" ? raw : {};
60
+ const screenId = slugify(source.screen_id || source.screenId || source.id || path.basename(filePath || "screen", ".json"), "screen");
61
+ const sections = Array.isArray(source.sections) ? source.sections.map(normalizeSection) : [];
62
+ const topLevelSlots = source.data_slots || source.dataSlots || [];
63
+ const topLevelComponents = source.components || [];
64
+ return {
65
+ screen_id: screenId,
66
+ screen_title: String(source.screen_title || source.screenTitle || source.title || screenId),
67
+ purpose: String(source.purpose || ""),
68
+ user_intent: String(source.user_intent || source.userIntent || ""),
69
+ route: String(source.route || ""),
70
+ primary_action: String(source.primary_action || source.primaryAction || "Continue"),
71
+ template_role: source.template_role || source.templateRole || null,
72
+ sections,
73
+ components: (Array.isArray(topLevelComponents) ? topLevelComponents : []).map((component, index) =>
74
+ normalizeComponent(component, `${screenId}-component-${index + 1}`)
75
+ ),
76
+ states: Array.isArray(source.states || source.required_states || source.requiredStates)
77
+ ? (source.states || source.required_states || source.requiredStates).map((state) => String(state))
78
+ : [],
79
+ interactions: Array.isArray(source.interactions) ? source.interactions.map((interaction) => String(interaction)) : [],
80
+ data_slots: (Array.isArray(topLevelSlots) ? topLevelSlots : []).map((slot, index) => normalizeSlot(slot, `${screenId}-slot-${index + 1}`)),
81
+ acceptance_criteria: Array.isArray(source.acceptance_criteria || source.acceptanceCriteria)
82
+ ? (source.acceptance_criteria || source.acceptanceCriteria).map((item) => String(item))
83
+ : [],
84
+ source_evidence: source.source_evidence || source.sourceEvidence || null,
85
+ file_path: filePath,
86
+ };
87
+ }
88
+
89
+ function loadPrototypeBriefIndex({ paths }) {
90
+ const briefsByScreenId = new Map();
91
+ const findings = [];
92
+ const root = paths && paths.designScreenBriefsStatePath ? paths.designScreenBriefsStatePath : null;
93
+ if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
94
+ return {
95
+ briefsByScreenId,
96
+ findings: [{ code: "BRIEF_DIR_MISSING", message: ".sdtk/design/screen-briefs directory is missing." }],
97
+ };
98
+ }
99
+ for (const filePath of fs.readdirSync(root).filter((name) => name.toLowerCase().endsWith(".json")).sort()) {
100
+ const absolutePath = path.join(root, filePath);
101
+ try {
102
+ const brief = normalizeBrief(JSON.parse(fs.readFileSync(absolutePath, "utf-8")), absolutePath);
103
+ if (briefsByScreenId.has(brief.screen_id)) {
104
+ findings.push({ screenId: brief.screen_id, code: "BRIEF_DUPLICATE", message: `Duplicate brief skipped: ${filePath}` });
105
+ } else {
106
+ briefsByScreenId.set(brief.screen_id, brief);
107
+ }
108
+ } catch (err) {
109
+ findings.push({ code: "BRIEF_INVALID_JSON", message: `${filePath}: ${err.message}` });
110
+ }
111
+ }
112
+ return { briefsByScreenId, findings };
113
+ }
114
+
115
+ function briefForScreen({ screen, briefIndex }) {
116
+ if (!screen || !briefIndex || !briefIndex.briefsByScreenId) return null;
117
+ return briefIndex.briefsByScreenId.get(slugify(screen.screenId || screen.id || screen.title, "screen")) || null;
118
+ }
119
+
120
+ module.exports = {
121
+ briefForScreen,
122
+ loadPrototypeBriefIndex,
123
+ normalizeBrief,
124
+ slugify,
125
+ };