@bongos/core 1.21.163 → 1.21.165

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 (41) hide show
  1. package/.bongos-core.json +60 -45
  2. package/.gitattributes +6 -0
  3. package/docs/copy-inventory.md +127 -127
  4. package/docs/copy-registry.json +205 -185
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-readings.json +297 -462
  7. package/modules/copy-desk/page-status.js +3 -0
  8. package/modules/copy-desk/pages.js +5 -0
  9. package/modules/hall-ui/public/approval-queue-lib.js +4 -0
  10. package/modules/hall-ui/public/approval-queue.css +2 -0
  11. package/modules/hall-ui/public/approval-queue.js +1 -0
  12. package/modules/hall-ui/public/builders.js +6 -4
  13. package/modules/hall-ui/public/craft-mode.js +32 -158
  14. package/modules/hall-ui/public/craft-review.css +7 -120
  15. package/modules/hall-ui/public/goal-inbox.js +100 -7
  16. package/modules/hall-ui/public/goals.js +2 -4
  17. package/modules/hall-ui/public/hall-render.js +39 -9
  18. package/modules/hall-ui/public/hall-widgets.js +20 -10
  19. package/modules/hall-ui/public/index.html +7 -0
  20. package/modules/hall-ui/public/page-notes.css +64 -1
  21. package/modules/hall-ui/public/page-notes.js +96 -11
  22. package/modules/hall-ui/public/style.css +95 -0
  23. package/modules/hall-ui/public/task-detail.js +8 -0
  24. package/modules/hall-ui/records/home-reshuffle.md +31 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +20 -0
  28. package/scripts/gds/copy-apply.js +65 -2
  29. package/scripts/gds/git-merge-regen.js +14 -0
  30. package/scripts/gds/page-reader.js +46 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/conflict_resolve.mjs +6 -0
  33. package/tests/craft_review_mode.mjs +21 -104
  34. package/tests/hall_approval_queue.mjs +16 -0
  35. package/tests/hall_board_world.mjs +7 -6
  36. package/tests/home_attention_cards.mjs +119 -0
  37. package/tests/home_bounded_reads.mjs +1 -1
  38. package/tests/page_notes_panel.mjs +161 -27
  39. package/tests/promote_goal_id.mjs +8 -0
  40. package/tests/tweak_batch_apply.mjs +40 -0
  41. package/tests/tweak_land_heal.mjs +139 -0
@@ -4565,3 +4565,98 @@ html.palette-open { overflow: hidden; }
4565
4565
  .gr-cat { display: inline-flex; align-items: baseline; gap: 5px; margin-left: 8px; padding: 1px 8px; border: 1px solid var(--rule-soft); border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: .01em; color: var(--ink-soft); background: var(--surface-2); vertical-align: baseline; white-space: nowrap; }
4566
4566
  .gr-cat--inherited { font-weight: 550; color: var(--ink-faint); }
4567
4567
  .gr-cat__from { font-style: normal; font-weight: 500; font-size: 10px; color: var(--ink-faint); opacity: .85; }
4568
+
4569
+ /* ---- Home reshuffle (task 1004774; mock docs/design/mocks/hall/home/v1.html) ----
4570
+ A slimmer banner pulled up under the greeting, the version as a progress tab
4571
+ under it, Standing as a chip at the top right with a hover card, the
4572
+ Needs-attention slices as cards that open a window, and Your work beside
4573
+ Recently shipped. */
4574
+ body[data-page="home"] .page-head--hall { position: relative; margin: 0 0 12px; padding-right: 180px; }
4575
+ body[data-page="home"] .stat-tile { padding: 12px 22px; min-height: 0; gap: 4px; }
4576
+ body[data-page="home"] .stat-tile__num { font-size: clamp(20px, 2vw, 26px); }
4577
+ body[data-page="home"] #home-stats .stat-row { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
4578
+
4579
+ .version-tab {
4580
+ display: flex; align-items: center; gap: 14px; min-height: 24px;
4581
+ padding: 10px 22px; color: var(--ink); text-decoration: none;
4582
+ background: var(--glass); border: 1px solid var(--glass-edge); border-top: 0;
4583
+ border-radius: 0 0 var(--radius) var(--radius);
4584
+ }
4585
+ .version-tab:hover { text-decoration: none; background: var(--glass-strong); }
4586
+ .version-tab[hidden] { display: none; }
4587
+ .version-tab__name { font-weight: 600; white-space: nowrap; }
4588
+ .version-tab__bar { flex: 1; height: 8px; border-radius: 4px; background: var(--rule-soft); overflow: hidden; }
4589
+ .version-tab__fill { display: block; height: 100%; width: 0; background: var(--accent); border-radius: 4px; }
4590
+ .version-tab__pct { color: var(--ink-soft); font-size: 13px; white-space: nowrap; font-variant-numeric: tabular-nums; }
4591
+
4592
+ .hall-standing { position: absolute; top: 0; right: 0; height: 44px; outline: none; }
4593
+ .hall-standing[hidden] { display: none; }
4594
+ /* The chip IS the top of the card: anchored at the right, it grows LEFT as the
4595
+ name slides in, and only .hall-standing__more drops down below it. */
4596
+ .hall-standing__card {
4597
+ position: absolute; top: 0; right: 0; z-index: 20; min-width: 0;
4598
+ background: var(--bg-card); border-radius: 22px; padding: 4px 6px 4px 4px;
4599
+ transition: min-width .2s ease, border-radius .2s ease, padding .2s ease, box-shadow .2s ease;
4600
+ }
4601
+ .hall-standing__card .home-who { gap: 8px; padding: 0; }
4602
+ .hall-standing__card .home-who__avatar { width: 36px; height: 36px; }
4603
+ .hall-standing__card .home-who__meta {
4604
+ max-width: 0; opacity: 0; overflow: hidden; white-space: nowrap;
4605
+ transition: max-width .2s ease, opacity .2s ease;
4606
+ }
4607
+ .hall-standing__more {
4608
+ max-height: 0; overflow: hidden; opacity: 0;
4609
+ transition: max-height .25s ease, opacity .2s ease;
4610
+ }
4611
+ .hall-standing__more .home-facts { margin-top: 12px; }
4612
+ .hall-standing__more .card-foot { margin: 12px 0 0; }
4613
+ .hall-standing:hover .hall-standing__card,
4614
+ .hall-standing:focus-within .hall-standing__card {
4615
+ min-width: 300px; border-radius: var(--radius); padding: 14px 16px 16px;
4616
+ box-shadow: 0 12px 40px rgba(0, 0, 0, .35);
4617
+ }
4618
+ .hall-standing:hover .home-who__meta,
4619
+ .hall-standing:focus-within .home-who__meta { max-width: 200px; opacity: 1; }
4620
+ .hall-standing:hover .hall-standing__more,
4621
+ .hall-standing:focus-within .hall-standing__more { max-height: 420px; opacity: 1; }
4622
+ .hall-standing:focus-visible .hall-standing__card { outline: 2px solid var(--focus); outline-offset: 2px; }
4623
+
4624
+ .attn-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
4625
+ .attn-card {
4626
+ display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-height: 100px;
4627
+ padding: 16px 18px; text-align: left; font: inherit; color: var(--ink); cursor: pointer;
4628
+ background: var(--glass); border: 1px solid var(--glass-edge); border-radius: var(--radius);
4629
+ }
4630
+ .attn-card:hover { border-color: var(--accent); }
4631
+ .attn-card__n { font-family: var(--font-display); font-size: 30px; font-weight: 300; line-height: 1; font-variant-numeric: tabular-nums; }
4632
+ .attn-card__t { font-weight: 600; }
4633
+ .attn-card__s { margin-top: auto; font-size: 12px; color: var(--ink-soft); }
4634
+ .attn-dialog {
4635
+ width: min(640px, calc(100vw - 32px)); max-height: 80vh; padding: 22px 24px; overflow: auto;
4636
+ background: var(--bg-card); color: var(--ink); border: 0; border-radius: var(--radius);
4637
+ }
4638
+ .attn-dialog::backdrop { background: var(--scrim); }
4639
+ .attn-dialog__head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
4640
+ .attn-dialog__h { flex: 1; margin: 0; font-family: var(--font-display); font-weight: 500; font-size: 20px; }
4641
+ .attn-dialog__x { min-width: 32px; min-height: 32px; background: none; border: 0; color: var(--ink-soft); font-size: 22px; cursor: pointer; }
4642
+ .attn-dialog__body .ginbox__sec-h { display: none; }
4643
+
4644
+ .home-split { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: start; }
4645
+ .home-split__col { min-width: 0; }
4646
+ .home-split > .home-split__col[hidden] + .home-split__col { grid-column: 1 / -1; }
4647
+
4648
+ @media (max-width: 760px) {
4649
+ body[data-page="home"] .page-head--hall { padding-right: 0; }
4650
+ .hall-standing { position: relative; display: block; margin-top: 10px; }
4651
+ .hall-standing__card { left: 0; right: auto; }
4652
+ .hall-standing:hover .hall-standing__card,
4653
+ .hall-standing:focus-within .hall-standing__card { min-width: min(300px, calc(100vw - 32px)); }
4654
+ .home-split { grid-template-columns: 1fr; }
4655
+ }
4656
+ /* With Standing gone from the band, a lone card there (Goals) takes the row. */
4657
+ .view-zone--band > .scroll:only-child { grid-column: 1 / -1; }
4658
+ /* The Standing hover card's stat row: four figures in a 2x2 grid, label over number. */
4659
+ .home-facts--grid { grid-template-columns: 1fr 1fr; gap: 10px 14px; padding: 12px 0; border-top: 1px solid var(--rule-soft); border-bottom: 1px solid var(--rule-soft); }
4660
+ .home-facts--grid > div { display: flex; flex-direction: column; gap: 2px; }
4661
+ .home-facts--grid dt, .home-facts--grid dd { border-top: 0; padding: 0; text-align: left; }
4662
+ .home-facts--grid dd { font-family: var(--font-display); font-size: 20px; font-weight: 300; }
@@ -450,6 +450,12 @@
450
450
  var priOptions = [1, 2, 3, 4, 5].map(function (p) {
451
451
  return '<option value="' + p + '"' + (p === pri ? ' selected' : '') + '>P' + p + '</option>';
452
452
  }).join('');
453
+ // A create with no discipline the server cannot infer from the wording is
454
+ // refused (task 1002988), so the builder names the craft here. Engineer is
455
+ // the default, the same fallback bug routing uses (ideas/inbox.js).
456
+ var craftOptions = [['engineer', 'Engineer'], ['artist', 'Artist'], ['ideator', 'Ideator'], ['ui', 'UI']].map(function (c) {
457
+ return '<option value="' + c[0] + '"' + (c[0] === 'engineer' ? ' selected' : '') + '>' + c[1] + '</option>';
458
+ }).join('');
453
459
  box.innerHTML =
454
460
  '<form class="td-form" data-form="promote">'
455
461
  + '<label class="td-field td-field--wide"><span>Title</span>'
@@ -457,6 +463,7 @@
457
463
  + '<label class="td-field td-field--wide"><span>Version</span><select name="version">' + options + '</select></label>'
458
464
  + '<label class="td-field td-field--wide"><span>Goal</span><select name="goal" data-goal-select>' + goalOptions + '</select></label>'
459
465
  + '<label class="td-field"><span>Priority</span><select name="priority">' + priOptions + '</select></label>'
466
+ + '<label class="td-field"><span>Craft</span><select name="discipline">' + craftOptions + '</select></label>'
460
467
  + '<label class="td-field"><span>Estimate (min)</span>'
461
468
  + '<input type="number" name="est" min="0" max="100000" step="5" placeholder="—"></label>'
462
469
  + '<label class="td-field"><span>Reward</span>'
@@ -504,6 +511,7 @@
504
511
  title: title,
505
512
  description: body + provenance,
506
513
  priority: Number(form.priority.value) || 3,
514
+ discipline: (form.discipline && form.discipline.value) || 'engineer',
507
515
  source: 'idea_inbox',
508
516
  source_ref: 'idea:' + idea.id,
509
517
  };
@@ -0,0 +1,31 @@
1
+ # The Home reshuffle — slim banner, version tab, Standing chip, attention cards with windows (task 1004774)
2
+
3
+ The owner's Builders' Hall feedback (idea 1001262), home-page half, built to the approved mock at
4
+ [`docs/design/mocks/hall/home/v1.html`](../../../docs/design/mocks/hall/home/v1.html).
5
+
6
+ - **Banner + version tab.** The stat row is slimmer (`body[data-page="home"]` overrides), and under it
7
+ `#home-version` draws the version the greeting's sub-line names, from the same `currentVersion()`
8
+ read — `renderVersionTab` in `hall-render.js`. Hidden while nothing is being built.
9
+ - **Standing is a chip, not a card.** The `home-standing` widget is gone; `#hall-standing` sits in the
10
+ page head (it is chrome, not a `<section class="scroll">`, so the registry rule is untouched). The
11
+ card it opens on hover or keyboard focus is still `#home-standing-body`, filled by `renderProfile`.
12
+ The place row reads "Leaderboard place" — the owner asked for no "on the board" wording. The chip
13
+ IS the top of the card (owner, 2026-10-23): at rest it shows the avatar and rank with no text and
14
+ no link, and on hover or focus it widens LEFT as the name slides in, while only
15
+ `.hall-standing__more` (the facts and a "View full profile" link to /profile) drops down below it.
16
+ - **Needs attention is cards.** Every slice still renders into its own div exactly as before, kept in
17
+ the hidden `#attention-slices`. `OTBAttention.refresh()` rebuilds one card per non-empty slice from
18
+ the slice's heading (title, count pill = the server total, the `.ginbox__sub` note). Opening a card
19
+ MOVES the slice div into `#attention-dialog`, which sits inside the widget host — so the delegated
20
+ click handler still sees every button and no slice needed a second action path. Closing moves it back.
21
+ `tests/home_attention_cards.mjs` runs the real file in a browser: card totals, open, X, Escape, and a
22
+ window whose slice empties while open.
23
+ - **Goals card** lost its subtitle, and its foot is one "Browse goals" link; the "not joined" line shows only when you have joined none.
24
+ - **Next up is five rows**, level with the five Recently shipped rows, asked for AND capped client-side.
25
+ - **Renamed:** "Stuck on the owner" → "Requires project owner fix"; "Going quiet" → "Things not moving".
26
+ - **Your work beside Recently shipped** — `.home-split` in the `home-recent` widget; shipped takes the
27
+ full row when Next up is hidden, and both stack under 760px.
28
+
29
+ Not built, because the data does not exist yet: the "Tasks you are blocking in other goals"
30
+ (requested / total) card, and the any-owner / main-owner split on "Requires project owner fix" —
31
+ blockers carry no addressee today.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.163",
3
+ "version": "1.21.165",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.163",
9
+ "version": "1.21.165",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.163",
3
+ "version": "1.21.165",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -10020,5 +10020,25 @@
10020
10020
  "id": "1004773",
10021
10021
  "text": "The Private option on the account page now opens with the artist line \"you are a ninja.\", and still tells people Private hides their page from everyone, them included."
10022
10022
  }
10023
+ ],
10024
+ "1.21.164": [
10025
+ {
10026
+ "id": "1004772",
10027
+ "text": "Promoting an idea from the home page's Triage button no longer fails with \"discipline was not supplied\". The Promote form now has a Craft choice (Engineer by default), so every promoted idea becomes a task."
10028
+ },
10029
+ {
10030
+ "id": "1004775",
10031
+ "text": "Approved page tweaks now land on their own. When two rounds were applied from the same main, the second used to hit a conflict in the page readings file and wait for a person; that file now merges page by page, so the second r"
10032
+ },
10033
+ {
10034
+ "id": "1004774",
10035
+ "text": "Home got the owner's reshuffle: a slimmer banner with the version's progress bar under it; your picture and rank at the top right, opening on hover into your standing card with a View full profile link; every Needs-attention s"
10036
+ }
10037
+ ],
10038
+ "1.21.165": [
10039
+ {
10040
+ "id": "1004771",
10041
+ "text": "Artist review is now a small tab on the right edge of every hall page in artist mode. Hover the tab and the review panel slides out; move the cursor back onto the page and it tucks away, keeping your draft. The page stays scro"
10042
+ }
10023
10043
  ]
10024
10044
  }
@@ -458,17 +458,73 @@ function applyBatchToSources({ batch, registry, readSource }) {
458
458
  return { applied: applied.sort(byOrder).map(({ order, ...rest }) => rest), refused, sources };
459
459
  }
460
460
 
461
+ // findPinningTests({ applied, listTests, readFile }) -> [{ key, tests: [rel…] }]
462
+ //
463
+ // Task 1004775. A test that asserts a line's OLD words verbatim goes red the
464
+ // moment the round lands, which used to surface only in CI, after the artist had
465
+ // approved, and needed a person. So at APPLY time every applied line's old text is
466
+ // searched for in the test files (tests/ and every module's own tests folder), and each test that
467
+ // still carries it is named in the applied block and shown in the Approval queue.
468
+ // Nothing is edited: a test may be guarding what the words MEAN, and only a person
469
+ // can tell that apart from a test that pins the wording alone.
470
+ //
471
+ // The needle is the line's old text; where it has {…} holes, its longest literal
472
+ // run between them, and a needle under MIN_PIN_NEEDLE characters is skipped (a
473
+ // two-word label matches half the suite and would name nothing useful).
474
+ const MIN_PIN_NEEDLE = 12;
475
+ const PIN_TEST_RE = /\.(mjs|cjs|js)$/;
476
+
477
+ function pinNeedle(text) {
478
+ const runs = String(text == null ? '' : text).split('{…}').map((s) => s.trim());
479
+ const longest = runs.reduce((a, b) => (b.length > a.length ? b : a), '');
480
+ return longest.length >= MIN_PIN_NEEDLE ? longest : null;
481
+ }
482
+
483
+ function listTestFiles({ root = REPO_ROOT, fsImpl = fs } = {}) {
484
+ const out = [];
485
+ const walk = (rel) => {
486
+ let ents;
487
+ try { ents = fsImpl.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { return; }
488
+ for (const e of ents) {
489
+ const r = `${rel}/${e.name}`;
490
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'fixtures') walk(r); } else if (PIN_TEST_RE.test(e.name)) out.push(r);
491
+ }
492
+ };
493
+ walk('tests');
494
+ let mods = [];
495
+ try { mods = fsImpl.readdirSync(path.join(root, 'modules'), { withFileTypes: true }); } catch { mods = []; }
496
+ for (const m of mods) if (m.isDirectory()) walk(`modules/${m.name}/tests`);
497
+ return out.sort();
498
+ }
499
+
500
+ function findPinningTests({ applied, listTests = listTestFiles, readFile = (rel) => fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8') }) {
501
+ const wants = (applied || []).map((a) => ({ key: a.key, needle: pinNeedle(a.before) })).filter((w) => w.needle);
502
+ if (!wants.length) return [];
503
+ const hits = new Map();
504
+ for (const rel of listTests()) {
505
+ let src;
506
+ try { src = readFile(rel); } catch { continue; }
507
+ for (const w of wants) {
508
+ if (!src.includes(w.needle)) continue;
509
+ if (!hits.has(w.key)) hits.set(w.key, []);
510
+ hits.get(w.key).push(rel);
511
+ }
512
+ }
513
+ return wants.filter((w) => hits.has(w.key)).map((w) => ({ key: w.key, tests: hits.get(w.key) }));
514
+ }
515
+
461
516
  // The ```page-tweak-applied``` block's content, from the page's NEW reading.
462
517
  // lines_after is the non-shared lines' texts: shared shell lines are left out of
463
518
  // drift on both sides (D8, builder pick 7), so they must be left out here too or
464
519
  // every one of them would read as "removed" on the next drift check.
465
- function appliedBlockFor({ pageId, readingPage, refused, appliedAt }) {
520
+ function appliedBlockFor({ pageId, readingPage, refused, appliedAt, pinnedTests = [] }) {
466
521
  return {
467
522
  page_id: pageId,
468
523
  applied_at: appliedAt,
469
524
  reading_hash_after: readingPage ? readingPage.reading_hash : null,
470
525
  lines_after: readingPage ? (readingPage.lines || []).filter((l) => l && l.placement !== 'shared').map((l) => String(l.text)) : [],
471
526
  refused: (refused || []).map((x) => ({ key: x.key, code: x.code })),
527
+ ...(pinnedTests && pinnedTests.length ? { pinned_tests: pinnedTests.map((p) => ({ key: p.key, tests: p.tests.slice() })) } : {}),
472
528
  };
473
529
  }
474
530
 
@@ -564,7 +620,10 @@ async function batchMain({ taskId, batchFile, dryRun, pageId = null, now = () =>
564
620
  return 4;
565
621
  }
566
622
  const readingPage = (deps.readingFor || ((id) => readingFor(id, { readFile })))(batch.page_id);
567
- const block = appliedBlockFor({ pageId: batch.page_id, readingPage, refused: result.refused, appliedAt: now() });
623
+ const oldText = new Map(batch.lines.map((l) => [l.key, l.before]));
624
+ const pinnedTests = (deps.findPinningTests || findPinningTests)({ applied: result.applied.map((a) => ({ key: a.key, before: oldText.get(a.key) })) });
625
+ for (const p of pinnedTests) log(` ${p.key} the old words are still asserted by: ${p.tests.join(', ')} (update the test or it fails once this lands)`);
626
+ const block = appliedBlockFor({ pageId: batch.page_id, readingPage, refused: result.refused, appliedAt: now(), pinnedTests });
568
627
  if (batchFile) {
569
628
  log('copy-apply --batch: the page-tweak-applied block (a --batch-file run writes no task):');
570
629
  log(pages.composeAppliedBlock(block));
@@ -684,6 +743,10 @@ module.exports = {
684
743
  applyBatchToSources,
685
744
  appliedBlockFor,
686
745
  batchMain,
746
+ // task 1004775: tests that still assert a rewritten line's old words.
747
+ findPinningTests,
748
+ listTestFiles,
749
+ pinNeedle,
687
750
  surfaceRootFor,
688
751
  checkPatchPath,
689
752
  locateOccurrence,
@@ -115,6 +115,8 @@ const REGENERABLE = new Set([
115
115
  'docs/session-log-index.md',
116
116
  'docs/copy-inventory.md',
117
117
  'docs/copy-registry.json',
118
+ // task 1004775: merged per page (page-reader.js mergeReadingDocs), see the rule below.
119
+ 'docs/page-readings.json',
118
120
  ...G.NESTED_DIRS.map((d) => `${d}/CLAUDE.md`),
119
121
  ...API_DOC_FILES,
120
122
  ...API_CLIENT_FILES,
@@ -170,6 +172,18 @@ function regenerateResolved(relpath, { base = '', ours = '', theirs = '' } = {})
170
172
  if (relpath === 'docs/copy-registry.json') {
171
173
  return { content: `${JSON.stringify(CI.buildRegistry(), null, 2)}\n`, ok: true };
172
174
  }
175
+ // docs/page-readings.json (task 1004775): one entry per page, and a re-read needs a
176
+ // browser no merge has. Two approved page-tweak rounds re-read DIFFERENT pages and
177
+ // both rewrite the top counts line, so every pair conflicted and the second round
178
+ // stranded for a person. Merge it per page from the three sides and recompute the
179
+ // counts (page-reader.js mergeReadingDocs). Unlike the scans above it reads the
180
+ // merge sides, not the tree, so it is not provisional; a page BOTH sides re-read
181
+ // differently is a real conflict and stays one.
182
+ if (relpath === 'docs/page-readings.json') {
183
+ const PR = require('./page-reader');
184
+ const m = PR.mergeReadingDocs({ base, ours, theirs });
185
+ return m.ok ? { content: m.content, ok: true } : { content: null, ok: false };
186
+ }
173
187
  if (relpath === 'docs/copy-inventory.md') {
174
188
  return { content: CI.renderReport(CI.buildRegistry()), ok: true };
175
189
  }
@@ -600,6 +600,50 @@ function mergeReadings(inventory, previous, fresh) {
600
600
  return inventory.pages.map((p) => byId.get(p.id)).filter(Boolean);
601
601
  }
602
602
 
603
+ // mergeReadingDocs({ base, ours, theirs }) -> { ok, content, conflicted }
604
+ //
605
+ // The merge rule for docs/page-readings.json (task 1004775). Two approved page
606
+ // tweak rounds branched from the same main each re-read THEIR page, and both
607
+ // rewrite the top `counts` line — so git conflicts on every pair, and the second
608
+ // round stranded as unresolvable_conflict until a person merged it by hand. A
609
+ // re-read needs a browser, which a merge never has, but it does not need one: the
610
+ // file is one entry per page, so the merge is per page. A page only one side
611
+ // changed takes that side's reading; a page both changed the same way takes it; a
612
+ // page both changed DIFFERENTLY is a real conflict (two rounds re-read one page —
613
+ // their source files conflict too) and is named, never guessed. The summary counts
614
+ // are recomputed from the merged pages and the whole file re-serialized, so the
615
+ // result is byte-for-byte what the reader itself would write for those pages.
616
+ // Pages keep ours' order; a page only theirs has goes where theirs had it.
617
+ function mergeReadingDocs({ base, ours, theirs }) {
618
+ const parse = (s) => { try { return s && String(s).trim() ? JSON.parse(s) : { pages: [] }; } catch { return null; } };
619
+ const b = parse(base), o = parse(ours), t = parse(theirs);
620
+ if (!b || !o || !t) return { ok: false, content: null, conflicted: ['(unreadable json)'] };
621
+ const map = (d) => new Map((d.pages || []).map((p) => [p.id, p]));
622
+ const B = map(b), O = map(o), T = map(t);
623
+ const same = (x, y) => JSON.stringify(x === undefined ? null : x) === JSON.stringify(y === undefined ? null : y);
624
+ const order = [...O.keys()];
625
+ for (const id of T.keys()) {
626
+ if (order.includes(id)) continue;
627
+ const at = (t.pages || []).findIndex((p) => p.id === id);
628
+ const prev = at > 0 ? t.pages[at - 1].id : null;
629
+ const i = prev ? order.indexOf(prev) : -1;
630
+ order.splice(i + 1, 0, id);
631
+ }
632
+ const pages = [];
633
+ const conflicted = [];
634
+ for (const id of order) {
635
+ const bv = B.get(id), ov = O.get(id), tv = T.get(id);
636
+ let pick;
637
+ if (same(ov, tv)) pick = ov;
638
+ else if (same(ov, bv)) pick = tv;
639
+ else if (same(tv, bv)) pick = ov;
640
+ else { conflicted.push(id); continue; }
641
+ if (pick) pages.push(pick);
642
+ }
643
+ if (conflicted.length) return { ok: false, content: null, conflicted };
644
+ return { ok: true, content: serialize(buildDoc(pages)), conflicted: [] };
645
+ }
646
+
603
647
  function loadPrevious() {
604
648
  try { return readJsonFile(READINGS_REL); } catch { return null; }
605
649
  }
@@ -671,6 +715,8 @@ module.exports = {
671
715
  filesHash, readingHash, buildMatcher, placeText, collectStrings, holeData,
672
716
  mergeStates, assembleReading, serialize, buildDoc, checkReadings, planRenders,
673
717
  captureInPage, mergeReadings,
718
+ // task 1004775: the merge driver's per-page rule for docs/page-readings.json.
719
+ mergeReadingDocs,
674
720
  // task 1004322: /tweak renders the page the reader reads, through the same harness.
675
721
  startHarness, SURFACE_MODULE, PINNED_CLOCK,
676
722
  };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.163'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.165'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -591,7 +591,13 @@ test('1003546 every REGENERABLE artifact has a producer, and every producer is i
591
591
  .map((p) => [p, 'scripts/gds/gen-api-client.js']),
592
592
  ]);
593
593
 
594
+ // task 1004775 — docs/page-readings.json is merged per page FROM THE THREE MERGE
595
+ // SIDES (page-reader.js mergeReadingDocs), never by scanning the tree, so the
596
+ // driver's result is already final; its only producer opens a browser, which the
597
+ // server never has. It is the one REGENERABLE entry with no post-merge generator.
598
+ const MERGED_FROM_SIDES = new Set(['docs/page-readings.json']);
594
599
  for (const p of regen.REGENERABLE) {
600
+ if (MERGED_FROM_SIDES.has(p)) continue;
595
601
  const producer = PRODUCED_BY.get(p);
596
602
  assert.ok(producer, `REGENERABLE knows ${p} but this test has no producer for it — name the generator that writes it`);
597
603
  assert.ok(
@@ -1,9 +1,10 @@
1
1
  // tests/craft_review_mode.mjs — artist review mode (task 1004686, goal 1000095).
2
2
  //
3
- // Owner ask, 2026-10-09: hovering the Review button in Artistry opens a menu of
4
- // the four experiences (Ideator, Governing, Building, Artistry); picking one
5
- // puts the artist into review mode on that mode's view, and graffiti is painted
6
- // across the top bar's empty space while it lasts. These cases run the REAL
3
+ // Owner ask, 2026-10-09: the artist picks one of the four experiences (Ideator,
4
+ // Governing, Building, Artistry) and reviews the hall in that mode's view, with
5
+ // graffiti across the top bar's empty space while it lasts. Since task 1004771
6
+ // the choice lives in the docked review panel's own Review mode row
7
+ // (page-notes.js; tests/page_notes_panel.mjs runs that row), not a top-bar menu. These cases run the REAL
7
8
  // craft-mode.js (and, when it is loaded, the real craft-review.js) over a small
8
9
  // DOM, and read the real craft-review.css for the motion rules.
9
10
  import assert from 'node:assert/strict';
@@ -83,7 +84,7 @@ function boot({ session = {}, store = {}, narrow = false } = {}) {
83
84
  head.onAppend = (tag) => {
84
85
  loaded.push(tag.src);
85
86
  if (/craft-review\.js/.test(tag.src)) vm.runInContext(REVIEW, sandbox, { filename: 'craft-review.js' });
86
- else window.OTBPageNotes = { open: () => opened.push('open'), openNote: () => {} };
87
+ else window.OTBPageNotes = { open: () => opened.push('open'), openNote: () => {}, dock: () => opened.push('dock'), close: () => opened.push('close') };
87
88
  tag.onload();
88
89
  };
89
90
  vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
@@ -93,107 +94,43 @@ function boot({ session = {}, store = {}, narrow = false } = {}) {
93
94
  return {
94
95
  craft: window.OTBCraftMode, root, topbar, side, sw, title, loaded, opened, session, byId, docListeners,
95
96
  tools: () => all(side).filter((n) => n.hasAttribute('data-craft-tool')).map((n) => n.id.replace('craft-tool-', '')),
96
- menu: () => byId('craft-review-menu'),
97
- menuOpen: () => !!byId('craft-review-menu') && !byId('craft-review-menu').hasAttribute('hidden'),
98
- // A click on a menu item: the menu's own listener reads the button off the target.
99
- pick: (attrs) => byId('craft-review-menu').fire('click', { target: { closest: () => ({ getAttribute: (k) => (k in attrs ? attrs[k] : null) }) } }),
100
97
  };
101
98
  }
102
99
  const artist = () => ({ 'hall-craft': 'artistry', 'hall-craft-asked': '1' });
103
100
 
104
- test('THE SPLIT PILL: in Artistry, Review rides a group with a review-mode chevron and a closed menu', () => {
101
+ test('NO TOP-BAR REVIEW BUTTON: in Artistry the bar holds Edit words alone, and no review-mode menu', async () => {
105
102
  const p = boot({ session: artist() });
106
- const pill = p.byId('craft-review');
107
- assert.ok(pill, 'the group exists');
108
- assert.equal(p.byId('craft-tool-palette').parentNode, pill, 'Review sits in it');
109
- const more = p.byId('craft-review-more');
110
- assert.equal(more.parentNode, pill);
111
- assert.equal(more.getAttribute('aria-haspopup'), 'menu');
112
- assert.equal(more.getAttribute('aria-expanded'), 'false');
113
- assert.equal(more.getAttribute('aria-controls'), 'craft-review-menu');
114
- assert.equal(p.menu().getAttribute('role'), 'menu');
115
- assert.ok(p.menu().hasAttribute('hidden'), 'the menu starts closed');
103
+ await flush();
104
+ assert.deepEqual(p.tools(), ['paintbrush'], 'the review panel docks at the edge instead (task 1004771)');
105
+ assert.equal(p.byId('craft-review'), null, 'no split pill');
106
+ assert.equal(p.byId('craft-review-menu'), null, 'no menu');
107
+ assert.deepEqual(p.opened, ['dock']);
116
108
  assert.equal(p.byId('craft-graffiti'), null, 'no wall until a review starts');
117
109
  assert.equal(p.root.getAttribute('data-craft-review'), null);
118
- for (const m of ['building', 'ideating', 'governing']) {
119
- p.craft.choose(m);
120
- assert.equal(p.byId('craft-review'), null, `no Review pill in ${m}`);
121
- }
122
- });
123
-
124
- test('THE MENU: write feedback first, then the four experiences in the owner\'s order and words', () => {
125
- const p = boot({ session: artist() });
126
- p.byId('craft-review-more').click({ detail: 1 });
127
- assert.ok(p.menuOpen(), 'the chevron opens it');
128
- assert.equal(p.byId('craft-review-more').getAttribute('aria-expanded'), 'true');
129
- const html = p.menu().innerHTML;
130
- const labels = [...html.matchAll(/<span>([^<]+)<\/span>/g)].map((m) => m[1]);
131
- assert.deepEqual(labels, ['Write feedback on this page', 'Ideator experience', 'Governing experience', 'Building experience', 'Artistry experience']);
132
- assert.deepEqual([...html.matchAll(/data-review-pick="([a-z]+)"/g)].map((m) => m[1]), ['ideating', 'governing', 'building', 'artistry']);
133
- assert.equal((html.match(/role="menuitemradio" aria-checked="true"/g) || []).length, 0, 'nothing is checked before a review');
134
- assert.doesNotMatch(html, /Stop reviewing/, 'no way out of a review that has not started');
135
- p.byId('craft-review-more').click({ detail: 1 });
136
- assert.ok(!p.menuOpen(), 'the chevron closes it again');
137
- });
138
-
139
- test('HOVER opens it for a mouse, never for a touch, and leaving closes it after a beat', async () => {
140
- const p = boot({ session: artist() });
141
- const pill = p.byId('craft-review');
142
- pill.fire('pointerenter', { pointerType: 'touch' });
143
- assert.ok(!p.menuOpen(), 'a touch is not a hover');
144
- pill.fire('pointerenter', { pointerType: 'mouse' });
145
- assert.ok(p.menuOpen(), 'a mouse over the pill opens the menu');
146
- pill.fire('pointerleave', { pointerType: 'mouse' });
147
- assert.ok(p.menuOpen(), 'it stays a beat, so the pointer can reach the menu');
148
- pill.fire('pointerenter', { pointerType: 'mouse' });
149
- await new Promise((r) => setTimeout(r, 260));
150
- assert.ok(p.menuOpen(), 'coming back in cancels the close');
151
- pill.fire('pointerleave', { pointerType: 'mouse' });
152
- await new Promise((r) => setTimeout(r, 260));
153
- assert.ok(!p.menuOpen(), 'and leaving for good closes it');
154
- });
155
-
156
- test('CLICKING Review still opens the feedback panel; on a phone a tap opens the menu instead', async () => {
157
- const p = boot({ session: artist() });
158
- p.byId('craft-tool-palette').click({ detail: 1 });
159
- await flush();
160
- assert.deepEqual(p.opened, ['open'], 'the feedback doc opens, as before');
161
- assert.ok(!p.menuOpen());
162
- const phone = boot({ session: artist(), narrow: true });
163
- phone.byId('craft-tool-palette').click({ detail: 1 });
164
- await flush();
165
- assert.deepEqual(phone.opened, [], 'no panel on the tap');
166
- assert.ok(phone.menuOpen(), 'the menu instead (a phone has no hover)');
167
- phone.pick({ 'data-review-act': 'write' });
168
- await flush();
169
- assert.deepEqual(phone.opened, ['open'], 'and its first line opens the feedback doc');
110
+ assert.deepEqual(JSON.parse(JSON.stringify(p.craft.experiences().map((x) => x.label))), ['Ideator experience', 'Governing experience', 'Building experience', 'Artistry experience'], 'the four, in the owner\'s order and words, for the review panel row');
170
111
  });
171
112
 
172
113
  test('PICKING an experience: the hall wears that mode, the artist keeps the Review tools, and the wall goes up', async () => {
173
114
  const p = boot({ session: artist() });
174
- p.byId('craft-review-more').click({ detail: 1 });
175
- p.pick({ 'data-review-pick': 'governing' });
176
- assert.ok(!p.menuOpen(), 'the menu closes on a pick');
115
+ p.craft.review('governing');
177
116
  assert.equal(p.root.dataset.craft, 'governing', 'the hall wears Governing: its tabs, look and tallies follow');
178
117
  assert.equal(p.root.getAttribute('data-craft-review'), 'governing');
179
118
  assert.equal(p.craft.current(), 'governing');
180
119
  assert.equal(p.craft.reviewing(), 'governing');
181
120
  assert.equal(p.session['hall-craft'], 'artistry', 'the artist\'s own mode stays Artistry');
182
121
  assert.equal(p.session['hall-craft-review'], 'governing', 'the review lasts the session');
183
- assert.deepEqual(p.tools(), ['palette', 'paintbrush'], 'Review and Edit words stay in the bar');
184
- assert.ok(p.byId('craft-tool-palette').classList.contains('craft-tool--reviewing'));
122
+ assert.deepEqual(p.tools(), ['paintbrush'], 'Edit words stays in the bar');
123
+ await flush();
124
+ assert.ok(p.opened.filter((x) => x === 'dock').length >= 2, 'the docked panel is told again, so its mode row repaints');
185
125
  const wall = p.byId('craft-graffiti');
186
126
  assert.ok(wall, 'the graffiti wall is up');
187
127
  assert.equal(wall.parentNode, p.topbar, 'in the top bar');
188
128
  assert.equal(wall.nextSibling, p.side, 'in the empty space before the controls');
189
129
  assert.equal(wall.getAttribute('aria-hidden'), 'true', 'decorative');
190
130
  await flush();
191
- assert.deepEqual(p.loaded, ['/builders/craft-review.js?v=2026-10-09-test'], 'the wall\'s script loads on demand, at craft-mode.js\'s version');
131
+ assert.ok(p.loaded.includes('/builders/craft-review.js?v=2026-10-09-test'), 'the wall\'s script loads on demand, at craft-mode.js\'s version');
192
132
  assert.equal((wall.innerHTML.match(/class="gx-tag /g) || []).length, 6, 'six doodles');
193
133
  assert.doesNotMatch(wall.innerHTML, /<text/, 'scribbles, not words (owner, 2026-10-09)');
194
- p.byId('craft-review-more').click({ detail: 1 });
195
- assert.match(p.menu().innerHTML, /aria-checked="true" data-review-pick="governing"/, 'the menu checks the live experience');
196
- assert.match(p.menu().innerHTML, /data-review-act="stop"/, 'and offers a way out');
197
134
  });
198
135
 
199
136
  test('IT LASTS THE SESSION: a reload keeps the review; a new session starts without one', () => {
@@ -215,7 +152,7 @@ test('IT LASTS THE SESSION: a reload keeps the review; a new session starts with
215
152
  test('REVIEWING Ideating shows its own tool too, marked a guest so a tiny phone can fold it away', () => {
216
153
  const p = boot({ session: artist() });
217
154
  p.craft.review('ideating');
218
- assert.deepEqual(p.tools(), ['palette', 'paintbrush', 'lightbulb'], 'the artist\'s tools, then the experience\'s own, in order');
155
+ assert.deepEqual(p.tools(), ['paintbrush', 'lightbulb'], 'the artist\'s tool, then the experience\'s own, in order');
219
156
  assert.ok(p.byId('craft-tool-lightbulb').classList.contains('craft-tool--guest'));
220
157
  assert.ok(!p.byId('craft-tool-paintbrush').classList.contains('craft-tool--guest'));
221
158
  p.craft.choose('ideating');
@@ -227,13 +164,11 @@ test('ENDING it: Stop reviewing, or any mode from the switch, puts the artist ba
227
164
  const p = boot({ session: artist() });
228
165
  p.craft.review('building');
229
166
  assert.equal(p.root.dataset.craft, 'building');
230
- p.byId('craft-review-more').click({ detail: 1 });
231
- p.pick({ 'data-review-act': 'stop' });
167
+ p.craft.stopReview();
232
168
  assert.equal(p.root.dataset.craft, 'artistry', 'Stop reviewing is back in Artistry');
233
169
  assert.equal(p.root.getAttribute('data-craft-review'), null);
234
170
  assert.equal(p.byId('craft-graffiti'), null, 'the wall comes down');
235
171
  assert.equal(p.session['hall-craft-review'], undefined, 'and the session forgets it');
236
- assert.ok(!p.byId('craft-tool-palette').classList.contains('craft-tool--reviewing'));
237
172
  p.craft.review('governing');
238
173
  p.craft.choose('artistry');
239
174
  assert.equal(p.craft.reviewing(), null, 'choosing a mode from the switch, even Artistry, ends a review');
@@ -242,25 +177,7 @@ test('ENDING it: Stop reviewing, or any mode from the switch, puts the artist ba
242
177
  p.craft.choose('governing');
243
178
  assert.equal(p.root.dataset.craft, 'governing');
244
179
  assert.equal(p.root.getAttribute('data-craft-review'), null, 'Governing chosen for real is not a review');
245
- assert.equal(p.byId('craft-review'), null, 'and carries no Review pill');
246
- });
247
-
248
- test('THE KEYS: arrows move through the menu, Escape closes it, an outside click closes it', () => {
249
- const p = boot({ session: artist() });
250
- p.byId('craft-review-more').click({ detail: 1 });
251
- assert.equal(p.docListeners.click && p.docListeners.click.size, 1, 'an open menu listens for a click outside');
252
- p.menu().fire('keydown', { key: 'Escape' });
253
- assert.ok(!p.menuOpen(), 'Escape closes it');
254
- assert.equal(p.docListeners.click.size, 0, 'and stops listening');
255
- p.byId('craft-review-more').fire('keydown', { key: 'ArrowDown' });
256
- assert.ok(p.menuOpen(), 'ArrowDown on the chevron opens it');
257
- [...p.docListeners.click][0]({ target: p.title });
258
- assert.ok(!p.menuOpen(), 'a click elsewhere closes it');
259
- p.byId('craft-tool-palette').fire('keydown', { key: 'ArrowDown' });
260
- assert.ok(p.menuOpen(), 'ArrowDown on Review opens it too');
261
- assert.match(CRAFT, /e\.key === 'ArrowUp'/);
262
- assert.match(CRAFT, /e\.key === 'Home'/);
263
- assert.match(CRAFT, /e\.key === 'End'/);
180
+ assert.ok(p.opened.includes('close'), 'and the review panel leaves with Artistry');
264
181
  });
265
182
 
266
183
  test('THE WALL is scribbled in CSS only, in ink alone, and stands still under reduced motion', () => {