sdtk-design-kit 0.1.2 → 0.2.0

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.
@@ -0,0 +1,340 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { describeDesignPaths } = require("./design-paths");
6
+ const { ValidationError } = require("./errors");
7
+
8
+ function escapeMarkdown(value) {
9
+ return String(value || "").replace(/\r?\n/g, " ").trim();
10
+ }
11
+
12
+ function toScreenBriefFileName(screenId) {
13
+ return `${String(screenId || "")
14
+ .trim()
15
+ .replace(/[^a-zA-Z0-9_-]+/g, "-")}_DESIGN_BRIEF.md`;
16
+ }
17
+
18
+ function toJsonFileName(screenId) {
19
+ return `${String(screenId || "")
20
+ .trim()
21
+ .replace(/[^a-zA-Z0-9_-]+/g, "-")}.json`;
22
+ }
23
+
24
+ function cleanArray(values) {
25
+ return Array.isArray(values)
26
+ ? values.map((item) => String(item || "").trim()).filter(Boolean)
27
+ : [];
28
+ }
29
+
30
+ function uniqueStrings(values) {
31
+ return Array.from(new Set(cleanArray(values)));
32
+ }
33
+
34
+ function needs(marker) {
35
+ return `NEEDS_${marker}`;
36
+ }
37
+
38
+ function listOrNeeds(values, marker) {
39
+ const items = cleanArray(values);
40
+ if (items.length > 0) {
41
+ return items.map((item) => `- ${escapeMarkdown(item)}`).join("\n");
42
+ }
43
+ return `- ${needs(marker)}`;
44
+ }
45
+
46
+ function tokenUsageOrDefault(value) {
47
+ const payload = value && typeof value === "object" && !Array.isArray(value) ? value : {};
48
+ const tokenUsage = {
49
+ palette_keys: cleanArray(payload.palette_keys || payload.paletteKeys),
50
+ typography_keys: cleanArray(payload.typography_keys || payload.typographyKeys),
51
+ spacing_keys: cleanArray(payload.spacing_keys || payload.spacingKeys),
52
+ radius_keys: cleanArray(payload.radius_keys || payload.radiusKeys),
53
+ };
54
+ if (
55
+ tokenUsage.palette_keys.length === 0 &&
56
+ tokenUsage.typography_keys.length === 0 &&
57
+ tokenUsage.spacing_keys.length === 0 &&
58
+ tokenUsage.radius_keys.length === 0
59
+ ) {
60
+ return {
61
+ palette_keys: ["color.surface", "color.textPrimary", "color.accentPrimary"],
62
+ typography_keys: ["typography.fontFamily", "typography.uiWeight"],
63
+ spacing_keys: ["spacing.md", "spacing.lg", "spacing.sectionGap"],
64
+ radius_keys: ["radius.card", "radius.control"],
65
+ };
66
+ }
67
+ return tokenUsage;
68
+ }
69
+
70
+ function tokenUsageMarkdown(tokenUsage) {
71
+ return [
72
+ `- Palette keys: ${tokenUsage.palette_keys.length > 0 ? tokenUsage.palette_keys.join(", ") : needs("PALETTE_KEYS")}`,
73
+ `- Typography keys: ${tokenUsage.typography_keys.length > 0 ? tokenUsage.typography_keys.join(", ") : needs("TYPOGRAPHY_KEYS")}`,
74
+ `- Spacing keys: ${tokenUsage.spacing_keys.length > 0 ? tokenUsage.spacing_keys.join(", ") : needs("SPACING_KEYS")}`,
75
+ `- Radius keys: ${tokenUsage.radius_keys.length > 0 ? tokenUsage.radius_keys.join(", ") : needs("RADIUS_KEYS")}`,
76
+ ].join("\n");
77
+ }
78
+
79
+ function layoutModelForRole(templateRole) {
80
+ const models = {
81
+ home: "hero-catalog-support",
82
+ category: "filter-and-results",
83
+ "product-detail": "split-detail-with-purchase-panel",
84
+ search: "search-toolbar-results",
85
+ cart: "line-item-table-with-summary",
86
+ checkout: "stepper-with-order-summary",
87
+ "order-history": "table-list-with-filters",
88
+ "order-detail": "timeline-detail-summary",
89
+ "account-info": "sidebar-form-shell",
90
+ "configurator-bom": "wizard-with-preview-and-table",
91
+ };
92
+ return models[templateRole] || null;
93
+ }
94
+
95
+ function densityForRole(templateRole) {
96
+ const highDensityRoles = new Set(["checkout", "cart", "configurator-bom", "order-detail"]);
97
+ const mediumDensityRoles = new Set(["product-detail", "category", "search", "order-history", "account-info"]);
98
+ if (highDensityRoles.has(templateRole)) return "high";
99
+ if (mediumDensityRoles.has(templateRole)) return "medium";
100
+ return null;
101
+ }
102
+
103
+ function sectionMarkdown(sections) {
104
+ if (!Array.isArray(sections) || sections.length === 0) {
105
+ return `- ${needs("SECTIONS")}`;
106
+ }
107
+ return sections
108
+ .map((section, index) => {
109
+ const title = escapeMarkdown(section.title || `Section ${index + 1}`);
110
+ const details = [];
111
+ if (section.purpose) details.push(`purpose=${escapeMarkdown(section.purpose)}`);
112
+ if (Array.isArray(section.components) && section.components.length > 0) {
113
+ details.push(`components=${section.components.map(escapeMarkdown).join(", ")}`);
114
+ }
115
+ if (Array.isArray(section.data_slots) && section.data_slots.length > 0) {
116
+ details.push(`data=${section.data_slots.map(escapeMarkdown).join(", ")}`);
117
+ }
118
+ if (Array.isArray(section.states) && section.states.length > 0) {
119
+ details.push(`states=${section.states.map(escapeMarkdown).join(", ")}`);
120
+ }
121
+ return `- ${title}${details.length > 0 ? ` (${details.join("; ")})` : ""}`;
122
+ })
123
+ .join("\n");
124
+ }
125
+
126
+ function flattenSectionValues(sections, key) {
127
+ if (!Array.isArray(sections)) {
128
+ return [];
129
+ }
130
+ return uniqueStrings(sections.flatMap((section) => cleanArray(section && section[key])));
131
+ }
132
+
133
+ function profileRoleHints(profilePrimitives, templateRole, screenId) {
134
+ if (!profilePrimitives || !profilePrimitives.screenRoleHints) {
135
+ return [];
136
+ }
137
+ const hints = profilePrimitives.screenRoleHints[screenId] || profilePrimitives.screenRoleHints[templateRole];
138
+ return cleanArray(hints);
139
+ }
140
+
141
+ function buildBriefModel({ screen, profileSelection, profilePrimitives, sourceArtifacts }) {
142
+ const templateRole = screen.templateRole || "generic";
143
+ const sections = Array.isArray(screen.sections) ? screen.sections : [];
144
+ const sectionComponents = flattenSectionValues(sections, "components");
145
+ const sectionStates = flattenSectionValues(sections, "states");
146
+ const sectionInteractions = flattenSectionValues(sections, "interactions");
147
+ const sectionDataSlots = flattenSectionValues(sections, "data_slots");
148
+ const componentHints = profileRoleHints(profilePrimitives, templateRole, screen.screenId);
149
+ const components = uniqueStrings([...(screen.requiredComponents || []), ...sectionComponents, ...componentHints]);
150
+ const states = uniqueStrings([...(screen.requiredStates || []), ...sectionStates]);
151
+ const interactions = uniqueStrings([
152
+ ...(screen.interactions || []),
153
+ ...sectionInteractions,
154
+ ...(screen.primaryAction ? [`Primary action: ${screen.primaryAction}`] : []),
155
+ ]);
156
+ const dataSlots = uniqueStrings([...(screen.dataSlots || []), ...sectionDataSlots]);
157
+ const sourceEvidence = screen.sourceEvidence || {};
158
+ const supportingArtifacts = cleanArray(sourceArtifacts);
159
+ const model = {
160
+ schema: "sdtk.design.screen-brief.v2",
161
+ screen_id: screen.screenId,
162
+ screen_title: screen.title,
163
+ purpose: screen.purpose || needs("SCREEN_PURPOSE"),
164
+ user_intent: screen.userIntent || needs("USER_INTENT"),
165
+ route: screen.route || needs("ROUTE"),
166
+ sections,
167
+ section_titles: sections.map((section) => section.title).filter(Boolean),
168
+ layout_model: screen.layoutModel || layoutModelForRole(templateRole) || needs("LAYOUT_MODEL"),
169
+ density: screen.density || densityForRole(templateRole) || needs("DENSITY"),
170
+ components: components.length > 0 ? components : [needs("COMPONENTS")],
171
+ states: states.length > 0 ? states : [needs("STATES")],
172
+ interactions: interactions.length > 0 ? interactions : [needs("INTERACTIONS")],
173
+ data_slots: dataSlots.length > 0 ? dataSlots : [needs("DATA_SLOTS")],
174
+ accessibility_notes:
175
+ cleanArray(screen.accessibilityNotes).length > 0
176
+ ? cleanArray(screen.accessibilityNotes)
177
+ : [
178
+ "Use one screen-level heading before nested section headings.",
179
+ "Expose keyboard focus order for navigation, filters, forms, and primary actions.",
180
+ "Keep contrast and touch target sizing aligned with DESIGN_TOKENS guidance.",
181
+ ],
182
+ visual_token_usage: tokenUsageOrDefault(screen.visualTokenUsage),
183
+ acceptance_criteria:
184
+ cleanArray(screen.acceptanceCriteria).length > 0
185
+ ? cleanArray(screen.acceptanceCriteria)
186
+ : [needs("ACCEPTANCE_CRITERIA")],
187
+ template_role: templateRole,
188
+ source_evidence: {
189
+ source_artifact: sourceEvidence.source_artifact || screen.sourceArtifact || null,
190
+ source_status: sourceEvidence.source_status || screen.sourceStatus || "explicit",
191
+ source_quote: sourceEvidence.source_quote || needs("SOURCE_QUOTE"),
192
+ source_line_range: Array.isArray(sourceEvidence.source_line_range)
193
+ ? sourceEvidence.source_line_range
194
+ : null,
195
+ supporting_artifacts: supportingArtifacts,
196
+ },
197
+ profile: profileSelection || "generic",
198
+ confidence: screen.confidence || "low",
199
+ };
200
+ model.gaps = [
201
+ model.purpose,
202
+ model.user_intent,
203
+ model.route,
204
+ model.layout_model,
205
+ model.density,
206
+ ...model.components,
207
+ ...model.states,
208
+ ...model.interactions,
209
+ ...model.data_slots,
210
+ ...model.acceptance_criteria,
211
+ model.source_evidence.source_quote,
212
+ ].filter((value) => String(value).startsWith("NEEDS_"));
213
+ return model;
214
+ }
215
+
216
+ function renderScreenBrief({ screen, profileSelection, profilePrimitives, sourceArtifacts }) {
217
+ const model = buildBriefModel({ screen, profileSelection, profilePrimitives, sourceArtifacts });
218
+
219
+ return `# Screen Design Brief: ${escapeMarkdown(screen.title)}
220
+
221
+ ## Screen Meta
222
+ - Screen ID: ${escapeMarkdown(screen.screenId)}
223
+ - Template role: ${escapeMarkdown(model.template_role)}
224
+ - Profile: ${escapeMarkdown(profileSelection || "none")}
225
+ - Confidence: ${escapeMarkdown(model.confidence)}
226
+
227
+ ## Screen Purpose And User Intent
228
+ - Purpose: ${escapeMarkdown(model.purpose)}
229
+ - User intent: ${escapeMarkdown(model.user_intent)}
230
+
231
+ ## Route
232
+ - Route: ${escapeMarkdown(model.route)}
233
+
234
+ ## Required Sections Top To Bottom
235
+ ${sectionMarkdown(model.sections)}
236
+
237
+ ## Layout Model And Density
238
+ - Layout model: ${escapeMarkdown(model.layout_model)}
239
+ - Density: ${escapeMarkdown(model.density)}
240
+ - Navigation: preserve deterministic route and section order from explicit screen model.
241
+
242
+ ## Component Requirements
243
+ ${listOrNeeds(model.components, "COMPONENTS")}
244
+
245
+ ## State Requirements
246
+ ${listOrNeeds(model.states, "STATES")}
247
+
248
+ ## Interaction Notes
249
+ ${listOrNeeds(model.interactions, "INTERACTIONS")}
250
+
251
+ ## Data / Content Slots
252
+ ${listOrNeeds(model.data_slots, "DATA_SLOTS")}
253
+
254
+ ## Visual Token Usage
255
+ ${tokenUsageMarkdown(model.visual_token_usage)}
256
+
257
+ ## Accessibility Notes
258
+ ${listOrNeeds(model.accessibility_notes, "ACCESSIBILITY_NOTES")}
259
+
260
+ ## Acceptance Criteria
261
+ ${listOrNeeds(model.acceptance_criteria, "ACCEPTANCE_CRITERIA")}
262
+
263
+ ## Source Evidence / Confidence
264
+ - Source artifact: ${escapeMarkdown(model.source_evidence.source_artifact || needs("SOURCE_ARTIFACT"))}
265
+ - Source status: ${escapeMarkdown(model.source_evidence.source_status)}
266
+ - Source quote: ${escapeMarkdown(model.source_evidence.source_quote)}
267
+ - Source line range: ${Array.isArray(model.source_evidence.source_line_range) ? model.source_evidence.source_line_range.join("-") : needs("SOURCE_LINE_RANGE")}
268
+ - Confidence level: ${escapeMarkdown(model.confidence)}
269
+ - Supporting explicit artifacts:
270
+ ${model.source_evidence.supporting_artifacts.length > 0 ? model.source_evidence.supporting_artifacts.map((item) => ` - ${escapeMarkdown(item)}`).join("\n") : ` - ${needs("SUPPORTING_ARTIFACTS")}`}
271
+ `;
272
+ }
273
+
274
+ function writeScreenBriefArtifacts(projectPath, inputContractState) {
275
+ const paths = describeDesignPaths(projectPath);
276
+ if (
277
+ !inputContractState ||
278
+ inputContractState.analysisStatus !== "INPUT_CONTRACT_READY" ||
279
+ !inputContractState.screenModel ||
280
+ !Array.isArray(inputContractState.screenModel.screens) ||
281
+ inputContractState.screenModel.screens.length === 0
282
+ ) {
283
+ throw new ValidationError(
284
+ "Screen brief generation requires INPUT_CONTRACT_READY with at least one explicit screen model entry."
285
+ );
286
+ }
287
+
288
+ fs.mkdirSync(paths.screensPath, { recursive: true });
289
+ fs.mkdirSync(paths.designScreenBriefsStatePath, { recursive: true });
290
+
291
+ const sourceArtifacts = Object.values(inputContractState.artifacts || {})
292
+ .filter((artifact) => artifact && artifact.found && artifact.relativeToSpecRoot)
293
+ .map((artifact) => artifact.relativeToSpecRoot);
294
+
295
+ const generated = [];
296
+ for (const screen of inputContractState.screenModel.screens) {
297
+ const mdFileName = toScreenBriefFileName(screen.screenId);
298
+ const jsonFileName = toJsonFileName(screen.screenId);
299
+ const markdownPath = path.join(paths.screensPath, mdFileName);
300
+ const sidecarPath = path.join(paths.designScreenBriefsStatePath, jsonFileName);
301
+ const markdown = renderScreenBrief({
302
+ screen,
303
+ profileSelection: inputContractState.profileSelection || null,
304
+ profilePrimitives: inputContractState.profilePrimitives || null,
305
+ sourceArtifacts,
306
+ });
307
+ const briefModel = buildBriefModel({
308
+ screen,
309
+ profileSelection: inputContractState.profileSelection || null,
310
+ profilePrimitives: inputContractState.profilePrimitives || null,
311
+ sourceArtifacts,
312
+ });
313
+ const sidecar = {
314
+ ...briefModel,
315
+ primary_action: screen.primaryAction || null,
316
+ required_states: briefModel.states,
317
+ major_sections: briefModel.section_titles,
318
+ profile: briefModel.profile,
319
+ confidence: briefModel.confidence,
320
+ source_artifact: screen.sourceArtifact || null,
321
+ };
322
+ fs.writeFileSync(markdownPath, `${markdown}\n`, "utf-8");
323
+ fs.writeFileSync(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`, "utf-8");
324
+ generated.push({
325
+ screenId: screen.screenId,
326
+ markdownRelativePath: path.relative(projectPath, markdownPath).split(path.sep).join("/"),
327
+ sidecarRelativePath: path.relative(projectPath, sidecarPath).split(path.sep).join("/"),
328
+ });
329
+ }
330
+
331
+ return {
332
+ generatedCount: generated.length,
333
+ generated,
334
+ };
335
+ }
336
+
337
+ module.exports = {
338
+ buildBriefModel,
339
+ writeScreenBriefArtifacts,
340
+ };