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.
@@ -43,36 +43,107 @@ function listOrNeeds(values, marker) {
43
43
  return `- ${needs(marker)}`;
44
44
  }
45
45
 
46
- function tokenUsageOrDefault(value) {
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
- spacing_keys: cleanArray(payload.spacing_keys || payload.spacingKeys),
52
- radius_keys: cleanArray(payload.radius_keys || payload.radiusKeys),
130
+ section_keys: cleanArray(payload.section_keys || payload.sectionKeys),
131
+ component_keys: cleanArray(payload.component_keys || payload.componentKeys),
53
132
  };
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;
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
- `- 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")}`,
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.v2",
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: tokenUsageOrDefault(screen.visualTokenUsage),
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
- model.gaps = [
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
- ].filter((value) => String(value).startsWith("NEEDS_"));
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
- };