@12ui/design 0.2.36 → 0.2.38

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.
Files changed (101) hide show
  1. package/SKILL.md +1 -1
  2. package/dist/branch-client.d.ts +12 -0
  3. package/dist/branch-client.d.ts.map +1 -1
  4. package/dist/branch-client.js.map +1 -1
  5. package/dist/branch-completion.d.ts.map +1 -1
  6. package/dist/branch-completion.js +1 -0
  7. package/dist/branch-completion.js.map +1 -1
  8. package/dist/branch-execution-types.d.ts +3 -0
  9. package/dist/branch-execution-types.d.ts.map +1 -1
  10. package/dist/branch-execution.d.ts.map +1 -1
  11. package/dist/branch-execution.js +13 -0
  12. package/dist/branch-execution.js.map +1 -1
  13. package/dist/branch-identity.d.ts +1 -1
  14. package/dist/branch-identity.js +1 -1
  15. package/dist/branch-plan.d.ts +3 -1
  16. package/dist/branch-plan.d.ts.map +1 -1
  17. package/dist/branch-plan.js +2 -0
  18. package/dist/branch-plan.js.map +1 -1
  19. package/dist/branch-run-record.d.ts +18 -0
  20. package/dist/branch-run-record.d.ts.map +1 -1
  21. package/dist/branch-run-record.js.map +1 -1
  22. package/dist/branch-winner.d.ts.map +1 -1
  23. package/dist/branch-winner.js +7 -0
  24. package/dist/branch-winner.js.map +1 -1
  25. package/dist/cli-branch-command.d.ts.map +1 -1
  26. package/dist/cli-branch-command.js +61 -7
  27. package/dist/cli-branch-command.js.map +1 -1
  28. package/dist/cli-help/design.d.ts +1 -1
  29. package/dist/cli-help/design.d.ts.map +1 -1
  30. package/dist/cli-help/design.js +1 -0
  31. package/dist/cli-help/design.js.map +1 -1
  32. package/dist/cli-help/index.d.ts +1 -1
  33. package/dist/cli-prototype-command.d.ts +3 -2
  34. package/dist/cli-prototype-command.d.ts.map +1 -1
  35. package/dist/cli-prototype-command.js +9 -3
  36. package/dist/cli-prototype-command.js.map +1 -1
  37. package/dist/cli-review-command.js +2 -2
  38. package/dist/cli-review-command.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/prototype-branch-contract.d.ts +67 -0
  44. package/dist/prototype-branch-contract.d.ts.map +1 -0
  45. package/dist/prototype-branch-contract.js +403 -0
  46. package/dist/prototype-branch-contract.js.map +1 -0
  47. package/dist/prototype-poststep.d.ts +27 -0
  48. package/dist/prototype-poststep.d.ts.map +1 -0
  49. package/dist/prototype-poststep.js +97 -0
  50. package/dist/prototype-poststep.js.map +1 -0
  51. package/dist/prototype-run-record.d.ts +92 -0
  52. package/dist/prototype-run-record.d.ts.map +1 -0
  53. package/dist/prototype-run-record.js +236 -0
  54. package/dist/prototype-run-record.js.map +1 -0
  55. package/dist/prototype-runner.d.ts +13 -1
  56. package/dist/prototype-runner.d.ts.map +1 -1
  57. package/dist/prototype-runner.js +79 -20
  58. package/dist/prototype-runner.js.map +1 -1
  59. package/open-design.json +2 -2
  60. package/package.json +1 -1
  61. package/prototype-kit/analyze.mjs +48 -6
  62. package/prototype-kit/build.mjs +249 -65
  63. package/prototype-kit/inventory.mjs +290 -2
  64. package/prototype-kit/lib/holding-composition.mjs +67 -0
  65. package/prototype-kit/lib/nav-active-state.mjs +67 -0
  66. package/prototype-kit/lib/review-framing.mjs +72 -0
  67. package/prototype-kit/lib/runtime-check.mjs +361 -28
  68. package/prototype-kit/lib/shell-consistency.mjs +68 -0
  69. package/prototype-kit/lib/shell-stitching.mjs +221 -0
  70. package/prototype-kit/runtime/proto.css +8 -3
  71. package/prototype-kit/tests/README.md +4 -1
  72. package/prototype-kit/tests/adversarial-runtime.mjs +85 -2
  73. package/prototype-kit/tests/fixtures/holding-runtime/PROVENANCE.md +22 -0
  74. package/prototype-kit/tests/fixtures/holding-runtime/band-ownership-fire.html +10 -0
  75. package/prototype-kit/tests/fixtures/holding-runtime/band-ownership-silent.html +10 -0
  76. package/prototype-kit/tests/fixtures/holding-runtime/declared-width-fire.html +11 -0
  77. package/prototype-kit/tests/fixtures/holding-runtime/declared-width-silent.html +11 -0
  78. package/prototype-kit/tests/fixtures/holding-runtime/ground-registration-mismatch-fire.html +5 -0
  79. package/prototype-kit/tests/fixtures/holding-runtime/ground-registration-mismatch-silent.html +5 -0
  80. package/prototype-kit/tests/fixtures/holding-runtime/holding-contrast-fire.html +5 -0
  81. package/prototype-kit/tests/fixtures/holding-runtime/holding-contrast-silent.html +5 -0
  82. package/prototype-kit/tests/fixtures/holding-runtime/positioned-ground-fire.html +5 -0
  83. package/prototype-kit/tests/fixtures/holding-runtime/positioned-ground-silent.html +5 -0
  84. package/prototype-kit/tests/fixtures/holding-runtime/shell-geometry-fire.html +5 -0
  85. package/prototype-kit/tests/fixtures/holding-runtime/shell-geometry-silent.html +5 -0
  86. package/prototype-kit/tests/fixtures/holding-runtime/shell-intersection-fire.html +5 -0
  87. package/prototype-kit/tests/fixtures/holding-runtime/shell-intersection-silent.html +5 -0
  88. package/prototype-kit/tests/fixtures/runtime/history.html +1 -1
  89. package/prototype-kit/tests/fixtures/shell-nav-runtime/PROVENANCE.md +6 -0
  90. package/prototype-kit/tests/fixtures/shell-nav-runtime/active-fire.html +3 -0
  91. package/prototype-kit/tests/fixtures/shell-nav-runtime/proto-manifest.json +20 -0
  92. package/prototype-kit/tests/fixtures/shell-nav-runtime/shell-fire.html +3 -0
  93. package/prototype-kit/tests/fixtures/shell-nav-runtime/shell-silent.html +3 -0
  94. package/prototype-kit/tests/fixtures/shell-nav-runtime/unlinked-silent.html +3 -0
  95. package/prototype-kit/tests/fixtures/three-shell/pages/p0-home.html +3 -3
  96. package/prototype-kit/tests/fixtures/three-shell/pages/p1-reports.html +3 -3
  97. package/prototype-kit/tests/lib/run-process.mjs +8 -2
  98. package/prototype-kit/tests/offline-check.mjs +143 -0
  99. package/prototype-kit/tests/population-sweep.mjs +26 -7
  100. package/prototype-kit/tests/rules-check.mjs +80 -0
  101. package/prototype-kit/verify-runtime.mjs +5 -1
@@ -33,6 +33,294 @@ for (const file of files) {
33
33
  const planPage = planPages.find((p) => p.id === pageId);
34
34
  await page.goto('file://' + path.resolve(pagesDir, file), { waitUntil: 'load' });
35
35
  await page.evaluate(() => document.fonts.ready);
36
+ const declaredBands = await page.evaluate(() => {
37
+ const roots = [...document.querySelectorAll('.ly-band[data-ly-band]')];
38
+ if (roots.length === 0) return [{ key: 'native', conditions: [] }];
39
+ const out = new Map(roots.map((root) => [root.getAttribute('data-ly-band'), []]));
40
+ const visit = (rules, conditions = []) => {
41
+ for (const rule of rules) {
42
+ if (rule.cssRules && rule.conditionText) {
43
+ visit(rule.cssRules, [...conditions, rule.conditionText]);
44
+ continue;
45
+ }
46
+ if (!rule.selectorText || !rule.style) continue;
47
+ for (const key of out.keys()) {
48
+ if (rule.selectorText.includes(`.ly-band[data-ly-band="${key}"]`)
49
+ && rule.style.getPropertyValue('display') === 'block') {
50
+ out.set(key, conditions);
51
+ }
52
+ }
53
+ }
54
+ };
55
+ for (const sheet of document.styleSheets) {
56
+ try { visit(sheet.cssRules || []); } catch { /* cross-origin font sheet: no band rules */ }
57
+ }
58
+ return [...out].map(([key, conditions]) => ({ key, conditions }));
59
+ });
60
+ const viewportForBand = async ({ key, conditions }) => {
61
+ const suffix = Number(key.match(/(\d+)(?!.*\d)/)?.[1]);
62
+ const boundaries = conditions.flatMap((condition) => (
63
+ [...condition.matchAll(/(?:min|max)-width:\s*(\d+)px/g)].map((match) => Number(match[1]))
64
+ ));
65
+ const candidates = [
66
+ ...(key === 'native' && conditions.length === 0 ? [1536] : []),
67
+ ...(Number.isFinite(suffix) ? [suffix] : []),
68
+ ...boundaries.flatMap((value) => [value - 1, value, value + 1]),
69
+ 390, 768, 1024, 1280, 1536, 1920, 2560,
70
+ ].filter((value, index, all) => value > 0 && value <= 4096 && all.indexOf(value) === index);
71
+ for (const width of candidates) {
72
+ await page.setViewportSize({ width, height: 1024 });
73
+ const selected = await page.evaluate(({ bandKey, media }) => (
74
+ media.every((condition) => matchMedia(condition).matches)
75
+ && (document.querySelectorAll('.ly-band[data-ly-band]').length === 0
76
+ ? bandKey === 'native'
77
+ : [...document.querySelectorAll('.ly-band[data-ly-band]')]
78
+ .filter((root) => getComputedStyle(root).display !== 'none')
79
+ .some((root) => root.getAttribute('data-ly-band') === bandKey))
80
+ ), { bandKey: key, media: conditions });
81
+ if (selected) return width;
82
+ }
83
+ throw new Error(`Prototype input error: declared responsive band ${key} has no selectable viewport`);
84
+ };
85
+ const bands = [];
86
+ for (const declared of declaredBands) {
87
+ const viewportWidth = await viewportForBand(declared);
88
+ await page.setViewportSize({ width: viewportWidth, height: 1024 });
89
+ const band = await page.evaluate(async ({ key, viewportWidth, conditions }) => {
90
+ const round = (value) => Math.round(value * 10) / 10;
91
+ const root = key === 'native' && !document.querySelector('.ly-band[data-ly-band]')
92
+ ? document.body
93
+ : [...document.querySelectorAll('.ly-band[data-ly-band]')]
94
+ .find((candidate) => candidate.getAttribute('data-ly-band') === key);
95
+ const screen = root?.querySelector('.generated-screen') || (root === document.body ? root : null);
96
+ if (!root || !screen || getComputedStyle(root).display === 'none') {
97
+ throw new Error(`responsive band ${key} is not active at ${viewportWidth}px`);
98
+ }
99
+ const screenBox = screen.getBoundingClientRect();
100
+ const host = screen.querySelector('.ly-anchor-canvas') || screen;
101
+ const hostBox = host.getBoundingClientRect();
102
+ const hostStyle = getComputedStyle(host);
103
+ const hostZoom = parseFloat(hostStyle.zoom) || 1;
104
+ const width = parseFloat(hostStyle.width) || screenBox.width / hostZoom;
105
+ const height = parseFloat(hostStyle.height) || screenBox.height / hostZoom;
106
+ const visibleCanvasHeight = Math.min(height, window.innerHeight / hostZoom);
107
+ const relativeBox = (element) => {
108
+ const box = element.getBoundingClientRect();
109
+ return [
110
+ round((box.left - hostBox.left) / hostZoom),
111
+ round((box.top - hostBox.top) / hostZoom),
112
+ round(box.width / hostZoom),
113
+ round(box.height / hostZoom),
114
+ ];
115
+ };
116
+ const shellCandidates = [...screen.querySelectorAll('header,footer,aside,nav,[id$="-nav"],[id$="-header"],[id$="-footer"],[id$="-sidebar"]')]
117
+ .map((element) => {
118
+ const [x, y, w, h] = relativeBox(element);
119
+ const tag = element.tagName.toLowerCase();
120
+ const id = element.id || '';
121
+ let region = null;
122
+ if ((tag === 'header' || tag === 'nav' || id.endsWith('-nav') || id.endsWith('-header')) && y <= visibleCanvasHeight * 0.2) region = 'header';
123
+ else if ((tag === 'footer' || id.endsWith('-footer')) && y + h >= visibleCanvasHeight * 0.8) region = 'footer';
124
+ else if ((tag === 'aside' || id.endsWith('-sidebar')) && x <= width * 0.1 && h >= visibleCanvasHeight * 0.5) region = 'sidebar';
125
+ return { containerId: element.id, region, box: [x, y, w, h] };
126
+ })
127
+ .filter((shell) => shell.containerId && shell.region && shell.box[2] > 0 && shell.box[3] > 0);
128
+ const shells = [];
129
+ for (const region of ['sidebar', 'header', 'footer']) {
130
+ const winner = shellCandidates.filter((shell) => shell.region === region)
131
+ .sort((left, right) => right.box[2] * right.box[3] - left.box[2] * left.box[3])[0];
132
+ if (winner) shells.push(winner);
133
+ }
134
+ const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim().toLocaleLowerCase();
135
+ const markerTarget = (label) => {
136
+ const value = String(label || '').replace(/\s+/g, ' ').trim();
137
+ const beneath = value.match(/\bunderline\s+beneath\s+(?:footer\s+)?(.+)$/i);
138
+ if (beneath) return beneath[1].trim();
139
+ const navigation = value.match(/^(.+?)\s+navigation\s+underline$/i);
140
+ return navigation ? navigation[1].trim() : null;
141
+ };
142
+ for (const shell of shells) {
143
+ const element = document.getElementById(shell.containerId);
144
+ const rootBox = element.getBoundingClientRect();
145
+ const itemNodes = [...element.querySelectorAll('.text,h1,h2,h3,h4,h5,h6,p,a,button,img,.divider,.icon')]
146
+ .filter((node, index, all) => node.id && all.indexOf(node) === index);
147
+ const items = itemNodes.map((node) => {
148
+ const box = relativeBox(node);
149
+ const style = getComputedStyle(node);
150
+ const chain = [];
151
+ for (let ancestor = node.parentElement; ancestor && ancestor !== element; ancestor = ancestor.parentElement) {
152
+ if (ancestor.id) chain.push(ancestor.id);
153
+ }
154
+ return {
155
+ id: node.id,
156
+ tag: node.tagName.toLowerCase(),
157
+ cls: typeof node.className === 'string' ? node.className : '',
158
+ text: ['IMG'].includes(node.tagName) ? '' : (node.textContent || '').replace(/\s+/g, ' ').trim(),
159
+ label: node.getAttribute('alt') || node.getAttribute('aria-label') || '',
160
+ x: box[0], y: box[1], w: box[2], h: box[3],
161
+ color: style.color,
162
+ background: style.backgroundColor,
163
+ fontSize: parseFloat(style.fontSize) || 0,
164
+ fontWeight: style.fontWeight,
165
+ chain,
166
+ };
167
+ }).filter((item) => item.w > 0 && item.h > 0);
168
+ const navItems = items.filter((item) => item.text && item.text.length <= 32 && item.fontSize >= 8);
169
+ const markerIds = new Set();
170
+ for (const item of items) {
171
+ const labelled = markerTarget(item.label);
172
+ if (labelled && navItems.some((nav) => normalize(nav.text) === normalize(labelled))) {
173
+ markerIds.add(item.id);
174
+ continue;
175
+ }
176
+ if (!String(item.cls).split(/\s+/).includes('divider')) continue;
177
+ const matches = navItems.filter((nav) => {
178
+ const overlap = Math.min(item.x + item.w, nav.x + nav.w) - Math.max(item.x, nav.x);
179
+ return overlap / Math.max(1, Math.min(item.w, nav.w)) >= 0.7
180
+ && item.y >= nav.y + nav.h - 2
181
+ && item.y - (nav.y + nav.h) <= 28
182
+ && item.w >= nav.w * 0.5
183
+ && item.w <= nav.w * 1.5;
184
+ });
185
+ if (matches.length === 1) markerIds.add(item.id);
186
+ }
187
+ const visualNodes = [element, ...element.querySelectorAll('*')]
188
+ .filter((node) => ![...markerIds].some((id) => node.id === id || node.closest(`#${CSS.escape(id)}`)))
189
+ .map((node) => {
190
+ const box = node.getBoundingClientRect();
191
+ const style = getComputedStyle(node);
192
+ return {
193
+ tag: node.tagName.toLowerCase(),
194
+ cls: typeof node.className === 'string' ? node.className : '',
195
+ text: node.children.length === 0 ? (node.textContent || '').replace(/\s+/g, ' ').trim() : '',
196
+ label: node.getAttribute('alt') || node.getAttribute('aria-label') || '',
197
+ source: node.getAttribute('src') || '',
198
+ box: [
199
+ round((box.left - rootBox.left) / hostZoom),
200
+ round((box.top - rootBox.top) / hostZoom),
201
+ round(box.width / hostZoom),
202
+ round(box.height / hostZoom),
203
+ ],
204
+ style: [
205
+ style.display, style.position, style.color, style.backgroundColor,
206
+ style.fontFamily, style.fontSize, style.fontWeight, style.lineHeight,
207
+ style.borderTopWidth, style.borderTopColor, style.borderRadius,
208
+ style.opacity, style.objectFit,
209
+ ],
210
+ };
211
+ }).filter((node) => node.box[2] > 0 && node.box[3] > 0);
212
+ const encoded = new TextEncoder().encode(JSON.stringify({ region: shell.region, visualNodes }));
213
+ const digest = await crypto.subtle.digest('SHA-256', encoded);
214
+ shell.signature = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
215
+ shell.items = items;
216
+ shell.activeMarkerIds = [...markerIds];
217
+ }
218
+ const shellIds = new Set(shells.map((shell) => shell.containerId));
219
+ const textItems = [...screen.querySelectorAll('.text,h1,h2,h3,h4,h5,h6,p,a,button')]
220
+ .map((element) => {
221
+ const box = relativeBox(element);
222
+ const style = getComputedStyle(element);
223
+ const inShell = [...shellIds].some((id) => element.closest(`#${CSS.escape(id)}`));
224
+ return {
225
+ id: element.id,
226
+ text: (element.textContent || '').replace(/\s+/g, ' ').trim(),
227
+ x: box[0], y: box[1], w: box[2], h: box[3],
228
+ fontSize: parseFloat(style.fontSize) || 0,
229
+ fontWeight: style.fontWeight,
230
+ fontFamily: style.fontFamily.split(',')[0].replace(/["']/g, ''),
231
+ color: style.color,
232
+ inShell,
233
+ };
234
+ })
235
+ .filter((item) => item.text && !item.inShell && item.w > 0 && item.h > 0);
236
+ const rootColor = getComputedStyle(document.documentElement).backgroundColor;
237
+ const screenColor = getComputedStyle(screen).backgroundColor;
238
+ const neutralSurface = rootColor !== 'rgba(0, 0, 0, 0)'
239
+ ? rootColor
240
+ : screenColor !== 'rgba(0, 0, 0, 0)' ? screenColor : '#ffffff';
241
+ const rgb = (value) => {
242
+ const match = String(value).match(/rgba?\((\d+(?:\.\d+)?)[, ]+(\d+(?:\.\d+)?)[, ]+(\d+(?:\.\d+)?)/);
243
+ if (match) return match.slice(1, 4).map(Number);
244
+ const hex = String(value).match(/^#([0-9a-f]{6})$/i)?.[1];
245
+ return hex ? [0, 2, 4].map((offset) => parseInt(hex.slice(offset, offset + 2), 16)) : null;
246
+ };
247
+ const luminance = (value) => {
248
+ const channels = rgb(value);
249
+ if (!channels) return null;
250
+ const linear = channels.map((channel) => {
251
+ const normalized = channel / 255;
252
+ return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
253
+ });
254
+ return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
255
+ };
256
+ const contrast = (foreground, background) => {
257
+ const left = luminance(foreground);
258
+ const right = luminance(background);
259
+ if (left === null || right === null) return 0;
260
+ return (Math.max(left, right) + 0.05) / (Math.min(left, right) + 0.05);
261
+ };
262
+ const groundRegistration = screen.getAttribute('data-ly-ground-registration');
263
+ const colorCounts = new Map();
264
+ for (const item of textItems) colorCounts.set(item.color, (colorCounts.get(item.color) || 0) + 1);
265
+ const commonDesignInk = [...colorCounts]
266
+ .sort((left, right) => right[1] - left[1])[0]?.[0];
267
+ const neutralContrastInk = [...colorCounts.keys()]
268
+ .sort((left, right) => contrast(right, neutralSurface) - contrast(left, neutralSurface))[0];
269
+ const fallbackInk = contrast('#111111', neutralSurface) >= contrast('#ffffff', neutralSurface)
270
+ ? '#111111'
271
+ : '#ffffff';
272
+ const heading = textItems
273
+ .sort((left, right) => right.fontSize - left.fontSize || left.y - right.y)[0] || null;
274
+ const usesNeutralSurface = groundRegistration === 'positioned-scene';
275
+ const readableTextColor = usesNeutralSurface
276
+ ? contrast(neutralContrastInk, neutralSurface) >= 4.5 ? neutralContrastInk : fallbackInk
277
+ : heading?.color || commonDesignInk || fallbackInk;
278
+ if (heading && usesNeutralSurface && contrast(heading.color, neutralSurface) < 4.5) {
279
+ heading.color = readableTextColor;
280
+ }
281
+ return {
282
+ key,
283
+ viewportWidth,
284
+ conditions,
285
+ width: round(width),
286
+ height: round(height),
287
+ neutralSurface,
288
+ readableTextColor,
289
+ groundRegistration: groundRegistration === 'positioned-scene' ? groundRegistration : null,
290
+ shells,
291
+ heading,
292
+ };
293
+ }, { key: declared.key, viewportWidth, conditions: declared.conditions });
294
+ const hostOrigins = [];
295
+ for (const sampleWidth of [...new Set([1536, 390, viewportWidth])]) {
296
+ await page.setViewportSize({ width: sampleWidth, height: 1024 });
297
+ const origin = await page.evaluate(({ key }) => {
298
+ const round = (value) => Math.round(value * 10) / 10;
299
+ const root = key === 'native' && !document.querySelector('.ly-band[data-ly-band]')
300
+ ? document.body
301
+ : [...document.querySelectorAll('.ly-band[data-ly-band]')]
302
+ .find((candidate) => candidate.getAttribute('data-ly-band') === key);
303
+ const screen = root?.querySelector('.generated-screen') || (root === document.body ? root : null);
304
+ if (!root || !screen || getComputedStyle(root).display === 'none') return null;
305
+ const rootBox = root.getBoundingClientRect();
306
+ if (rootBox.width <= 0 || rootBox.height <= 0) return null;
307
+ const screenBox = screen.getBoundingClientRect();
308
+ const host = screen.querySelector('.ly-anchor-canvas') || screen;
309
+ const hostBox = host.getBoundingClientRect();
310
+ const hostZoom = parseFloat(getComputedStyle(host).zoom) || 1;
311
+ return [
312
+ round((hostBox.left - screenBox.left) / hostZoom),
313
+ round((hostBox.top - screenBox.top) / hostZoom),
314
+ ];
315
+ }, { key: declared.key });
316
+ if (origin) hostOrigins.push({ viewportWidth: sampleWidth, origin });
317
+ }
318
+ if (!hostOrigins.some((sample) => sample.viewportWidth === viewportWidth)) {
319
+ throw new Error(`Prototype input error: no measured host origin for band ${declared.key} at ${viewportWidth}px`);
320
+ }
321
+ bands.push({ ...band, hostOrigins });
322
+ }
323
+ await page.setViewportSize({ width: 1536, height: 1024 });
36
324
  const sections = await page.evaluate(() => {
37
325
  const secs = [...document.querySelectorAll('section[id^="viewport-"]')].filter((s) => /^viewport-\d+-[A-Za-z0-9]+$/.test(s.id));
38
326
  const out = [];
@@ -95,8 +383,8 @@ for (const file of files) {
95
383
  }
96
384
  return out;
97
385
  });
98
- result.pages.push({ pageId, file, planPage, sections });
99
- console.error(`${pageId}: ${sections.length} section(s), ${sections.reduce((n, s) => n + s.items.length, 0)} items`);
386
+ result.pages.push({ pageId, file, planPage, bands, sections });
387
+ console.error(`${pageId}: ${sections.length} section(s), ${bands.length} band(s), ${sections.reduce((n, s) => n + s.items.length, 0)} items`);
100
388
  }
101
389
 
102
390
  await browser.close();
@@ -0,0 +1,67 @@
1
+ export const HOLDING_SURFACE_ATTRIBUTE = 'data-proto-holding-surface';
2
+ export const HOLDING_SHELL_ATTRIBUTE = 'data-proto-shell-region';
3
+
4
+ const escapeText = (value) => String(value)
5
+ .replace(/&/g, '&amp;')
6
+ .replace(/</g, '&lt;');
7
+
8
+ const escapeAttribute = (value) => escapeText(value).replace(/"/g, '&quot;');
9
+
10
+ export function holdingContentArea({ width, height, shells }) {
11
+ const area = { left: 0, top: 0, right: width, bottom: height };
12
+ for (const shell of shells) {
13
+ const [x, y, w, h] = shell.box;
14
+ if (shell.region === 'sidebar') area.left = Math.max(area.left, x + w);
15
+ if (shell.region === 'header') area.top = Math.max(area.top, y + h);
16
+ if (shell.region === 'footer') area.bottom = Math.min(area.bottom, y);
17
+ }
18
+ if (area.right <= area.left || area.bottom <= area.top) {
19
+ throw new Error(`Holding composition has no content area inside ${width}x${height}.`);
20
+ }
21
+ return area;
22
+ }
23
+
24
+ export function holdingBlockGeometry({ area, heading, viewportWidth }) {
25
+ const mobile = viewportWidth <= 600;
26
+ const inset = mobile ? 20 : Math.min(160, Math.max(32, Math.round((heading?.x ?? area.left + 40) - area.left)));
27
+ const left = area.left + inset;
28
+ const top = mobile
29
+ ? area.top + 28
30
+ : Math.max(area.top + 40, Math.min(area.top + 200, Math.round(heading?.y ?? area.top + 60)));
31
+ const width = Math.min(area.right - left - inset, 1100);
32
+ if (width <= 0 || top >= area.bottom) {
33
+ throw new Error('Holding composition has no room for its holding surface.');
34
+ }
35
+ return { left, top, width };
36
+ }
37
+
38
+ export function shellPlacementCss(shell) {
39
+ const [left, top, width, height] = shell.box;
40
+ return `#${shell.containerId}{position:absolute!important;left:${left}px!important;top:${top}px!important;`
41
+ + `width:${width}px!important;height:${height}px!important;margin:0!important;z-index:5}`;
42
+ }
43
+
44
+ export function holdingBlockMarkup({
45
+ title,
46
+ purpose,
47
+ backTarget,
48
+ backTitle,
49
+ geometry,
50
+ heading,
51
+ tokens,
52
+ textColor,
53
+ mobile,
54
+ }) {
55
+ const headingFamily = escapeAttribute(heading?.fontFamily || tokens.headingFont || 'system-ui');
56
+ const headingSize = Number(heading?.fontSize || tokens.headingSize || 28);
57
+ const headingWeight = escapeAttribute(heading?.fontWeight || tokens.headingWeight || 600);
58
+ const headingColor = escapeAttribute(heading?.color || tokens.headingColor || '#111111');
59
+ const bodyColor = escapeAttribute(textColor || headingColor);
60
+ const layout = mobile ? ' data-proto-holding-layout="mobile"' : '';
61
+ return `<div class="proto-holding-block" ${HOLDING_SURFACE_ATTRIBUTE}="true"${layout} style="left:${geometry.left}px;top:${geometry.top}px;width:${geometry.width}px">
62
+ <h1 class="proto-holding-title" style="font-family:'${headingFamily}',sans-serif;font-size:${headingSize}px;font-weight:${headingWeight};color:${headingColor}">${escapeText(title)}</h1>
63
+ <p class="proto-holding-sub" style="color:${bodyColor}">${escapeText(purpose)}</p>
64
+ <div class="proto-holding-cards" aria-hidden="true">${[1, 2, 3].map(() => '<div class="proto-holding-card"><div class="proto-holding-card-line"></div><div class="proto-holding-card-line short"></div><div class="proto-holding-card-line"></div></div>').join('')}</div>
65
+ <p class="proto-holding-note" style="color:${bodyColor}"><a class="proto-clickable" href="${escapeAttribute(backTarget)}" data-proto-action="navigate" data-proto-target="${escapeAttribute(backTarget)}" aria-label="Back to ${escapeAttribute(backTitle)}">Back</a></p>
66
+ </div>`;
67
+ }
@@ -0,0 +1,67 @@
1
+ import { normalizeText } from './text.mjs';
2
+
3
+ const explicitMarkerTarget = (label) => {
4
+ const normalized = String(label || '').replace(/\s+/g, ' ').trim();
5
+ if (!normalized) return null;
6
+ const beneath = normalized.match(/\bunderline\s+beneath\s+(?:footer\s+)?(.+)$/i);
7
+ if (beneath) return beneath[1].trim();
8
+ const navigation = normalized.match(/^(.+?)\s+navigation\s+underline$/i);
9
+ if (navigation) return navigation[1].trim();
10
+ return null;
11
+ };
12
+
13
+ function overlapsHorizontally(marker, nav) {
14
+ const overlap = Math.min(marker.x + marker.w, nav.x + nav.w) - Math.max(marker.x, nav.x);
15
+ return overlap / Math.max(1, Math.min(marker.w, nav.w)) >= 0.7;
16
+ }
17
+
18
+ function geometricDividerTarget(marker, navItems) {
19
+ if (!String(marker.cls || '').split(/\s+/).includes('divider')) return null;
20
+ const candidates = navItems.filter((nav) => overlapsHorizontally(marker, nav)
21
+ && marker.y >= nav.y + nav.h - 2
22
+ && marker.y - (nav.y + nav.h) <= 28
23
+ && marker.w >= nav.w * 0.5
24
+ && marker.w <= nav.w * 1.5);
25
+ return candidates.length === 1 ? candidates[0].text : null;
26
+ }
27
+
28
+ /**
29
+ * Finds only marker entities the converted page declared strongly enough to
30
+ * move: an explicit accessible underline label, or one unique divider directly
31
+ * below a declared nav entity. Ambiguity means silence.
32
+ */
33
+ export function declaredActiveMarkers(shell, navItems) {
34
+ if (!shell) return [];
35
+ const normalizedNav = new Map(navItems.map((item) => [normalizeText(item.text), item]));
36
+ return (shell.items || []).flatMap((item) => {
37
+ const labelled = explicitMarkerTarget(item.label);
38
+ const targetText = labelled || geometricDividerTarget(item, navItems);
39
+ const target = targetText ? normalizedNav.get(normalizeText(targetText)) : null;
40
+ if (!target) return [];
41
+ return [{
42
+ id: item.id,
43
+ targetText: target.text,
44
+ targetId: target.id,
45
+ evidence: labelled ? 'declared-label' : 'measured-divider',
46
+ box: [item.x, item.y, item.w, item.h],
47
+ parentIds: item.chain || [],
48
+ }];
49
+ });
50
+ }
51
+
52
+ export function linkedNavForUnit({ navRows, unit, sourceMarkers, region }) {
53
+ const linked = navRows.filter((item) => item.region === region
54
+ && item.target
55
+ && unit.states.some((state) => state.stateId === item.target));
56
+ if (linked.length === 0) return null;
57
+ const declared = sourceMarkers
58
+ .map((marker) => linked.find((item) => normalizeText(item.text) === normalizeText(marker.targetText)))
59
+ .find(Boolean);
60
+ return declared || linked[0];
61
+ }
62
+
63
+ export function markerOverlapsNav(marker, nav) {
64
+ return overlapsHorizontally(marker, nav)
65
+ && marker.y >= nav.y + nav.h - 2
66
+ && marker.y - (nav.y + nav.h) <= 32;
67
+ }
@@ -0,0 +1,72 @@
1
+ const escapeText = (value) => String(value ?? '')
2
+ .replace(/&/g, '&amp;')
3
+ .replace(/</g, '&lt;')
4
+ .replace(/>/g, '&gt;');
5
+
6
+ const escapeAttribute = (value) => escapeText(value).replace(/"/g, '&quot;');
7
+
8
+ const branchStatus = (review) => {
9
+ const terminal = String(review.terminalStatus || '').toLowerCase();
10
+ return terminal === 'failed' ? 'Failed' : 'Partial';
11
+ };
12
+
13
+ const routeCountCopy = (available, expected) => {
14
+ const noun = expected === 1 ? 'route' : 'routes';
15
+ return `${available} of ${expected} ${noun} ${available === 1 ? 'is' : 'are'} available in this review.`;
16
+ };
17
+
18
+ export function reviewFramingMarkup(review) {
19
+ const available = Number.isFinite(review.availablePageCount) ? review.availablePageCount : 0;
20
+ const expected = Number.isFinite(review.expectedPageCount) ? review.expectedPageCount : 0;
21
+ const unavailable = Array.isArray(review.missingRoutes) ? review.missingRoutes : [];
22
+ const unavailableLinks = unavailable.map((route) => (
23
+ `<a href="${escapeAttribute(route.file)}">${escapeText(route.title)}</a>`
24
+ )).join('');
25
+ const startingPageCopy = review.winnerPageFailed
26
+ ? review.entry?.kind === 'holding'
27
+ ? '<p class="proto-review-framing__explanation">The approved starting page could not be built. This review opens on its unavailable-page notice instead of substituting another route.</p>'
28
+ : `<p class="proto-review-framing__explanation">The approved starting page could not be built. This review opens on ${escapeText(review.entry?.title || 'another available route')} instead, while keeping the missing page explicit.</p>`
29
+ : '';
30
+ const unavailableCopy = unavailable.length > 0
31
+ ? `<div class="proto-review-framing__routes"><span>Unavailable routes:</span>${unavailableLinks}</div>`
32
+ : '<p class="proto-review-framing__routes proto-review-framing__routes--empty">No unavailable routes are listed.</p>';
33
+
34
+ return `<aside id="proto-review-framing" class="proto-review-framing" aria-label="Prototype review status">
35
+ <p class="proto-review-framing__eyebrow">Prototype review</p>
36
+ <p class="proto-review-framing__status">Branch status: ${branchStatus(review)}</p>
37
+ <p class="proto-review-framing__count">${routeCountCopy(available, expected)}</p>
38
+ ${startingPageCopy}
39
+ ${unavailableCopy}
40
+ </aside>`;
41
+ }
42
+
43
+ const REVIEW_FRAMING_CSS = `
44
+ #proto-review-framing.proto-review-framing{
45
+ position:fixed;right:18px;bottom:18px;z-index:2147483646;
46
+ box-sizing:border-box;width:min(430px,calc(100vw - 36px));max-height:calc(100vh - 36px);overflow:auto;
47
+ padding:16px 18px;border:1px solid rgba(255,255,255,.2);border-radius:14px;
48
+ background:rgba(20,22,26,.96);box-shadow:0 18px 54px rgba(0,0,0,.34);
49
+ color:#f7f7f5;font:500 14px/1.42 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
50
+ letter-spacing:0;text-align:left;writing-mode:horizontal-tb;direction:ltr;
51
+ }
52
+ #proto-review-framing.proto-review-framing p{margin:0;color:inherit;font:inherit;letter-spacing:inherit;text-transform:none}
53
+ #proto-review-framing .proto-review-framing__eyebrow{margin-bottom:5px;color:#c8c9cd;font-size:11px;font-weight:700;letter-spacing:.08em}
54
+ #proto-review-framing .proto-review-framing__status{font-size:18px;font-weight:750;line-height:1.2}
55
+ #proto-review-framing .proto-review-framing__count{margin-top:5px;color:#e1e2e5}
56
+ #proto-review-framing .proto-review-framing__explanation{margin-top:10px;padding-top:10px;border-top:1px solid rgba(255,255,255,.14);color:#fff}
57
+ #proto-review-framing .proto-review-framing__routes{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin-top:11px;color:#c8c9cd}
58
+ #proto-review-framing .proto-review-framing__routes span{margin-right:1px}
59
+ #proto-review-framing .proto-review-framing__routes a{display:inline-flex;padding:4px 8px;border:1px solid rgba(255,255,255,.24);border-radius:999px;color:#fff;text-decoration:none;font:650 12px/1.25 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
60
+ #proto-review-framing .proto-review-framing__routes a:hover,#proto-review-framing .proto-review-framing__routes a:focus-visible{background:#fff;color:#17191d;outline:none}
61
+ #proto-review-framing .proto-review-framing__routes--empty{display:block}
62
+ @media(max-width:600px){#proto-review-framing.proto-review-framing{right:10px;bottom:10px;width:calc(100vw - 20px);max-height:calc(100vh - 20px);padding:14px 15px}}
63
+ `;
64
+
65
+ export function injectReviewFraming(document, review) {
66
+ if (!review) return;
67
+ document.querySelector('head').insertAdjacentHTML(
68
+ 'beforeend',
69
+ `<style id="proto-review-framing-style">${REVIEW_FRAMING_CSS}</style>`,
70
+ );
71
+ document.body.insertAdjacentHTML('afterbegin', reviewFramingMarkup(review));
72
+ }