sdtk-design-kit 0.2.1 → 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.
- package/README.md +67 -125
- package/package.json +2 -1
- package/skills/design-prototype/SKILL.md +276 -0
- package/skills/design-prototype/references/craft.md +75 -0
- package/skills/design-prototype/references/designer-charter.md +56 -0
- package/src/commands/handoff.js +220 -220
- package/src/commands/help.js +7 -3
- package/src/commands/init.js +53 -0
- package/src/commands/prototype.js +140 -653
- package/src/commands/review.js +129 -139
- package/src/commands/start.js +22 -5
- package/src/commands/status.js +10 -2
- package/src/commands/system.js +186 -14
- package/src/commands/update.js +11 -0
- package/src/index.js +4 -0
- package/src/lib/anti-slop-lint.js +0 -11
- package/src/lib/component-contract.js +300 -34
- package/src/lib/design-input-contract.js +3 -2
- package/src/lib/design-paths.js +3 -0
- package/src/lib/design-profiles.js +31 -0
- package/src/lib/screen-briefs.js +235 -24
- package/src/lib/update.js +219 -0
- package/src/lib/prototype-briefs.js +0 -125
- package/src/lib/prototype-component-map.js +0 -219
- package/src/lib/prototype-density.js +0 -377
- package/src/lib/prototype-renderer.js +0 -382
package/src/lib/screen-briefs.js
CHANGED
|
@@ -43,36 +43,107 @@ function listOrNeeds(values, marker) {
|
|
|
43
43
|
return `- ${needs(marker)}`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// BK-227: bounded enum — sections with repeated-slot content render NEEDS_* as <a> skeleton.
|
|
47
|
+
const INTERACTIVE_COMPONENT_PATTERNS = [
|
|
48
|
+
"product-card", "product-row", "order-row", "order-card",
|
|
49
|
+
"search-result", "search-row", "result-row",
|
|
50
|
+
"nav-item", "list-item", "category-card", "catalog-card",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const INTERACTIVE_TEMPLATE_CONTEXTS = {
|
|
54
|
+
category: ["grid", "products", "items", "list", "catalog"],
|
|
55
|
+
search: ["results", "items", "matches"],
|
|
56
|
+
"order-history": ["orders", "list", "rows", "history"],
|
|
57
|
+
home: ["categories", "featured", "grid", "cards"],
|
|
58
|
+
"product-detail": ["related", "recommendations"],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// BK-230: normalize identifier for naming-convention-agnostic comparison
|
|
62
|
+
// (matches PascalCase, kebab-case, snake_case, spaced — all collapse to lower-alphanum).
|
|
63
|
+
function normalizeIdentifier(value) {
|
|
64
|
+
return String(value || "").toLowerCase().replace(/[\s_-]+/g, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// BK-230: precompute normalized pattern + keyword sets at module load.
|
|
68
|
+
const INTERACTIVE_COMPONENT_PATTERNS_NORM = INTERACTIVE_COMPONENT_PATTERNS.map(normalizeIdentifier);
|
|
69
|
+
const INTERACTIVE_TEMPLATE_CONTEXTS_NORM = Object.fromEntries(
|
|
70
|
+
Object.entries(INTERACTIVE_TEMPLATE_CONTEXTS).map(([k, kws]) => [
|
|
71
|
+
normalizeIdentifier(k),
|
|
72
|
+
kws.map(normalizeIdentifier),
|
|
73
|
+
])
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
function isInteractiveSection(section, screen) {
|
|
77
|
+
if (!section || !screen) return false;
|
|
78
|
+
const components = Array.isArray(section.components)
|
|
79
|
+
? section.components.map(normalizeIdentifier)
|
|
80
|
+
: [];
|
|
81
|
+
// Rule 1: explicit component pattern match (normalized both sides).
|
|
82
|
+
for (const pattern of INTERACTIVE_COMPONENT_PATTERNS_NORM) {
|
|
83
|
+
if (components.some((c) => c.includes(pattern))) return true;
|
|
84
|
+
}
|
|
85
|
+
// Rule 2: template_role + section title/components keyword (normalized).
|
|
86
|
+
const templateRole = normalizeIdentifier(screen.templateRole || screen.template_role || "");
|
|
87
|
+
const contextKeywords = INTERACTIVE_TEMPLATE_CONTEXTS_NORM[templateRole] || [];
|
|
88
|
+
if (contextKeywords.length === 0) return false;
|
|
89
|
+
const title = normalizeIdentifier(section.title || "");
|
|
90
|
+
for (const kw of contextKeywords) {
|
|
91
|
+
if (title.includes(kw)) return true;
|
|
92
|
+
if (components.some((c) => c.includes(kw))) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function interactiveDataSlotsForSection(section, screen) {
|
|
98
|
+
if (!isInteractiveSection(section, screen)) return [];
|
|
99
|
+
return Array.isArray(section.data_slots) ? cleanArray(section.data_slots) : [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// BK-225: v3 visual_token_usage aligned to BK-223 v2 token schema key paths.
|
|
103
|
+
const TOKEN_USAGE_V3_DEFAULTS = {
|
|
104
|
+
palette_keys: [
|
|
105
|
+
"palette.surface", "palette.surfaceAlt", "palette.textPrimary", "palette.textMuted",
|
|
106
|
+
"palette.primary", "palette.accent", "palette.border",
|
|
107
|
+
"palette.state.success.base", "palette.state.error.base",
|
|
108
|
+
],
|
|
109
|
+
typography_keys: [
|
|
110
|
+
"typography.fontFamily.body",
|
|
111
|
+
"typography.ramp.h1", "typography.ramp.h2", "typography.ramp.h3",
|
|
112
|
+
"typography.ramp.body", "typography.ramp.caption",
|
|
113
|
+
],
|
|
114
|
+
section_keys: [
|
|
115
|
+
"section.containerMaxWidth", "section.contentMaxWidth",
|
|
116
|
+
"section.sectionGap", "section.heroMinHeight", "section.gridColumns",
|
|
117
|
+
],
|
|
118
|
+
component_keys: [
|
|
119
|
+
"component.radius.card", "component.radius.control",
|
|
120
|
+
"component.shadow.sm", "component.shadow.md",
|
|
121
|
+
"component.borderWidth.default",
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function tokenUsageV3OrDefault(value) {
|
|
47
126
|
const payload = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
48
127
|
const tokenUsage = {
|
|
49
128
|
palette_keys: cleanArray(payload.palette_keys || payload.paletteKeys),
|
|
50
129
|
typography_keys: cleanArray(payload.typography_keys || payload.typographyKeys),
|
|
51
|
-
|
|
52
|
-
|
|
130
|
+
section_keys: cleanArray(payload.section_keys || payload.sectionKeys),
|
|
131
|
+
component_keys: cleanArray(payload.component_keys || payload.componentKeys),
|
|
53
132
|
};
|
|
54
|
-
|
|
55
|
-
tokenUsage.palette_keys.length
|
|
56
|
-
tokenUsage.typography_keys.length
|
|
57
|
-
tokenUsage.
|
|
58
|
-
tokenUsage.
|
|
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;
|
|
133
|
+
const hasAny =
|
|
134
|
+
tokenUsage.palette_keys.length > 0 ||
|
|
135
|
+
tokenUsage.typography_keys.length > 0 ||
|
|
136
|
+
tokenUsage.section_keys.length > 0 ||
|
|
137
|
+
tokenUsage.component_keys.length > 0;
|
|
138
|
+
return hasAny ? tokenUsage : { ...TOKEN_USAGE_V3_DEFAULTS };
|
|
68
139
|
}
|
|
69
140
|
|
|
70
141
|
function tokenUsageMarkdown(tokenUsage) {
|
|
71
142
|
return [
|
|
72
143
|
`- Palette keys: ${tokenUsage.palette_keys.length > 0 ? tokenUsage.palette_keys.join(", ") : needs("PALETTE_KEYS")}`,
|
|
73
144
|
`- Typography keys: ${tokenUsage.typography_keys.length > 0 ? tokenUsage.typography_keys.join(", ") : needs("TYPOGRAPHY_KEYS")}`,
|
|
74
|
-
`-
|
|
75
|
-
`-
|
|
145
|
+
`- Section keys: ${tokenUsage.section_keys.length > 0 ? tokenUsage.section_keys.join(", ") : needs("SECTION_KEYS")}`,
|
|
146
|
+
`- Component keys: ${tokenUsage.component_keys.length > 0 ? tokenUsage.component_keys.join(", ") : needs("COMPONENT_KEYS")}`,
|
|
76
147
|
].join("\n");
|
|
77
148
|
}
|
|
78
149
|
|
|
@@ -100,6 +171,90 @@ function densityForRole(templateRole) {
|
|
|
100
171
|
return null;
|
|
101
172
|
}
|
|
102
173
|
|
|
174
|
+
// BK-225: derive height_band from section position and role.
|
|
175
|
+
function heightBandForSection(index, total, templateRole) {
|
|
176
|
+
if (index === 0 && templateRole === "home") return "hero";
|
|
177
|
+
if (index === 0) return "standard";
|
|
178
|
+
if (index === total - 1) return "compact";
|
|
179
|
+
return "standard";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// BK-225: build visual_direction block for a section.
|
|
183
|
+
// Only uses explicit handoff fields from the normalized section record.
|
|
184
|
+
function visualDirectionForSection(section, screen, index) {
|
|
185
|
+
const totalSections = Array.isArray(screen.sections) ? screen.sections.length : 1;
|
|
186
|
+
const templateRole = screen.templateRole || "generic";
|
|
187
|
+
|
|
188
|
+
// Dimension
|
|
189
|
+
const heightBand = heightBandForSection(index, totalSections, templateRole);
|
|
190
|
+
const hasColumns = typeof section.column_count === "number" || typeof section.columnCount === "number";
|
|
191
|
+
const columnCount = hasColumns ? (section.column_count || section.columnCount) : null;
|
|
192
|
+
const splitRatio = section.split_ratio || section.splitRatio || null;
|
|
193
|
+
const dimension = {
|
|
194
|
+
height_band: heightBand,
|
|
195
|
+
column_count: columnCount !== null ? columnCount : needs("COLUMN_COUNT"),
|
|
196
|
+
split_ratio: splitRatio || needs("SPLIT_RATIO"),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Layout intent: derive from explicit layout_model or section components.
|
|
200
|
+
const hasComponents = Array.isArray(section.components) && section.components.length > 0;
|
|
201
|
+
const sectionLayoutIntent = section.layout_intent || section.layoutIntent || null;
|
|
202
|
+
let layoutIntent;
|
|
203
|
+
if (sectionLayoutIntent) {
|
|
204
|
+
layoutIntent = sectionLayoutIntent;
|
|
205
|
+
} else if (hasComponents) {
|
|
206
|
+
layoutIntent = `Render ${section.components.slice(0, 2).join(" and ")} in section.`;
|
|
207
|
+
} else {
|
|
208
|
+
layoutIntent = needs("LAYOUT_INTENT");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Content direction: sourced from explicit handoff fields only.
|
|
212
|
+
const heading = section.title ? section.title : needs("CONTENT_HEADING");
|
|
213
|
+
const body = section.purpose ? section.purpose : needs("CONTENT_BODY");
|
|
214
|
+
const hasDataSlots = Array.isArray(section.data_slots) && section.data_slots.length > 0;
|
|
215
|
+
const dataDirection = hasDataSlots
|
|
216
|
+
? `Map data_slots into scannable rows: ${section.data_slots.slice(0, 3).join(", ")}.`
|
|
217
|
+
: needs("DATA_DIRECTION");
|
|
218
|
+
const contentDirection = {
|
|
219
|
+
heading,
|
|
220
|
+
body,
|
|
221
|
+
data_direction: dataDirection,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Imagery: sourced from explicit handoff fields only.
|
|
225
|
+
const imageryPlacement = section.imagery_placement || section.imageryPlacement || needs("IMAGERY_PLACEMENT");
|
|
226
|
+
const imagerySubject = section.imagery_subject || section.imagerySubject || needs("IMAGERY_SUBJECT");
|
|
227
|
+
const imageryAspectRatio = section.imagery_aspect_ratio || section.imageryAspectRatio || "16:9";
|
|
228
|
+
const imageryTreatment = section.imagery_treatment || section.imageryTreatment || "token-bounded, non-decorative";
|
|
229
|
+
const imagery = {
|
|
230
|
+
placement: imageryPlacement,
|
|
231
|
+
subject: imagerySubject,
|
|
232
|
+
aspect_ratio: imageryAspectRatio,
|
|
233
|
+
treatment: imageryTreatment,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Source evidence: screen-level is sufficient when section-specific is unavailable.
|
|
237
|
+
const screenEvidence = screen.sourceEvidence || {};
|
|
238
|
+
const sourceStatus = screenEvidence.source_status || "explicit";
|
|
239
|
+
const sourceQuote = screenEvidence.source_quote || needs("SOURCE_QUOTE");
|
|
240
|
+
const sourceLineRange = Array.isArray(screenEvidence.source_line_range)
|
|
241
|
+
? screenEvidence.source_line_range
|
|
242
|
+
: [1, 1];
|
|
243
|
+
const sourceEvidence = {
|
|
244
|
+
source_status: sourceStatus,
|
|
245
|
+
source_quote: sourceQuote,
|
|
246
|
+
source_line_range: sourceLineRange,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
dimension,
|
|
251
|
+
layout_intent: layoutIntent,
|
|
252
|
+
content_direction: contentDirection,
|
|
253
|
+
imagery,
|
|
254
|
+
source_evidence: sourceEvidence,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
103
258
|
function sectionMarkdown(sections) {
|
|
104
259
|
if (!Array.isArray(sections) || sections.length === 0) {
|
|
105
260
|
return `- ${needs("SECTIONS")}`;
|
|
@@ -115,6 +270,10 @@ function sectionMarkdown(sections) {
|
|
|
115
270
|
if (Array.isArray(section.data_slots) && section.data_slots.length > 0) {
|
|
116
271
|
details.push(`data=${section.data_slots.map(escapeMarkdown).join(", ")}`);
|
|
117
272
|
}
|
|
273
|
+
// BK-227: surface interactive slot list for renderer skill consumption.
|
|
274
|
+
if (Array.isArray(section.interactive_data_slots) && section.interactive_data_slots.length > 0) {
|
|
275
|
+
details.push(`interactive=${section.interactive_data_slots.map(escapeMarkdown).join(", ")}`);
|
|
276
|
+
}
|
|
118
277
|
if (Array.isArray(section.states) && section.states.length > 0) {
|
|
119
278
|
details.push(`states=${section.states.map(escapeMarkdown).join(", ")}`);
|
|
120
279
|
}
|
|
@@ -156,14 +315,23 @@ function buildBriefModel({ screen, profileSelection, profilePrimitives, sourceAr
|
|
|
156
315
|
const dataSlots = uniqueStrings([...(screen.dataSlots || []), ...sectionDataSlots]);
|
|
157
316
|
const sourceEvidence = screen.sourceEvidence || {};
|
|
158
317
|
const supportingArtifacts = cleanArray(sourceArtifacts);
|
|
318
|
+
|
|
319
|
+
// BK-225: add visual_direction per section.
|
|
320
|
+
// BK-227: add interactive_data_slots per section (subset of data_slots for repeated-slot contexts).
|
|
321
|
+
const sectionsWithVisualDirection = sections.map((section, index) => ({
|
|
322
|
+
...section,
|
|
323
|
+
visual_direction: visualDirectionForSection(section, screen, index),
|
|
324
|
+
interactive_data_slots: interactiveDataSlotsForSection(section, screen),
|
|
325
|
+
}));
|
|
326
|
+
|
|
159
327
|
const model = {
|
|
160
|
-
schema: "sdtk.design.screen-brief.
|
|
328
|
+
schema: "sdtk.design.screen-brief.v3",
|
|
161
329
|
screen_id: screen.screenId,
|
|
162
330
|
screen_title: screen.title,
|
|
163
331
|
purpose: screen.purpose || needs("SCREEN_PURPOSE"),
|
|
164
332
|
user_intent: screen.userIntent || needs("USER_INTENT"),
|
|
165
333
|
route: screen.route || needs("ROUTE"),
|
|
166
|
-
sections,
|
|
334
|
+
sections: sectionsWithVisualDirection,
|
|
167
335
|
section_titles: sections.map((section) => section.title).filter(Boolean),
|
|
168
336
|
layout_model: screen.layoutModel || layoutModelForRole(templateRole) || needs("LAYOUT_MODEL"),
|
|
169
337
|
density: screen.density || densityForRole(templateRole) || needs("DENSITY"),
|
|
@@ -179,7 +347,7 @@ function buildBriefModel({ screen, profileSelection, profilePrimitives, sourceAr
|
|
|
179
347
|
"Expose keyboard focus order for navigation, filters, forms, and primary actions.",
|
|
180
348
|
"Keep contrast and touch target sizing aligned with DESIGN_TOKENS guidance.",
|
|
181
349
|
],
|
|
182
|
-
visual_token_usage:
|
|
350
|
+
visual_token_usage: tokenUsageV3OrDefault(screen.visualTokenUsage),
|
|
183
351
|
acceptance_criteria:
|
|
184
352
|
cleanArray(screen.acceptanceCriteria).length > 0
|
|
185
353
|
? cleanArray(screen.acceptanceCriteria)
|
|
@@ -197,7 +365,8 @@ function buildBriefModel({ screen, profileSelection, profilePrimitives, sourceAr
|
|
|
197
365
|
profile: profileSelection || "generic",
|
|
198
366
|
confidence: screen.confidence || "low",
|
|
199
367
|
};
|
|
200
|
-
|
|
368
|
+
|
|
369
|
+
const gapSources = [
|
|
201
370
|
model.purpose,
|
|
202
371
|
model.user_intent,
|
|
203
372
|
model.route,
|
|
@@ -209,10 +378,49 @@ function buildBriefModel({ screen, profileSelection, profilePrimitives, sourceAr
|
|
|
209
378
|
...model.data_slots,
|
|
210
379
|
...model.acceptance_criteria,
|
|
211
380
|
model.source_evidence.source_quote,
|
|
212
|
-
]
|
|
381
|
+
];
|
|
382
|
+
// BK-225: include visual_direction NEEDS_* markers in gaps.
|
|
383
|
+
for (const section of sectionsWithVisualDirection) {
|
|
384
|
+
const vd = section.visual_direction;
|
|
385
|
+
if (vd) {
|
|
386
|
+
if (String(vd.layout_intent).startsWith("NEEDS_")) gapSources.push(vd.layout_intent);
|
|
387
|
+
if (String(vd.dimension && vd.dimension.column_count).startsWith("NEEDS_")) gapSources.push(String(vd.dimension.column_count));
|
|
388
|
+
if (String(vd.content_direction && vd.content_direction.heading).startsWith("NEEDS_")) gapSources.push(String(vd.content_direction.heading));
|
|
389
|
+
if (String(vd.imagery && vd.imagery.subject).startsWith("NEEDS_")) gapSources.push(String(vd.imagery.subject));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
model.gaps = gapSources.filter((value) => String(value).startsWith("NEEDS_"));
|
|
213
393
|
return model;
|
|
214
394
|
}
|
|
215
395
|
|
|
396
|
+
function renderVisualDirectionMarkdown(sections) {
|
|
397
|
+
if (!Array.isArray(sections) || sections.length === 0) {
|
|
398
|
+
return `- ${needs("VISUAL_DIRECTION")}`;
|
|
399
|
+
}
|
|
400
|
+
return sections
|
|
401
|
+
.map((section, index) => {
|
|
402
|
+
const vd = section.visual_direction;
|
|
403
|
+
if (!vd) return `- Section ${index + 1}: ${needs("VISUAL_DIRECTION")}`;
|
|
404
|
+
const title = escapeMarkdown(section.title || `Section ${index + 1}`);
|
|
405
|
+
const dim = vd.dimension || {};
|
|
406
|
+
return [
|
|
407
|
+
`### ${title}`,
|
|
408
|
+
`- Height band: ${escapeMarkdown(dim.height_band || needs("HEIGHT_BAND"))}`,
|
|
409
|
+
`- Column count: ${escapeMarkdown(String(dim.column_count || needs("COLUMN_COUNT")))}`,
|
|
410
|
+
`- Split ratio: ${escapeMarkdown(dim.split_ratio || needs("SPLIT_RATIO"))}`,
|
|
411
|
+
`- Layout intent: ${escapeMarkdown(vd.layout_intent || needs("LAYOUT_INTENT"))}`,
|
|
412
|
+
`- Content heading: ${escapeMarkdown((vd.content_direction && vd.content_direction.heading) || needs("CONTENT_HEADING"))}`,
|
|
413
|
+
`- Content body: ${escapeMarkdown((vd.content_direction && vd.content_direction.body) || needs("CONTENT_BODY"))}`,
|
|
414
|
+
`- Data direction: ${escapeMarkdown((vd.content_direction && vd.content_direction.data_direction) || needs("DATA_DIRECTION"))}`,
|
|
415
|
+
`- Imagery placement: ${escapeMarkdown((vd.imagery && vd.imagery.placement) || needs("IMAGERY_PLACEMENT"))}`,
|
|
416
|
+
`- Imagery subject: ${escapeMarkdown((vd.imagery && vd.imagery.subject) || needs("IMAGERY_SUBJECT"))}`,
|
|
417
|
+
`- Imagery aspect ratio: ${escapeMarkdown((vd.imagery && vd.imagery.aspect_ratio) || "16:9")}`,
|
|
418
|
+
`- Imagery treatment: ${escapeMarkdown((vd.imagery && vd.imagery.treatment) || "token-bounded, non-decorative")}`,
|
|
419
|
+
].join("\n");
|
|
420
|
+
})
|
|
421
|
+
.join("\n\n");
|
|
422
|
+
}
|
|
423
|
+
|
|
216
424
|
function renderScreenBrief({ screen, profileSelection, profilePrimitives, sourceArtifacts }) {
|
|
217
425
|
const model = buildBriefModel({ screen, profileSelection, profilePrimitives, sourceArtifacts });
|
|
218
426
|
|
|
@@ -254,6 +462,9 @@ ${listOrNeeds(model.data_slots, "DATA_SLOTS")}
|
|
|
254
462
|
## Visual Token Usage
|
|
255
463
|
${tokenUsageMarkdown(model.visual_token_usage)}
|
|
256
464
|
|
|
465
|
+
## Section Visual Direction
|
|
466
|
+
${renderVisualDirectionMarkdown(model.sections)}
|
|
467
|
+
|
|
257
468
|
## Accessibility Notes
|
|
258
469
|
${listOrNeeds(model.accessibility_notes, "ACCESSIBILITY_NOTES")}
|
|
259
470
|
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { spawn } = require("child_process");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { parseFlags } = require("./args");
|
|
6
|
+
const { CliError, ValidationError } = require("./errors");
|
|
7
|
+
|
|
8
|
+
const PACKAGE_NAME = "sdtk-design-kit";
|
|
9
|
+
const PRODUCT_NAME = "SDTK-DESIGN";
|
|
10
|
+
const NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
11
|
+
const NPM_DISPLAY = "npm";
|
|
12
|
+
const NPM_VIEW_ARGS = ["view", PACKAGE_NAME, "version"];
|
|
13
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
14
|
+
const FLAG_DEFS = {
|
|
15
|
+
version: { type: "string" },
|
|
16
|
+
"project-path": { type: "string" },
|
|
17
|
+
"check-only": { type: "boolean" },
|
|
18
|
+
"skip-project-files": { type: "boolean" },
|
|
19
|
+
verbose: { type: "boolean" },
|
|
20
|
+
};
|
|
21
|
+
const pkg = require("../../package.json");
|
|
22
|
+
|
|
23
|
+
let commandExecutor = defaultCommandExecutor;
|
|
24
|
+
|
|
25
|
+
function defaultCommandExecutor(command, args, options = {}) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const child = spawn(command, args, {
|
|
28
|
+
cwd: options.cwd || process.cwd(),
|
|
29
|
+
env: options.env || process.env,
|
|
30
|
+
shell: options.shell || false,
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
});
|
|
33
|
+
let stdout = "";
|
|
34
|
+
let stderr = "";
|
|
35
|
+
|
|
36
|
+
child.stdout.on("data", (chunk) => {
|
|
37
|
+
const text = chunk.toString();
|
|
38
|
+
stdout += text;
|
|
39
|
+
if (options.verbose) process.stdout.write(text);
|
|
40
|
+
});
|
|
41
|
+
child.stderr.on("data", (chunk) => {
|
|
42
|
+
const text = chunk.toString();
|
|
43
|
+
stderr += text;
|
|
44
|
+
if (options.verbose) process.stderr.write(text);
|
|
45
|
+
});
|
|
46
|
+
child.on("error", (error) => {
|
|
47
|
+
if (error && error.code === "ENOENT") {
|
|
48
|
+
reject(new CliError(`Required command not found in PATH: ${command}`));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
reject(error);
|
|
52
|
+
});
|
|
53
|
+
child.on("close", (exitCode) => {
|
|
54
|
+
resolve({ exitCode: typeof exitCode === "number" ? exitCode : 1, stdout, stderr });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function setCommandExecutorForTests(executor) {
|
|
60
|
+
commandExecutor = executor || defaultCommandExecutor;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resetCommandExecutorForTests() {
|
|
64
|
+
commandExecutor = defaultCommandExecutor;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function quote(value) {
|
|
68
|
+
const text = String(value);
|
|
69
|
+
return /[\s"]/u.test(text) ? JSON.stringify(text) : text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatCommand(command, args) {
|
|
73
|
+
return [command, ...args].map((value) => quote(value)).join(" ");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateVersion(targetVersion) {
|
|
77
|
+
if (targetVersion !== "latest" && !VERSION_PATTERN.test(targetVersion)) {
|
|
78
|
+
throw new ValidationError(`Invalid value for --version: "${targetVersion}". Must be "latest" or x.y.z.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractResolvedVersion(stdout) {
|
|
83
|
+
const lines = String(stdout || "")
|
|
84
|
+
.split(/\r?\n/u)
|
|
85
|
+
.map((line) => line.trim())
|
|
86
|
+
.filter(Boolean);
|
|
87
|
+
const candidate = (lines[lines.length - 1] || "").replace(/^['"]|['"]$/gu, "");
|
|
88
|
+
if (!VERSION_PATTERN.test(candidate)) {
|
|
89
|
+
throw new CliError(`npm registry lookup returned an invalid version for ${PACKAGE_NAME}: "${candidate || "<empty>"}"`);
|
|
90
|
+
}
|
|
91
|
+
return candidate;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function resolveTargetVersion(options) {
|
|
95
|
+
if (options.requestedVersion !== "latest") {
|
|
96
|
+
return options.requestedVersion;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let result;
|
|
100
|
+
try {
|
|
101
|
+
result = await commandExecutor(NPM_BIN, NPM_VIEW_ARGS, {
|
|
102
|
+
verbose: options.verbose,
|
|
103
|
+
shell: process.platform === "win32",
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new CliError(`npm registry lookup failed for ${PACKAGE_NAME} while resolving --version latest.\n${error.message}`, error.exitCode || 4);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (result.exitCode !== 0) {
|
|
110
|
+
const detail = (result.stderr || result.stdout || "").trim();
|
|
111
|
+
throw new CliError(`npm registry lookup failed for ${PACKAGE_NAME} while resolving --version latest (exit code ${result.exitCode}).${detail ? `\n${detail}` : ""}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
return extractResolvedVersion(result.stdout);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw new CliError(`npm registry lookup failed for ${PACKAGE_NAME} while resolving --version latest.\n${error.message}`, error.exitCode || 4);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildPlan(options) {
|
|
122
|
+
const npmArgs = ["install", "-g", `${PACKAGE_NAME}@${options.targetVersion}`];
|
|
123
|
+
return {
|
|
124
|
+
installedVersion: pkg.version,
|
|
125
|
+
requestedVersion: options.requestedVersion,
|
|
126
|
+
targetVersion: options.targetVersion,
|
|
127
|
+
updateNeeded: pkg.version !== options.targetVersion,
|
|
128
|
+
npmArgs,
|
|
129
|
+
npmCommand: formatCommand(NPM_DISPLAY, npmArgs),
|
|
130
|
+
projectPath: options.projectPath,
|
|
131
|
+
checkOnly: options.checkOnly,
|
|
132
|
+
skipProjectFiles: options.skipProjectFiles,
|
|
133
|
+
projectRefreshCommand: options.skipProjectFiles
|
|
134
|
+
? "skipped (--skip-project-files)"
|
|
135
|
+
: "skipped (R1 package-only update; project files are never mutated)",
|
|
136
|
+
runtimeRefreshCommand: "skipped (no runtime asset update in R1)",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseUpdateOptions(args) {
|
|
141
|
+
const parsed = parseFlags(args || [], FLAG_DEFS);
|
|
142
|
+
const flags = parsed.flags || {};
|
|
143
|
+
const positional = parsed.positional || parsed.positionals || [];
|
|
144
|
+
|
|
145
|
+
if (positional.length > 0) {
|
|
146
|
+
throw new ValidationError(`Unexpected arguments: ${positional.join(" ")}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const requestedVersion = flags.version || "latest";
|
|
150
|
+
validateVersion(requestedVersion);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
requestedVersion,
|
|
154
|
+
projectPath: path.resolve(flags["project-path"] || process.cwd()),
|
|
155
|
+
checkOnly: Boolean(flags["check-only"]),
|
|
156
|
+
skipProjectFiles: Boolean(flags["skip-project-files"]),
|
|
157
|
+
verbose: Boolean(flags.verbose),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function printPlan(plan) {
|
|
162
|
+
console.log(`${PRODUCT_NAME} update plan`);
|
|
163
|
+
console.log(` Installed package version: ${plan.installedVersion}`);
|
|
164
|
+
console.log(` Requested package version: ${plan.requestedVersion}`);
|
|
165
|
+
console.log(` Target package version: ${plan.targetVersion}`);
|
|
166
|
+
console.log(` Package update needed: ${plan.updateNeeded ? "yes" : `no (already installed: ${plan.installedVersion})`}`);
|
|
167
|
+
console.log(` Package refresh command: ${plan.npmCommand}`);
|
|
168
|
+
console.log(` Project path: ${plan.projectPath}`);
|
|
169
|
+
console.log(` Project file refresh: ${plan.projectRefreshCommand}`);
|
|
170
|
+
console.log(` Runtime asset refresh: ${plan.runtimeRefreshCommand}`);
|
|
171
|
+
console.log(` Mode: ${plan.checkOnly ? "check-only (no changes applied)" : "apply"}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function runCommand(label, command, args, options) {
|
|
175
|
+
const result = await commandExecutor(command, args, options);
|
|
176
|
+
if (result.exitCode !== 0) {
|
|
177
|
+
const detail = (result.stderr || result.stdout || "").trim();
|
|
178
|
+
throw new CliError(`${label} failed (exit code ${result.exitCode}).${detail ? `\n${detail}` : ""}`);
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function applyPlan(plan, options) {
|
|
184
|
+
console.log("");
|
|
185
|
+
console.log(`Applying ${PRODUCT_NAME} update...`);
|
|
186
|
+
console.log(` npm refresh: ${plan.npmCommand}`);
|
|
187
|
+
await runCommand("npm package refresh", NPM_BIN, plan.npmArgs, {
|
|
188
|
+
verbose: options.verbose,
|
|
189
|
+
shell: process.platform === "win32",
|
|
190
|
+
});
|
|
191
|
+
console.log(` project refresh: ${plan.projectRefreshCommand}`);
|
|
192
|
+
console.log(` runtime refresh: ${plan.runtimeRefreshCommand}`);
|
|
193
|
+
console.log("");
|
|
194
|
+
console.log(`${PRODUCT_NAME} update completed successfully.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function executeUpdate(args) {
|
|
198
|
+
const options = parseUpdateOptions(args);
|
|
199
|
+
const targetVersion = await resolveTargetVersion(options);
|
|
200
|
+
const plan = buildPlan({ ...options, targetVersion });
|
|
201
|
+
printPlan(plan);
|
|
202
|
+
|
|
203
|
+
if (options.checkOnly) {
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await applyPlan(plan, options);
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = {
|
|
212
|
+
buildPlan,
|
|
213
|
+
executeUpdate,
|
|
214
|
+
formatCommand,
|
|
215
|
+
parseUpdateOptions,
|
|
216
|
+
resetCommandExecutorForTests,
|
|
217
|
+
resolveTargetVersion,
|
|
218
|
+
setCommandExecutorForTests,
|
|
219
|
+
};
|
|
@@ -1,125 +0,0 @@
|
|
|
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
|
-
};
|