@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
@@ -4,12 +4,22 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { parseHTML } from 'linkedom';
6
6
  import { usableDropdownOptions } from './lib/actions.mjs';
7
- import { shouldSuppressDroppedShell, shouldTransplantShell } from './lib/shells.mjs';
7
+ import {
8
+ HOLDING_SHELL_ATTRIBUTE,
9
+ holdingBlockGeometry,
10
+ holdingBlockMarkup,
11
+ holdingContentArea,
12
+ shellPlacementCss,
13
+ } from './lib/holding-composition.mjs';
14
+ import { injectReviewFraming } from './lib/review-framing.mjs';
15
+ import { stitchUnitShells } from './lib/shell-stitching.mjs';
16
+ import { shouldSuppressDroppedShell } from './lib/shells.mjs';
8
17
  import { normalizeText as norm, slugText as slug } from './lib/text.mjs';
9
18
 
10
19
  const [runDir, invFile, modelFile, labelsDir, outDir] = process.argv.slice(2);
11
20
  const inv = JSON.parse(fs.readFileSync(invFile, 'utf8'));
12
21
  const model = JSON.parse(fs.readFileSync(modelFile, 'utf8'));
22
+ const review = model.review || null;
13
23
  const here = path.dirname(new URL(import.meta.url).pathname);
14
24
  fs.mkdirSync(path.join(outDir, 'content'), { recursive: true });
15
25
  fs.copyFileSync(path.join(here, 'runtime/proto.js'), path.join(outDir, 'proto.js'));
@@ -31,8 +41,21 @@ for (const s of states) {
31
41
  u.states.push(s);
32
42
  } else units.push({ id: s.stateId, pageId: s.pageId, file: s.file, states: [s], title: s.title, isSource: s.isSource });
33
43
  }
44
+ const reviewRealRoutes = review && Array.isArray(review.realRoutes) ? review.realRoutes : [];
45
+ const reviewMissingRoutes = review && Array.isArray(review.missingRoutes) ? review.missingRoutes : [];
46
+ const routeKey = (value) => norm(String(value || '').replace(/^p\d+-/, '').replace(/-/g, ' '));
47
+ const reviewRealRouteForUnit = (unit) => reviewRealRoutes.find((route) => {
48
+ const keys = new Set([
49
+ unit.id,
50
+ unit.pageId,
51
+ unit.title,
52
+ ...unit.states.flatMap((state) => [state.stateId, state.pageId, state.title]),
53
+ ].map(routeKey));
54
+ return keys.has(routeKey(route.id)) || keys.has(routeKey(route.title));
55
+ });
56
+ const outputFileOfUnit = (unit) => reviewRealRouteForUnit(unit)?.file || `${unit.id}.html`;
34
57
  const unitOf = (stateId) => units.find((u) => u.states.some((s) => s.stateId === stateId));
35
- const fileOf = (stateId) => unitOf(stateId) ? unitOf(stateId).id + '.html' : null;
58
+ const fileOf = (stateId) => unitOf(stateId) ? outputFileOfUnit(unitOf(stateId)) : null;
36
59
 
37
60
  // ---- labels (Luna) per state
38
61
  const labels = {};
@@ -46,7 +69,23 @@ const holdings = new Map(); // slug -> title
46
69
  for (const n of model.nav) if (n.holding) holdings.set(n.holding, n.text);
47
70
  for (const acts of Object.values(labels)) for (const a of acts) if (a.action === 'navigate' && /^holding:/.test(a.target || '')) { const t = a.target.slice(8).trim(); holdings.set('holding-' + slug(t), t); }
48
71
  const usedHoldings = new Map(); // slug -> title, only those actually referenced by a bound action or nav item
49
- const holdingFile = (title) => { usedHoldings.set('holding-' + slug(title), holdings.get('holding-' + slug(title)) || title); return 'holding-' + slug(title) + '.html'; };
72
+ const reviewMissingByHolding = new Map(reviewMissingRoutes.map((route) => [route.holding, route]));
73
+ const reviewMissingForTitle = (title) => reviewMissingRoutes.find((route) => (
74
+ norm(route.title) === norm(title) || routeKey(route.id) === routeKey(title)
75
+ ));
76
+ for (const route of reviewMissingRoutes) {
77
+ holdings.set(route.holding, route.title);
78
+ usedHoldings.set(route.holding, route.title);
79
+ }
80
+ const holdingFile = (title) => {
81
+ const reviewRoute = reviewMissingForTitle(title);
82
+ if (reviewRoute) {
83
+ usedHoldings.set(reviewRoute.holding, reviewRoute.title);
84
+ return reviewRoute.file;
85
+ }
86
+ usedHoldings.set('holding-' + slug(title), holdings.get('holding-' + slug(title)) || title);
87
+ return 'holding-' + slug(title) + '.html';
88
+ };
50
89
 
51
90
  // ---- page docs cache
52
91
  const docCache = new Map();
@@ -110,7 +149,21 @@ function findPill(region) {
110
149
  const pills = Object.fromEntries(Object.keys(shell).map((r) => [r, findPill(r)]));
111
150
 
112
151
  // ---- action resolution for one state
113
- const report = { units: [], unbound: [], invalidDropdowns: [], shellSizeDelta: [], shellAmbiguities: model.shellAmbiguities || [], suppressedShellOverlaps: [], skippedShellTransplants: [], replacedShellActions: 0, shellActionRemaps: 0, holdings: [...holdings.values()], counts: { navigate: 0, dropdown: 0, modal: 0, field: 0, none: 0, lunaOnly: 0, detOnly: 0, agree: 0, disagree: 0 } };
152
+ const report = {
153
+ units: [],
154
+ unbound: [],
155
+ invalidDropdowns: [],
156
+ shellSizeDelta: [],
157
+ shellAmbiguities: model.shellAmbiguities || [],
158
+ suppressedShellOverlaps: [],
159
+ skippedShellTransplants: [],
160
+ replacedShellActions: 0,
161
+ shellActionRemaps: 0,
162
+ canonicalShellPage: model.canonicalShellPage || null,
163
+ shellConsistency: { changes: 0, decisions: [] },
164
+ holdings: [...holdings.values()],
165
+ counts: { navigate: 0, dropdown: 0, modal: 0, field: 0, none: 0, lunaOnly: 0, detOnly: 0, agree: 0, disagree: 0 },
166
+ };
114
167
  function resolveTarget(target, currentStateId) {
115
168
  if (!target) return null;
116
169
  if (/^holding:/.test(target)) return holdingFile(target.slice(8).trim());
@@ -119,7 +172,10 @@ function resolveTarget(target, currentStateId) {
119
172
  if (unitOf(target) === unitOf(currentStateId)) return fileOf(target) + '#' + byState[target].sectionId;
120
173
  return fileOf(target);
121
174
  }
122
- if (holdings.has(target)) return 'holding-' + target.replace(/^holding-/, '') + '.html';
175
+ if (holdings.has(target)) {
176
+ const reviewRoute = reviewMissingByHolding.get(target) || reviewMissingForTitle(holdings.get(target));
177
+ return reviewRoute?.file || 'holding-' + target.replace(/^holding-/, '') + '.html';
178
+ }
123
179
  // an invented id like "reports" -> a holding page titled "Reports"
124
180
  if (/^[a-z][a-z0-9-]{2,40}$/.test(target)) return holdingFile(target.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' '));
125
181
  return null;
@@ -193,7 +249,34 @@ for (const u of units) {
193
249
  const allActions = {};
194
250
  const content = {};
195
251
  let fieldCount = 0;
196
- const activeNav = navRows.find((n) => n.target && unitOf(n.target)?.id === u.id);
252
+ const recipientPage = inv.pages.find((page) => page.pageId === u.pageId);
253
+ const donorPage = inv.pages.find((page) => page.pageId === model.canonicalShellPage?.pageId);
254
+ const donorState = states.find((state) => state.pageId === donorPage?.pageId);
255
+ if (!recipientPage) throw new Error(`Prototype shell consistency cannot resolve recipient page for ${u.id}.`);
256
+ const stitched = donorPage && donorState
257
+ ? stitchUnitShells({
258
+ document,
259
+ donorDocument: loadDoc(donorState.file).document,
260
+ donorPage,
261
+ recipientPage,
262
+ unit: u,
263
+ canonicalShell: model.canonicalShell,
264
+ navRows,
265
+ rulesFor,
266
+ styleText,
267
+ })
268
+ : { extraCss: [], bands: [], decisions: [], changes: 0 };
269
+ extraCss.push(...stitched.extraCss);
270
+ report.shellConsistency.changes += stitched.changes;
271
+ report.shellConsistency.decisions.push(...stitched.decisions.map((decision) => ({ unit: u.id, ...decision })));
272
+ for (const decision of stitched.decisions.filter((candidate) => candidate.reason.startsWith('incompatible-') || candidate.reason.startsWith('missing-'))) {
273
+ report.skippedShellTransplants.push({ unit: u.id, region: decision.region, reason: decision.reason });
274
+ }
275
+ const activeRegionFact = stitched.bands.flatMap((band) => band.regions)
276
+ .find((region) => region.active?.status === 'linked') || null;
277
+ const activeNav = activeRegionFact
278
+ ? navRows.find((row) => row.region === activeRegionFact.region && norm(row.text) === norm(activeRegionFact.active.navText))
279
+ : navRows.find((row) => row.target && unitOf(row.target)?.id === u.id);
197
280
 
198
281
  for (const s of u.states) {
199
282
  const section = document.getElementById(s.sectionId);
@@ -205,23 +288,6 @@ for (const u of units) {
205
288
  report.suppressedShellOverlaps.push({ state: s.stateId, region: ambiguity.region, dropped: ambiguity.dropped, kept: ambiguity.kept });
206
289
  }
207
290
  }
208
- // shell transplant
209
- for (const sh of s.shell) {
210
- const can = shell[sh.region]; if (!can) continue;
211
- const own = document.getElementById(sh.containerId);
212
- if (!own) continue;
213
- if (can.containerId === sh.containerId) continue; // this is the canonical page
214
- if (!shouldTransplantShell(sh.box, can.box)) {
215
- report.skippedShellTransplants.push({ state: s.stateId, region: sh.region, own: sh.box.slice(2), canonical: can.box.slice(2) });
216
- continue;
217
- }
218
- // keep this page's own layout box (it may be in normal flow); swap in the canonical children + background
219
- own.innerHTML = can.inner;
220
- own.setAttribute('data-proto-shell', can.containerId);
221
- extraCss.push(can.css);
222
- extraCss.push(`#${sh.containerId}{${can.background ? `background:${can.background}!important;` : ''}overflow:visible!important;z-index:5}`);
223
- if (Math.abs(sh.box[2] - can.box[2]) > 2 || Math.abs(sh.box[3] - can.box[3]) > 2) report.shellSizeDelta.push({ state: s.stateId, region: sh.region, own: sh.box.slice(2), canonical: can.box.slice(2) });
224
- }
225
291
  // actions
226
292
  const acts = stateActions.get(s.stateId);
227
293
  for (const [id, a] of acts) {
@@ -308,52 +374,174 @@ for (const u of units) {
308
374
  head.insertAdjacentHTML('beforeend', `<style id="proto-extra">${extraCss.join('\n')}</style><link rel="stylesheet" href="proto.css">`);
309
375
  const cfg = { unit: u.id, states: u.states.map((s) => s.stateId), tokens: model.tokens, contentFile: `${u.id}.json`, activeNav: activeNav?.text || null };
310
376
  document.body.insertAdjacentHTML('beforeend', `<script id="proto-state" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script src="proto.js"></script>`);
311
- fs.writeFileSync(path.join(outDir, u.id + '.html'), '<!doctype html>\n' + document.documentElement.outerHTML);
377
+ if (review) injectReviewFraming(document, review);
378
+ fs.writeFileSync(path.join(outDir, outputFileOfUnit(u)), '<!doctype html>\n' + document.documentElement.outerHTML);
312
379
  fs.writeFileSync(path.join(outDir, 'content', u.id + '.json'), JSON.stringify(content, null, 1));
313
- report.units.push({ id: u.id, states: u.states.map((s) => s.stateId), actions: Object.keys(allActions).length, fields: fieldCount, texts: Object.keys(content).length, activeNav: activeNav?.text || null });
380
+ report.units.push({
381
+ id: u.id,
382
+ states: u.states.map((s) => s.stateId),
383
+ actions: Object.keys(allActions).length,
384
+ fields: fieldCount,
385
+ texts: Object.keys(content).length,
386
+ activeNav: activeNav?.text || null,
387
+ shellBands: stitched.bands,
388
+ shellDecisions: stitched.decisions,
389
+ });
314
390
  console.error(`${u.id}.html actions=${Object.keys(allActions).length} fields=${fieldCount} texts=${Object.keys(content).length} active=${activeNav?.text || '-'}`);
315
391
  }
316
392
 
317
- // ---- holding pages: canonical shell(s) + page background + a styled block in the content area
393
+ // ---- holding pages: fresh per-band composition from measured shells + neutral surface + holding block
318
394
  {
319
395
  const primaryRegion = model.canonicalShell.sidebar ? 'sidebar' : model.canonicalShell.header ? 'header' : Object.keys(model.canonicalShell)[0];
320
396
  const can = primaryRegion ? shell[primaryRegion] : null;
321
397
  const base = can ? byState[can.stateId] : (states.find((s) => s.isSource) || states[0]);
322
398
  const baseSec = invSection(base);
323
- // content area: the section minus shell boxes
324
- let area = { x: 0, y: 0, w: 1536, h: baseSec.height };
325
- for (const sh of base.shell) {
326
- const [x, y, w, h] = sh.box;
327
- if (sh.region === 'sidebar') { area.x = Math.max(area.x, x + w); }
328
- if (sh.region === 'header') { area.y = Math.max(area.y, y + h); }
329
- if (sh.region === 'footer') { area.h = Math.min(area.h, y); }
399
+ const basePage = inv.pages.find((page) => page.pageId === base.pageId);
400
+ if (!basePage?.bands?.length) {
401
+ throw new Error(`Holding composition requires measured band inventory for ${base.pageId}.`);
330
402
  }
331
- // heading style from the base page's largest content text
332
- // anchor the block where the page's own title sits: the top-most large text in the content area
333
- const heading = baseSec.items.filter((t) => t.text && t.x >= area.x && t.fontSize >= 20 && t.y < area.y + 200).sort((a, b) => a.y - b.y || a.x - b.x)[0]
334
- || baseSec.items.filter((t) => t.text && t.x >= area.x).sort((a, b) => b.fontSize - a.fontSize)[0];
335
- const pad = Math.min(160, Math.max(32, Math.round((heading?.x || area.x + 40) - area.x)));
336
- const top = Math.min(200, Math.max(40, Math.round(heading?.y || 60)));
403
+ const sourceState = states.find((state) => state.isSource) || states[0];
337
404
  for (const n of navRows) if (n.holding) holdingFile(n.text);
338
405
  for (const [hslug, htitle] of usedHoldings) {
339
406
  const { document } = loadDoc(base.file);
340
- for (const sec of [...document.querySelectorAll('section[id^="viewport-"]')]) if (/^viewport-\d+-[A-Za-z0-9]+$/.test(sec.id) && sec.id !== base.sectionId) sec.remove();
341
407
  for (const sc of [...document.querySelectorAll('script')]) sc.remove();
342
- const section = document.getElementById(base.sectionId);
343
- const keepIds = new Set(base.shell.map((x) => x.containerId));
344
- const bgIds = new Set(baseSec.containers.filter((c) => c.x === 0 && c.y === 0 && c.w >= 1535 && c.children === 0).map((c) => c.id));
345
- for (const child of [...section.children]) if (!keepIds.has(child.id) && !bgIds.has(child.id)) child.remove();
346
408
  const navMatch = navRows.find((n) => n.holding === hslug);
347
- const extraCss = [`.ly-anchor-canvas,#page-flow{height:${baseSec.height}px!important}`];
348
- // composite the other canonical shell regions (e.g. a footer that lives in a different state)
349
- for (const [region, can] of Object.entries(shell)) {
350
- if (base.shell.some((x) => x.region === region)) continue;
351
- section.insertAdjacentHTML('beforeend', can.html);
352
- extraCss.push(can.css);
353
- const top = region === 'footer' ? baseSec.height - can.box[3] : can.box[1];
354
- extraCss.push(`#${can.containerId}{position:absolute!important;left:${can.box[0]}px!important;top:${top}px!important;margin:0!important;z-index:5}`);
355
- if (region === 'footer') area.h = Math.min(area.h, top);
409
+ const reviewRoute = reviewMissingByHolding.get(hslug);
410
+ const outputFile = reviewRoute?.file || hslug + '.html';
411
+ const reviewBackRoute = review?.entry?.file === outputFile
412
+ ? reviewRealRoutes[0]
413
+ : review?.entry;
414
+ const backTarget = reviewBackRoute?.file || fileOf(sourceState.stateId);
415
+ const backTitle = reviewBackRoute?.title || sourceState.title;
416
+ const extraCss = [];
417
+ const bandDeclarations = [];
418
+ const bandRoots = [...document.querySelectorAll('.ly-band[data-ly-band]')];
419
+ const declared = bandRoots.length > 0
420
+ ? bandRoots.map((root) => ({ key: root.getAttribute('data-ly-band'), root }))
421
+ : [{ key: 'native', root: document.body }];
422
+
423
+ for (const { key, root } of declared) {
424
+ const screen = root.querySelector('.generated-screen') || (root === document.body ? root : null);
425
+ if (!screen) throw new Error(`Holding composition requires a screen root in band ${key}.`);
426
+ const band = basePage.bands.find((candidate) => candidate.key === key);
427
+ if (!band) {
428
+ throw new Error(`Holding composition has no measured inventory for declared band ${key} on ${base.pageId}.`);
429
+ }
430
+ if (!Array.isArray(band.hostOrigins) || band.hostOrigins.length === 0) {
431
+ throw new Error(`Holding composition has no measured viewport host origins for declared band ${key} on ${base.pageId}.`);
432
+ }
433
+ const host = screen.querySelector('.ly-anchor-canvas') || screen;
434
+ if (!host) throw new Error(`Holding composition requires a declared composition host in band ${key}.`);
435
+
436
+ const regions = new Set((band.shells || []).map((candidate) => candidate.region));
437
+ const canonicalBands = new Map();
438
+ for (const [region, canonical] of Object.entries(shell)) {
439
+ const canonicalState = byState[canonical.stateId];
440
+ const canonicalPage = inv.pages.find((page) => page.pageId === canonicalState.pageId);
441
+ const canonicalBand = canonicalPage?.bands?.find((candidate) => candidate.key === key) || null;
442
+ canonicalBands.set(region, { canonicalState, canonicalBand });
443
+ if (canonicalBand?.shells?.some((candidate) => candidate.region === region)) regions.add(region);
444
+ }
445
+ const placedShells = [...regions].map((region) => {
446
+ const canonical = canonicalBands.get(region);
447
+ const canonicalCandidate = canonical?.canonicalBand?.shells?.find((candidate) => candidate.region === region);
448
+ const baseCandidate = band.shells.find((candidate) => candidate.region === region);
449
+ const candidate = canonicalCandidate || baseCandidate;
450
+ const donorState = canonicalCandidate ? canonical.canonicalState : base;
451
+ if (!candidate || !donorState) {
452
+ throw new Error(`Holding composition cannot resolve measured ${region} shell geometry in band ${key}.`);
453
+ }
454
+ const { document: donorDocument } = loadDoc(donorState.file);
455
+ const donorRoots = [...donorDocument.querySelectorAll('.ly-band[data-ly-band]')];
456
+ const donorRoot = donorRoots.length > 0
457
+ ? donorRoots.find((candidateRoot) => candidateRoot.getAttribute('data-ly-band') === key)
458
+ : donorDocument.body;
459
+ const element = donorDocument.getElementById(candidate.containerId);
460
+ if (!donorRoot || !element || !donorRoot.contains(element)) {
461
+ throw new Error(`Holding composition cannot find declared ${region} shell ${candidate.containerId} in band ${key}.`);
462
+ }
463
+ const ancestorIds = [];
464
+ for (let ancestor = element.parentElement; ancestor && ancestor !== donorRoot; ancestor = ancestor.parentElement) {
465
+ if (ancestor.id) ancestorIds.push(ancestor.id);
466
+ }
467
+ return {
468
+ ...candidate,
469
+ region,
470
+ html: element.outerHTML,
471
+ css: rulesFor(styleText(donorDocument), candidate.containerId),
472
+ ancestorIds,
473
+ };
474
+ });
475
+
476
+ if (host !== screen) {
477
+ for (const child of [...screen.children]) if (child !== host) child.remove();
478
+ }
479
+ for (const child of [...host.children]) child.remove();
480
+ const parentShell = (placed) => placedShells.find((candidate) => (
481
+ candidate !== placed && placed.ancestorIds.includes(candidate.containerId)
482
+ ));
483
+ for (const placed of placedShells.filter((candidate) => !parentShell(candidate))) {
484
+ host.insertAdjacentHTML('beforeend', placed.html);
485
+ }
486
+ for (const placed of placedShells) {
487
+ const element = document.getElementById(placed.containerId);
488
+ if (!element || !host.contains(element)) {
489
+ throw new Error(`Holding composition failed to retain ${placed.region} shell ${placed.containerId} in band ${key}.`);
490
+ }
491
+ element.setAttribute(HOLDING_SHELL_ATTRIBUTE, placed.region);
492
+ if (placed.css) extraCss.push(placed.css);
493
+ const parent = parentShell(placed);
494
+ const box = parent
495
+ ? [placed.box[0] - parent.box[0], placed.box[1] - parent.box[1], placed.box[2], placed.box[3]]
496
+ : placed.box;
497
+ extraCss.push(shellPlacementCss({ ...placed, box }));
498
+ }
499
+
500
+ const area = holdingContentArea({ width: band.width, height: band.height, shells: placedShells });
501
+ const heading = band.heading || baseSec.items
502
+ .filter((item) => item.text && item.x >= area.left && item.fontSize >= 20 && item.y < area.top + 200)
503
+ .sort((left, right) => left.y - right.y || left.x - right.x)[0]
504
+ || baseSec.items.filter((item) => item.text && item.x >= area.left)
505
+ .sort((left, right) => right.fontSize - left.fontSize)[0];
506
+ const geometry = holdingBlockGeometry({ area, heading, viewportWidth: band.viewportWidth });
507
+ const purpose = `${htitle} is planned, but it is not included in this prototype yet.`;
508
+ host.insertAdjacentHTML('beforeend', holdingBlockMarkup({
509
+ title: htitle,
510
+ purpose,
511
+ backTarget,
512
+ backTitle,
513
+ geometry,
514
+ heading,
515
+ tokens: model.tokens,
516
+ textColor: band.readableTextColor,
517
+ mobile: band.viewportWidth <= 600,
518
+ }));
519
+ const holdingSurfaces = root.querySelectorAll('[data-proto-holding-surface="true"]');
520
+ if (holdingSurfaces.length !== 1) {
521
+ throw new Error(`Holding composition requires exactly one surface in band ${key}; found ${holdingSurfaces.length}.`);
522
+ }
523
+ const hostId = host.id || `proto-holding-${key}-host`;
524
+ if (!host.id) host.id = hostId;
525
+ host.setAttribute('data-proto-holding-band', key);
526
+ if (band.groundRegistration === 'positioned-scene') {
527
+ const selector = bandRoots.length > 0
528
+ ? `.ly-band[data-ly-band="${key}"]>.generated-screen`
529
+ : document.querySelector('.generated-screen') ? '.generated-screen' : 'body';
530
+ extraCss.push(`${selector}{background:${band.neutralSurface}!important;background-image:none!important}`);
531
+ extraCss.push(`${selector}::before,${selector}::after{content:none!important;display:none!important;background:none!important;border:0!important}`);
532
+ screen.setAttribute('data-proto-ground-neutralized', 'positioned-scene');
533
+ }
534
+ bandDeclarations.push({
535
+ key,
536
+ viewportWidth: band.viewportWidth,
537
+ hostOrigins: band.hostOrigins,
538
+ shellIds: placedShells.map((placed) => placed.containerId),
539
+ shellBoxes: placedShells.map((placed) => ({ id: placed.containerId, box: placed.box })),
540
+ groundRegistration: band.groundRegistration,
541
+ neutralSurface: band.neutralSurface,
542
+ });
356
543
  }
544
+
357
545
  if (navMatch) {
358
546
  const p = pills[navMatch.region];
359
547
  if (p && p.own.id !== navMatch.id) {
@@ -363,14 +551,6 @@ for (const u of units) {
363
551
  extraCss.push(`#${p.own.id}{color:${curText.color}!important;font-weight:${curText.fontWeight}!important}`);
364
552
  }
365
553
  }
366
- const purpose = navMatch ? `${htitle} is part of the planned product but was not one of the screens in this branch run.` : `${htitle} opens here in the full product. It was not one of the screens in this branch run.`;
367
- const block = `<div class="proto-holding-block" style="left:${area.x + pad}px;top:${top}px;width:${Math.min(area.w - area.x - pad * 2, 1100)}px">
368
- <h1 class="proto-holding-title" style="font-family:'${heading?.fontFamily || model.tokens.headingFont}',sans-serif;font-size:${heading?.fontSize || model.tokens.headingSize || 28}px;font-weight:${heading?.fontWeight || 600};color:${heading?.color || model.tokens.headingColor}">${esc(htitle)}</h1>
369
- <p class="proto-holding-sub">${esc(purpose)}</p>
370
- <div class="proto-holding-cards">${[1, 2, 3].map((i) => `<div class="proto-holding-card"><p class="proto-holding-card-title">${esc(htitle)} ${i}</p><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>
371
- <p class="proto-holding-note">Prototype holding page · <span class="proto-clickable" data-proto-action="navigate" data-proto-target="${fileOf((states.find((s) => s.isSource) || states[0]).stateId)}">Back to ${esc((states.find((s) => s.isSource) || states[0]).title)}</span></p>
372
- </div>`;
373
- section.insertAdjacentHTML('beforeend', block);
374
554
  for (const n of navRows) {
375
555
  const el = document.getElementById(n.rowId) || document.getElementById(n.id);
376
556
  if (!el) continue;
@@ -379,15 +559,19 @@ for (const u of units) {
379
559
  }
380
560
  document.title = htitle;
381
561
  document.querySelector('head').insertAdjacentHTML('beforeend', `<style id="proto-extra">${extraCss.join('\n')}</style><link rel="stylesheet" href="proto.css">`);
382
- const cfg = { unit: hslug, holding: true, tokens: model.tokens, contentFile: null, activeNav: navMatch?.text || null };
562
+ const cfg = { unit: hslug, holding: true, holdingBands: bandDeclarations, tokens: model.tokens, contentFile: null, activeNav: navMatch?.text || null };
383
563
  document.body.insertAdjacentHTML('beforeend', `<script id="proto-state" type="application/json">${JSON.stringify(cfg)}</script><script src="proto.js"></script>`);
384
- fs.writeFileSync(path.join(outDir, hslug + '.html'), '<!doctype html>\n' + document.documentElement.outerHTML);
564
+ if (review) injectReviewFraming(document, review);
565
+ fs.writeFileSync(path.join(outDir, outputFile), '<!doctype html>\n' + document.documentElement.outerHTML);
385
566
  }
386
567
  console.error(`holding pages: ${usedHoldings.size}`);
387
568
  }
388
569
 
389
570
  // index -> source
390
- const home = fileOf((states.find((s) => s.isSource) || states[0]).stateId);
571
+ const home = review?.entry?.file || fileOf((states.find((s) => s.isSource) || states[0]).stateId);
391
572
  fs.writeFileSync(path.join(outDir, 'index.html'), `<!doctype html><meta http-equiv="refresh" content="0;url=${home}"><a href="${home}">${home}</a>`);
392
- fs.writeFileSync(path.join(outDir, 'proto-manifest.json'), JSON.stringify({ units: report.units, nav: navRows.map((n) => ({ text: n.text, target: n.target, holding: n.holding, rowId: n.rowId })), holdings: [...usedHoldings.entries()], counts: report.counts, unbound: report.unbound, invalidDropdowns: report.invalidDropdowns, shellSizeDelta: report.shellSizeDelta, shellAmbiguities: report.shellAmbiguities, suppressedShellOverlaps: report.suppressedShellOverlaps, skippedShellTransplants: report.skippedShellTransplants, replacedShellActions: report.replacedShellActions, shellActionRemaps: report.shellActionRemaps, scriptDirections: states.map(({ stateId, scriptDirection, writingMode }) => ({ stateId, scriptDirection, writingMode })), canonicalShell: model.canonicalShell }, null, 1));
573
+ const manifestHoldings = review
574
+ ? [...usedHoldings.entries()].map(([holding, title]) => [holding.replace(/\.html$/, ''), title])
575
+ : [...usedHoldings.entries()];
576
+ fs.writeFileSync(path.join(outDir, 'proto-manifest.json'), JSON.stringify({ units: report.units, nav: navRows.map((n) => ({ text: n.text, target: n.target, holding: n.holding, rowId: n.rowId })), holdings: manifestHoldings, counts: report.counts, unbound: report.unbound, invalidDropdowns: report.invalidDropdowns, shellSizeDelta: report.shellSizeDelta, shellAmbiguities: report.shellAmbiguities, suppressedShellOverlaps: report.suppressedShellOverlaps, skippedShellTransplants: report.skippedShellTransplants, replacedShellActions: report.replacedShellActions, shellActionRemaps: report.shellActionRemaps, scriptDirections: states.map(({ stateId, scriptDirection, writingMode }) => ({ stateId, scriptDirection, writingMode })), canonicalShell: model.canonicalShell, canonicalShellPage: report.canonicalShellPage, shellConsistency: report.shellConsistency, ...(review ? { review } : {}) }, null, 1));
393
577
  console.error('counts', JSON.stringify(report.counts), 'unbound', report.unbound.length);