sdtk-design-kit 0.2.0 → 0.3.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.
- 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/help.js +3 -3
- package/src/commands/init.js +53 -0
- package/src/commands/prototype.js +139 -638
- package/src/commands/review.js +515 -458
- package/src/commands/start.js +22 -5
- package/src/commands/status.js +10 -2
- package/src/commands/system.js +186 -14
- package/src/lib/anti-slop-lint.js +199 -0
- 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/prototype-density.js +0 -147
- package/src/lib/prototype-renderer.js +0 -325
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
|
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const MIN_TOTAL_SCREEN_BYTES = 80 * 1024;
|
|
4
|
-
|
|
5
|
-
const ROLE_FLOORS = {
|
|
6
|
-
home: { minBytes: 8 * 1024 },
|
|
7
|
-
category: { minBytes: 6 * 1024 },
|
|
8
|
-
"product-detail": { minBytes: 6 * 1024 },
|
|
9
|
-
search: { minBytes: 4 * 1024 },
|
|
10
|
-
cart: { minBytes: 5 * 1024 },
|
|
11
|
-
checkout: { minBytes: 5 * 1024 },
|
|
12
|
-
"order-history": { minBytes: 4 * 1024 },
|
|
13
|
-
"order-detail": { minBytes: 4 * 1024 },
|
|
14
|
-
"account-info": { minBytes: 4 * 1024 },
|
|
15
|
-
"configurator-bom": { minBytes: 15 * 1024 },
|
|
16
|
-
"mode-b-configurator": { minBytes: 15 * 1024 },
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
function normalizeScreenRole(screen) {
|
|
20
|
-
const token = `${screen && screen.screenId ? screen.screenId : ""} ${screen && screen.title ? screen.title : ""} ${screen && screen.template_role ? screen.template_role : ""}`.toLowerCase();
|
|
21
|
-
if (token.includes("home")) return "home";
|
|
22
|
-
if (token.includes("category") || token.includes("catalog")) return "category";
|
|
23
|
-
if (token.includes("product-detail") || token.includes("product detail") || token.includes("pdp")) return "product-detail";
|
|
24
|
-
if (token.includes("search")) return "search";
|
|
25
|
-
if (token.includes("cart") && !token.includes("checkout")) return "cart";
|
|
26
|
-
if (token.includes("checkout")) return "checkout";
|
|
27
|
-
if (token.includes("order-history") || token.includes("order history")) return "order-history";
|
|
28
|
-
if (token.includes("order-detail") || token.includes("order detail")) return "order-detail";
|
|
29
|
-
if (token.includes("account")) return "account-info";
|
|
30
|
-
if (token.includes("configurator") || token.includes("bom") || token.includes("mode-b")) return "configurator-bom";
|
|
31
|
-
return screen && screen.screenId ? screen.screenId : "generic";
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function countMatches(html, pattern) {
|
|
35
|
-
const matches = String(html || "").match(pattern);
|
|
36
|
-
return matches ? matches.length : 0;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function countTableColumns(html) {
|
|
40
|
-
const headerRows = String(html || "").match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
|
41
|
-
let max = 0;
|
|
42
|
-
for (const row of headerRows) {
|
|
43
|
-
max = Math.max(max, countMatches(row, /<t[hd]\b/gi));
|
|
44
|
-
}
|
|
45
|
-
return max;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function roleStructureFindings(role, html) {
|
|
49
|
-
const lower = String(html || "").toLowerCase();
|
|
50
|
-
const findings = [];
|
|
51
|
-
const requireText = (text, label) => {
|
|
52
|
-
if (!lower.includes(text)) findings.push(`missing ${label}`);
|
|
53
|
-
};
|
|
54
|
-
const requireCount = (pattern, minimum, label) => {
|
|
55
|
-
const count = countMatches(html, pattern);
|
|
56
|
-
if (count < minimum) findings.push(`${label} count ${count} < ${minimum}`);
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
if (role === "home") {
|
|
60
|
-
requireText("hero", "hero");
|
|
61
|
-
requireCount(/class="[^"]*(feature-card|category-card|product-card)[^"]*"/gi, 3, "feature/category card");
|
|
62
|
-
} else if (role === "category") {
|
|
63
|
-
requireText("filter sidebar", "filter sidebar");
|
|
64
|
-
requireCount(/class="[^"]*product-card[^"]*"/gi, 6, "product card");
|
|
65
|
-
} else if (role === "product-detail") {
|
|
66
|
-
requireCount(/<tr\b/gi, 5, "spec table row");
|
|
67
|
-
requireText("qty-stepper", "quantity stepper");
|
|
68
|
-
requireText("add to cart", "add-to-cart");
|
|
69
|
-
} else if (role === "search") {
|
|
70
|
-
requireText("search-toolbar", "search toolbar");
|
|
71
|
-
requireText("result-list", "result list");
|
|
72
|
-
requireText("no-result", "no-result state");
|
|
73
|
-
} else if (role === "cart") {
|
|
74
|
-
requireText("cart-table", "cart table");
|
|
75
|
-
requireText("summary panel", "summary panel");
|
|
76
|
-
requireText("checkout", "checkout CTA");
|
|
77
|
-
} else if (role === "checkout") {
|
|
78
|
-
requireText("checkout-stepper", "multi-step indicator");
|
|
79
|
-
requireCount(/<(input|select|textarea)\b/gi, 4, "form field");
|
|
80
|
-
requireText("summary", "summary");
|
|
81
|
-
} else if (role === "order-history") {
|
|
82
|
-
requireCount(/<tr\b|class="[^"]*history-row/gi, 3, "order row");
|
|
83
|
-
} else if (role === "order-detail") {
|
|
84
|
-
requireText("timeline", "timeline");
|
|
85
|
-
requireText("detail summary", "detail summary");
|
|
86
|
-
} else if (role === "account-info") {
|
|
87
|
-
requireText("account sidebar", "account sidebar");
|
|
88
|
-
requireCount(/<(input|select|textarea)\b/gi, 4, "form field");
|
|
89
|
-
} else if (role === "configurator-bom" || role === "mode-b-configurator") {
|
|
90
|
-
requireText("configurator wizard", "configurator wizard");
|
|
91
|
-
requireCount(/class="[^"]*stepper-step[^"]*"/gi, 4, "stepper step");
|
|
92
|
-
const columns = countTableColumns(html);
|
|
93
|
-
if (columns < 5) findings.push(`table column count ${columns} < 5`);
|
|
94
|
-
requireText("preview panel", "preview panel");
|
|
95
|
-
}
|
|
96
|
-
return findings;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function assessPrototypePage({ screenId, title, role, relativePath, html }) {
|
|
100
|
-
const normalizedRole = role || normalizeScreenRole({ screenId, title });
|
|
101
|
-
const byteLength = Buffer.byteLength(String(html || ""), "utf8");
|
|
102
|
-
const floor = ROLE_FLOORS[normalizedRole] || { minBytes: 0 };
|
|
103
|
-
const findings = [];
|
|
104
|
-
if (byteLength < floor.minBytes) {
|
|
105
|
-
findings.push(`HTML bytes ${byteLength} < ${floor.minBytes}`);
|
|
106
|
-
}
|
|
107
|
-
findings.push(...roleStructureFindings(normalizedRole, html));
|
|
108
|
-
return {
|
|
109
|
-
screenId,
|
|
110
|
-
title,
|
|
111
|
-
role: normalizedRole,
|
|
112
|
-
relativePath,
|
|
113
|
-
byteLength,
|
|
114
|
-
minBytes: floor.minBytes,
|
|
115
|
-
pass: findings.length === 0,
|
|
116
|
-
findings,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function assessPrototypeDensity(pages) {
|
|
121
|
-
const pageResults = (Array.isArray(pages) ? pages : []).map(assessPrototypePage);
|
|
122
|
-
const totalBytes = pageResults.reduce((sum, page) => sum + page.byteLength, 0);
|
|
123
|
-
const findings = [];
|
|
124
|
-
if (totalBytes < MIN_TOTAL_SCREEN_BYTES) {
|
|
125
|
-
findings.push(`Total per-screen HTML bytes ${totalBytes} < ${MIN_TOTAL_SCREEN_BYTES}`);
|
|
126
|
-
}
|
|
127
|
-
for (const page of pageResults) {
|
|
128
|
-
if (!page.pass) {
|
|
129
|
-
findings.push(`${page.screenId}: ${page.findings.join("; ")}`);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return {
|
|
133
|
-
pass: findings.length === 0,
|
|
134
|
-
totalBytes,
|
|
135
|
-
minTotalBytes: MIN_TOTAL_SCREEN_BYTES,
|
|
136
|
-
pages: pageResults,
|
|
137
|
-
findings,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
module.exports = {
|
|
142
|
-
MIN_TOTAL_SCREEN_BYTES,
|
|
143
|
-
ROLE_FLOORS,
|
|
144
|
-
assessPrototypeDensity,
|
|
145
|
-
assessPrototypePage,
|
|
146
|
-
normalizeScreenRole,
|
|
147
|
-
};
|