@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
@@ -482,7 +482,7 @@
482
482
  }
483
483
  const shown = rows.slice(0, MAX_ROT_ROWS);
484
484
  const more = rows.length > shown.length
485
- ? `<p class="ginbox__pending">…${rows.length - shown.length} more going quiet.</p>`
485
+ ? `<p class="ginbox__pending">…${rows.length - shown.length} more not moving.</p>`
486
486
  : '';
487
487
  // The heading names the TIMER, not just the count. A reader who disagrees with
488
488
  // the line ("30 days is too short for a backlog") needs to know which number
@@ -493,7 +493,7 @@
493
493
  const timerNote = t.invalid
494
494
  ? `<span class="ginbox__sub"> — ${escapeHtml(String(t.describe || ''))}</span>`
495
495
  : `<span class="ginbox__sub"> — untouched ${escapeHtml(String(t.rot_days ?? '?'))}+ days</span>`;
496
- box.innerHTML = '<h3 class="ginbox__sec-h">Going quiet '
496
+ box.innerHTML = '<h3 class="ginbox__sec-h">Things not moving '
497
497
  + `<span class="count-pill">${rows.length}</span>${timerNote}</h3>`
498
498
  + `<ul class="ginbox">${shown.map(rotRowHtml).join('')}</ul>${more}`;
499
499
  window.OTBAttention?.refresh();
@@ -511,7 +511,7 @@
511
511
  // this handler swallowed it into an empty section that looked like "nothing
512
512
  // is rotting". A silent catch on the ONLY caller of a route is indistin-
513
513
  // guishable from a healthy empty state — which is the whole failure.
514
- console.warn('[hall] rot feed unavailable — the "Going quiet" section is empty '
514
+ console.warn('[hall] rot feed unavailable — the "Things not moving" section is empty '
515
515
  + 'because the request failed, NOT because nothing is rotting:', err);
516
516
  });
517
517
  }
@@ -560,7 +560,7 @@
560
560
  const more = total > rows.length
561
561
  ? `<p class="ginbox__pending">…${total - rows.length} more — <a href="/blockers">open the queue</a>.</p>`
562
562
  : '';
563
- box.innerHTML = '<h3 class="ginbox__sec-h">Stuck on the owner '
563
+ box.innerHTML = '<h3 class="ginbox__sec-h">Requires project owner fix '
564
564
  + `<span class="count-pill">${total}</span></h3>`
565
565
  + `<ul class="ginbox">${rows.map(blockerRowHtml).join('')}</ul>${more}`;
566
566
  window.OTBAttention?.refresh();
@@ -579,7 +579,7 @@
579
579
  if (box) box.innerHTML = '';
580
580
  window.OTBAttention?.refresh();
581
581
  if (err.code !== 'auth-needed' && err.code !== 'not-found') {
582
- console.warn('[hall] blockers feed unavailable — the "Stuck on the owner" section is empty '
582
+ console.warn('[hall] blockers feed unavailable — the "Requires project owner fix" section is empty '
583
583
  + 'because the request failed, NOT because nothing is open:', err);
584
584
  }
585
585
  });
@@ -595,6 +595,17 @@
595
595
  host.hidden = true; // stays hidden until a slice finds something actionable
596
596
  host.innerHTML =
597
597
  '<h2 id="attention-h" class="scroll__h">Needs your attention<span class="count-pill" id="attention-count" hidden></span></h2>' +
598
+ // task 1004774: every slice is a CARD showing its total, and a click opens the
599
+ // slice itself in a window, so its buttons work exactly as they do inline.
600
+ // The slices still render into their divs below (kept off-screen in
601
+ // #attention-slices), and refresh() rebuilds the cards from what they hold.
602
+ '<div class="attn-cards" id="attention-cards"></div>' +
603
+ '<dialog class="attn-dialog" id="attention-dialog" aria-labelledby="attention-dialog-h">' +
604
+ '<div class="attn-dialog__head"><h3 class="attn-dialog__h" id="attention-dialog-h"></h3>' +
605
+ '<button type="button" class="attn-dialog__x" id="attention-dialog-x" aria-label="Close">×</button></div>' +
606
+ '<div class="attn-dialog__body" id="attention-dialog-body"></div>' +
607
+ '</dialog>' +
608
+ '<div id="attention-slices" hidden>' +
598
609
  // task 1003464: the system's own notices — rebase owed, an input the
599
610
  // system needs, the monthly budget — used to stack as banners INSIDE the
600
611
  // Standing card. They are lines here now (rendered by hall-render.js
@@ -639,8 +650,10 @@
639
650
  // the card on purpose: every slice above is somebody waiting on YOU right
640
651
  // now, while rot is a standing question that has already waited weeks and
641
652
  // can wait until they are read.
642
- '<div id="attention-rot"></div>';
653
+ '<div id="attention-rot"></div>' +
654
+ '</div>';
643
655
  host.addEventListener('click', onClick);
656
+ wireCards();
644
657
  load();
645
658
  loadNodQueue();
646
659
  loadRemovals();
@@ -651,6 +664,83 @@
651
664
  window.OTBProfileNudge?.load();
652
665
  }
653
666
 
667
+ // ---- the cards + the window (task 1004774) --------------------------------
668
+ //
669
+ // Each slice div is one card. The card reads its title from the slice's own
670
+ // heading and its total from the heading's count pill (the server total, which
671
+ // can exceed the rows shown) or, failing that, the rows. Opening a card MOVES
672
+ // the slice div into the dialog — the dialog sits inside `host`, so the
673
+ // delegated click handler above still sees every button — and closing moves it
674
+ // back to where it came from.
675
+ const ITEM_SEL = '.ginbox__item, .triage-inbox__item';
676
+ let openSlice = null;
677
+
678
+ function sliceFacts(div) {
679
+ const h = div.querySelector('h3, .ginbox__sec-h, h4');
680
+ let title = '';
681
+ if (h) {
682
+ const copy = h.cloneNode(true);
683
+ copy.querySelectorAll('.count-pill, .ginbox__sub').forEach((n) => n.remove());
684
+ title = copy.textContent.replace(/\s+/g, ' ').trim();
685
+ }
686
+ const pill = h && h.querySelector('.count-pill');
687
+ const pillN = pill ? parseInt(pill.textContent, 10) : NaN;
688
+ const rows = div.querySelectorAll(ITEM_SEL).length;
689
+ const total = Number.isFinite(pillN) ? pillN : rows;
690
+ const sub = h && h.querySelector('.ginbox__sub');
691
+ return { title: title || 'Needs attention', total, rows, note: sub ? sub.textContent.replace(/^\s*—\s*/, '').trim() : '' };
692
+ }
693
+
694
+ function renderCards() {
695
+ const cards = host && host.querySelector('#attention-cards');
696
+ const slices = host && host.querySelector('#attention-slices');
697
+ if (!cards || !slices) return;
698
+ const html = [];
699
+ for (const div of slices.children) {
700
+ if (!div.id || !div.innerHTML.trim()) continue;
701
+ const f = sliceFacts(div);
702
+ if (!f.rows && !div.querySelector('.ginbox__pending')) continue;
703
+ html.push(`<button type="button" class="attn-card" data-attn-open="${escapeHtml(div.id)}">`
704
+ + `<span class="attn-card__n">${escapeHtml(String(f.total))}</span>`
705
+ + `<span class="attn-card__t">${escapeHtml(f.title)}</span>`
706
+ + (f.note ? `<span class="attn-card__s">${escapeHtml(f.note)}</span>` : '')
707
+ + '</button>');
708
+ }
709
+ cards.innerHTML = html.join('');
710
+ }
711
+
712
+ function openCard(id) {
713
+ const dlg = host.querySelector('#attention-dialog');
714
+ const div = host.querySelector(`#attention-slices > #${CSS.escape(id)}`);
715
+ if (!dlg || !div) return;
716
+ host.querySelector('#attention-dialog-h').textContent = sliceFacts(div).title;
717
+ host.querySelector('#attention-dialog-body').appendChild(div);
718
+ openSlice = div;
719
+ if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
720
+ }
721
+
722
+ function closeCard() {
723
+ const dlg = host && host.querySelector('#attention-dialog');
724
+ if (openSlice) {
725
+ host.querySelector('#attention-slices').appendChild(openSlice);
726
+ openSlice = null;
727
+ }
728
+ if (dlg && dlg.open && typeof dlg.close === 'function') dlg.close();
729
+ renderCards();
730
+ }
731
+
732
+ function wireCards() {
733
+ host.addEventListener('click', (e) => {
734
+ const card = e.target.closest('[data-attn-open]');
735
+ if (card) { openCard(card.getAttribute('data-attn-open')); return; }
736
+ if (e.target.closest('#attention-dialog-x')) { closeCard(); return; }
737
+ // a click on the backdrop lands on the <dialog> itself
738
+ if (e.target.id === 'attention-dialog') closeCard();
739
+ });
740
+ const dlg = host.querySelector('#attention-dialog');
741
+ if (dlg) dlg.addEventListener('close', () => { if (openSlice) closeCard(); });
742
+ }
743
+
654
744
  // The card's shared visibility contract: any slice renderer calls refresh()
655
745
  // after painting; the card shows iff any slice has actionable rows, and the
656
746
  // headline pill is the cross-slice total.
@@ -658,11 +748,14 @@
658
748
  refresh() {
659
749
  const sec = document.getElementById('attention-scroll');
660
750
  if (!sec) return;
661
- const n = sec.querySelectorAll('.ginbox__item, .triage-inbox__item').length;
751
+ const n = sec.querySelectorAll(ITEM_SEL).length;
662
752
  const pending = sec.querySelector('.ginbox__pending');
663
753
  const pill = document.getElementById('attention-count');
664
754
  if (pill) { pill.hidden = !n; pill.textContent = String(n); }
665
755
  sec.hidden = n === 0 && !pending;
756
+ // An open window keeps its slice; the card behind it is redrawn on close.
757
+ if (!openSlice) renderCards();
758
+ else if (!openSlice.querySelector(ITEM_SEL)) closeCard();
666
759
  },
667
760
  };
668
761
 
@@ -92,7 +92,7 @@
92
92
  }
93
93
 
94
94
  // task 2184 (Home rule: a summary card never lists more than 5 rows): cap the
95
- // joined-goals list; the overflow count rides the "All goals" foot link.
95
+ // joined-goals list. The foot is one "Browse goals" link (owner, task 1004774).
96
96
  const MAX_HOME_ROWS = 5;
97
97
  function render(joined, counts) {
98
98
  const body = host.querySelector('#goals-body');
@@ -101,14 +101,12 @@
101
101
  return;
102
102
  }
103
103
  const shown = joined.slice(0, MAX_HOME_ROWS);
104
- const more = joined.length - shown.length;
105
104
  body.innerHTML = `<ul class="goal-mine-list">${shown.map((d) => row(d, counts)).join('')}</ul>` +
106
- `<p class="goal-mine__foot card-foot"><a href="/goals">${more > 0 ? `All ${joined.length} goals →` : 'All goals →'}</a></p>`;
105
+ `<p class="goal-mine__foot card-foot"><a href="/goals">Browse goals →</a></p>`;
107
106
  }
108
107
 
109
108
  const SKELETON =
110
109
  '<h2 id="goals-h" class="scroll__h">Goals</h2>' +
111
- '<p class="scroll__lede">The open goals you’ve joined, and the work you can claim in each. Browse, join, and leave goals — and read their criteria — on the <a href="/goals">Goals page</a>.</p>' +
112
110
  '<div id="goals-body"><div class="profile__placeholder">Loading your goals…</div></div>';
113
111
 
114
112
  async function load() {
@@ -638,15 +638,25 @@
638
638
  </div>
639
639
  <span class="home-who__rank">${builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : ''}</span>
640
640
  </div>
641
- <dl class="home-facts">
642
- <div><dt>${escapeHtml(curLabel(true))}</dt><dd>${fmtNum(builder.total_credits)}</dd></div>
643
- <div><dt>On the board</dt><dd>${place}</dd></div>
644
- <div><dt>Preferred work</dt><dd>${prefs.length ? escapeHtml(prefs.join(', ')) : PREFS_EMPTY}</dd></div>
645
- </dl>
641
+ <div class="hall-standing__more">
642
+ <dl class="home-facts home-facts--grid">
643
+ <div><dt>${escapeHtml(curLabel(true))}</dt><dd>${fmtNum(builder.total_credits)}</dd></div>
644
+ <div><dt>Karma</dt><dd>${fmtNum(karmaTotal)}</dd></div>
645
+ <div><dt>Shipped</dt><dd id="standing-shipped">${escapeHtml((document.getElementById('stat-shipped') || {}).textContent || '—')}</dd></div>
646
+ <div><dt>Leaderboard place</dt><dd>${place}</dd></div>
647
+ </dl>
648
+ <dl class="home-facts">
649
+ <div><dt>Preferred work</dt><dd>${prefs.length ? escapeHtml(prefs.join(', ')) : PREFS_EMPTY}</dd></div>
650
+ </dl>
651
+ ${builder.id != null ? `<p class="card-foot"><a href="/profile?id=${encodeURIComponent(builder.id)}">View full profile →</a></p>` : ''}
652
+ </div>
646
653
  `;
647
654
  }
648
- const homeStandingSection = document.getElementById('home-standing');
649
- if (homeStandingSection && !ctx.newcomerGated) homeStandingSection.hidden = false;
655
+ // task 1004774: Standing is the page-head chip now, not a Home card. The
656
+ // who-line above IS the chip: at rest it shows the avatar and rank, and on
657
+ // hover it widens left to show the name while .hall-standing__more drops down.
658
+ const standingChip = document.getElementById('hall-standing');
659
+ if (standingChip && !ctx.newcomerGated) standingChip.hidden = false;
650
660
  renderSystemNotices({ rebaseTasks, need, actionNeeds, cost });
651
661
  }
652
662
 
@@ -724,6 +734,22 @@
724
734
  return rows.filter((r) => r && r.status === 'building')
725
735
  .sort((a, b) => (Number(b.total_count) || 0) - (Number(a.total_count) || 0))[0] || null;
726
736
  }
737
+ // task 1004774: the version tab under the Home banner — the same version the
738
+ // sub-line names, drawn as a bar. Hidden while no version is being built.
739
+ function renderVersionTab(v) {
740
+ const tab = document.getElementById('home-version');
741
+ if (!tab) return;
742
+ if (!v) { tab.hidden = true; return; }
743
+ const pct = Math.max(0, Math.min(100, Math.round(Number(v.percent_complete) || 0)));
744
+ const name = document.getElementById('home-version-name');
745
+ const fill = document.getElementById('home-version-fill');
746
+ const pctEl = document.getElementById('home-version-pct');
747
+ if (name) name.textContent = v.version_id;
748
+ if (fill) fill.style.width = `${pct}%`;
749
+ if (pctEl) pctEl.textContent = `${pct}% complete`;
750
+ tab.setAttribute('aria-label', `${v.version_id} is ${pct} percent complete`);
751
+ tab.hidden = false;
752
+ }
727
753
  function renderGreeting() {
728
754
  const h1 = document.getElementById('hall-h1');
729
755
  const sub = document.getElementById('hall-sub');
@@ -731,10 +757,11 @@
731
757
  if (h1 && builder) {
732
758
  h1.textContent = `${greetingWord(new Date().getHours())}, ${builder.display_name || builder.github_login || 'builder'}`;
733
759
  }
760
+ const v = currentVersion(ctx.lastProgressState);
761
+ renderVersionTab(v);
734
762
  if (!sub) return;
735
763
  const parts = [];
736
- const v = currentVersion(ctx.lastProgressState);
737
- if (v) parts.push(`${v.version_id} is ${Math.round(Number(v.percent_complete) || 0)} percent shipped.`);
764
+ if (v) parts.push(`${v.version_id} is ${Math.round(Number(v.percent_complete) || 0)} percent complete.`);
738
765
  const n = ctx.lastClaimableCount;
739
766
  if (typeof n === 'number') {
740
767
  if (n === 0) parts.push('Nothing is claimable right now.');
@@ -1036,6 +1063,9 @@
1036
1063
  // 5-row recent-work table are fed from the same feed. Guarded writes.
1037
1064
  const statShipped = document.getElementById('stat-shipped');
1038
1065
  if (statShipped) statShipped.textContent = fmtNum(shipState.all.length);
1066
+ // task 1004774: the Standing hover card carries the same count.
1067
+ const standingShipped = document.getElementById('standing-shipped');
1068
+ if (standingShipped) standingShipped.textContent = fmtNum(shipState.all.length);
1039
1069
  const homeRecent = document.querySelector('#home-recent-table tbody');
1040
1070
  if (homeRecent) {
1041
1071
  const latest = sortShips(shipState.all, 'recent').slice(0, 5);
@@ -75,16 +75,20 @@
75
75
  '<a class="stat-tile" href="#standing"><span class="stat-tile__label">Shipped</span><span class="stat-tile__num" id="stat-shipped">—</span><span class="stat-tile__sub" id="stat-shipped-sub"></span></a>' +
76
76
  '<a class="stat-tile" href="#standing"><span class="stat-tile__label">Karma</span><span class="stat-tile__num" id="stat-karma">—</span><span class="stat-tile__sub" id="stat-karma-sub"></span></a>' +
77
77
  '<a class="stat-tile" href="/ranks"><span class="stat-tile__label">Rank</span><span class="stat-tile__num stat-tile__num--badge" id="stat-rank">—</span><span class="stat-tile__sub" id="stat-rank-sub"></span></a>' +
78
- '</div>',
78
+ '</div>' +
79
+ // task 1004774: the version tab under the banner — how far the version being
80
+ // built has come. hall-render.js renderGreeting fills it from the same
81
+ // progress read as the greeting's sub-line; hidden until there is a version.
82
+ '<a class="version-tab" id="home-version" href="/roadmap" hidden>' +
83
+ '<span class="version-tab__name" id="home-version-name"></span>' +
84
+ '<span class="version-tab__bar" aria-hidden="true"><span class="version-tab__fill" id="home-version-fill"></span></span>' +
85
+ '<span class="version-tab__pct" id="home-version-pct"></span>' +
86
+ '</a>',
79
87
  { view: 'home', zone: 'home-stats', sectionClass: 'scroll scroll--bare', labelledby: 'home-stats-h', hiddenByDefault: true });
80
88
 
81
- // 0d. Standing summary card — left half of the Home band (task 1723). Filled by
82
- // builders.js renderProfile from the same /me payload as the full Standing view.
83
- w('home-standing', 'Standing', 18,
84
- '<h2 id="home-standing-h" class="scroll__h">Standing</h2>' +
85
- '<div id="home-standing-body"><div class="profile__placeholder">…</div></div>' +
86
- '<p class="card-foot"><a href="#standing">See your Full Account. →</a></p>',
87
- { view: 'home', zone: 'home-band', labelledby: 'home-standing-h', hiddenByDefault: true });
89
+ // (task 1004774: the Standing summary is no longer a Home card. It is the
90
+ // chip at the top right of the page head — index.html #hall-standing — whose
91
+ // hover card holds #home-standing-body, filled by hall-render.js renderProfile.)
88
92
 
89
93
  // (Goals — goals.js registers itself into the other half of the Home band.)
90
94
 
@@ -99,15 +103,21 @@
99
103
  // gated or empty), then the 5-row recent-ships table below it.
100
104
  w('home-recent', 'Your work', 28,
101
105
  '<h2 id="home-recent-h" class="scroll__h">Your work</h2>' +
102
- '<div id="home-next" hidden>' +
106
+ // task 1004774: Next up and Recently shipped sit side by side (stacking
107
+ // again on a phone); when Next up is hidden, shipped takes the full width.
108
+ '<div class="home-split">' +
109
+ '<div class="home-split__col" id="home-next" hidden>' +
103
110
  '<h3 class="ginbox__sec-h">Next up — ready to claim</h3>' +
104
111
  '<ul class="next-up" id="home-next-list"></ul>' +
105
112
  '<p class="next-up__foot"><a href="/work" id="home-next-foot">See all ready work →</a></p>' +
106
113
  '</div>' +
114
+ '<div class="home-split__col">' +
107
115
  '<h3 class="ginbox__sec-h" id="home-recent-subh" hidden>Recently shipped</h3>' +
108
116
  '<table class="ledger" id="home-recent-table"><thead><tr><th>№</th><th>Work</th><th data-currency-label="caps">Credits</th><th>Shipped</th></tr></thead>' +
109
117
  '<tbody><tr><td colspan="4" class="ledger__empty">…</td></tr></tbody></table>' +
110
- '<p class="card-foot"><a href="#standing">All your work →</a></p>',
118
+ '<p class="card-foot"><a href="#standing">All your work →</a></p>' +
119
+ '</div>' +
120
+ '</div>',
111
121
  { view: 'home', zone: 'home-main', labelledby: 'home-recent-h', hiddenByDefault: true });
112
122
 
113
123
  // 0f. The founding wall (task 1004424) — everyone who joined while the project was
@@ -48,6 +48,13 @@
48
48
  <div class="page-head page-head--board page-head--hall">
49
49
  <h1 id="hall-h1" class="page-head__h">Builders' Hall</h1>
50
50
  <p id="hall-sub" class="page-head__sub" hidden></p>
51
+ <!-- task 1004774: Standing left the Home band for this chip. At rest it
52
+ shows the avatar and rank. On hover or keyboard focus the chip itself
53
+ widens to the left into the top of the card, and only the facts below
54
+ drop down (#home-standing-body, filled by hall-render.js renderProfile). -->
55
+ <div class="hall-standing" id="hall-standing" hidden tabindex="0" aria-label="Your standing">
56
+ <div class="hall-standing__card" id="home-standing-body"></div>
57
+ </div>
51
58
  </div>
52
59
 
53
60
  <!-- BV1.R75 (ADR 0062 §4): hall widgets are NOT hardcoded here. Every widget
@@ -16,7 +16,9 @@
16
16
  --pn-glass: var(--glass, rgba(255, 255, 255, .08));
17
17
  }
18
18
 
19
- /* The page feedback doc: a sheet from the right over a scrim. */
19
+ /* A sheet from the right over a scrim: the lightbulb's panel (lightbulb.js), and
20
+ the feedback doc wherever it is OPENED rather than docked (the public site's
21
+ Artist review button, artist-review.js). */
20
22
  html.pn-open { overflow: hidden; }
21
23
  .pn {
22
24
  position: fixed;
@@ -26,6 +28,67 @@ html.pn-open { overflow: hidden; }
26
28
  justify-content: flex-end;
27
29
  background: var(--pn-scrim);
28
30
  }
31
+ /* The page feedback doc (.pn--dock, task 1004771): docked on the right with no
32
+ scrim and no scroll lock, so the reviewer keeps scrolling the page while
33
+ writing about it. A small tab on its left edge slides it away and back
34
+ (.pn--collapsed). */
35
+ .pn.pn--dock {
36
+ inset: 0 0 0 auto;
37
+ display: block;
38
+ width: min(560px, calc(100% - 36px));
39
+ background: none;
40
+ transition: transform .25s ease;
41
+ }
42
+ .pn--collapsed { transform: translateX(100%); }
43
+ /* Tucked away, the panel's controls leave the tab order once it has slid out. */
44
+ .pn--collapsed .pn__panel { visibility: hidden; transition: visibility 0s .25s; }
45
+ @media (prefers-reduced-motion: reduce) { .pn.pn--dock { transition: none; } }
46
+ .pn__tab {
47
+ position: absolute;
48
+ top: 50%;
49
+ right: 100%;
50
+ transform: translateY(-50%);
51
+ display: inline-flex; align-items: center; justify-content: center;
52
+ flex-direction: column; gap: 10px;
53
+ width: 36px; height: 96px; padding: 0;
54
+ border: 0;
55
+ border-radius: var(--radius-sm, 8px) 0 0 var(--radius-sm, 8px);
56
+ background: var(--bg-card);
57
+ color: var(--craft, var(--ink));
58
+ box-shadow: 0 0 0 1px var(--craft, var(--rule)), var(--pn-shadow-pop);
59
+ cursor: pointer;
60
+ }
61
+ .pn__tab:hover { background: var(--pn-glass); }
62
+ .pn__tab:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
63
+ .pn__tab-icon { width: 18px; height: 18px; }
64
+ .pn__tab-chev { width: 12px; height: 12px; transition: transform .25s ease; }
65
+ .pn--collapsed .pn__tab-chev { transform: rotate(180deg); }
66
+ @media (prefers-reduced-motion: reduce) { .pn__tab-chev { transition: none; } }
67
+ /* The Review mode row: which experience the hall wears while reviewed. */
68
+ .pn__modes { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 12px; }
69
+ .pn__modes-h {
70
+ width: 100%;
71
+ font-family: var(--label-face);
72
+ font-size: 11px;
73
+ letter-spacing: .12em;
74
+ text-transform: uppercase;
75
+ color: var(--ink-faint);
76
+ }
77
+ .pn__mode {
78
+ display: inline-flex; align-items: center; gap: 6px;
79
+ min-height: 30px; padding: 0 12px;
80
+ border: 1px solid var(--rule);
81
+ border-radius: var(--radius-pill);
82
+ background: none;
83
+ color: var(--ink-soft);
84
+ font: inherit; font-size: 13px;
85
+ cursor: pointer;
86
+ }
87
+ .pn__mode:hover { color: var(--ink); background: var(--pn-glass); }
88
+ .pn__mode[aria-pressed="true"] { color: var(--ink); border-color: var(--craft, var(--ink)); background: var(--pn-glass); }
89
+ .pn__mode:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
90
+ .pn__mode-icon { width: 14px; height: 14px; }
91
+ .pn__mode--stop { border-style: dashed; }
29
92
  .pn__panel {
30
93
  position: relative;
31
94
  width: min(560px, 100%);
@@ -19,7 +19,7 @@
19
19
  var MAX_IMAGES = 8;
20
20
  var TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
21
21
 
22
- var sheet = null, returnFocus = null, state = null, saveTimer = null, pollTimer = null;
22
+ var sheet = null, returnFocus = null, state = null, saveTimer = null, pollTimer = null, docked = false;
23
23
 
24
24
  function esc(s) {
25
25
  return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
@@ -101,11 +101,45 @@
101
101
  }
102
102
 
103
103
  // ---------- the sheet ----------
104
+ // The tab wears the Review button's own palette glyph (craft-mode.js ICONS
105
+ // artistry), drawn here too because the public site has no craft-mode.js.
106
+ var PALETTE_SVG = '<svg class="pn__tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
107
+ '<path d="M8 2a6 6 0 1 0 0 12c.9 0 1.4-.6 1.4-1.3 0-.8-.6-1-.6-1.7 0-.6.5-1 1.2-1h1.6A2.4 2.4 0 0 0 14 7.6C14 4.5 11.3 2 8 2Z"/><path d="M5 6.2h.01M7.6 4.6h.01M10.4 5.2h.01M4.6 9h.01" stroke-width="2.2"/></svg>';
108
+ // The Review mode row: which experience the hall wears while the artist
109
+ // reviews it (craft-mode.js review mode). Hall only: the public site has no
110
+ // modes, so it draws nothing there.
111
+ function craft() { var c = window.OTBCraftMode; return c && c.experiences && c.review ? c : null; }
112
+ function modesHtml() {
113
+ var c = craft();
114
+ if (!c) return '';
115
+ var on = c.reviewing();
116
+ return '<div class="pn__modes" role="group" aria-label="Review mode">' +
117
+ '<span class="pn__modes-h">Review mode</span>' +
118
+ c.experiences().map(function (x) {
119
+ return '<button class="pn__mode" type="button" data-pn-mode="' + esc(x.id) + '" aria-pressed="' + (on === x.id ? 'true' : 'false') + '">' +
120
+ c.iconHtml(x.id, 'pn__mode-icon') + '<span>' + esc(x.short) + '</span></button>';
121
+ }).join('') +
122
+ (on ? '<button class="pn__mode pn__mode--stop" type="button" data-pn-mode="">Stop reviewing</button>' : '') +
123
+ '</div>';
124
+ }
125
+ function paintModes() {
126
+ var row = sheet && sheet.querySelector('#pn-modes');
127
+ if (row) row.innerHTML = modesHtml();
128
+ }
129
+ // Two shapes. Docked (dock(), the hall's artist mode, task 1004771): a
130
+ // non-modal region at the right edge behind a tab. Opened (open(), e.g. the
131
+ // public site's Artist review button): the modal sheet over a scrim it was.
104
132
  function shellHtml(p) {
105
- return '<div class="pn__panel" role="dialog" aria-modal="true" aria-labelledby="pn-title">' +
133
+ return (docked
134
+ ? '<button class="pn__tab" type="button" data-pn-tab aria-expanded="true" aria-controls="pn-panel" aria-label="Hide the review panel">' +
135
+ PALETTE_SVG +
136
+ '<svg class="pn__tab-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg></button>' +
137
+ '<div class="pn__panel" id="pn-panel" role="region" aria-labelledby="pn-title">'
138
+ : '<div class="pn__panel" role="dialog" aria-modal="true" aria-labelledby="pn-title">') +
106
139
  '<div class="pn__head">' +
107
140
  '<p class="pn__eyebrow">Artist review · ' + esc(p.title) + '</p>' +
108
141
  '<h2 class="pn__title" id="pn-title">What should this page be?</h2>' +
142
+ '<div id="pn-modes">' + modesHtml() + '</div>' +
109
143
  '<button class="pn__close" type="button" data-pn-close aria-label="Close">' +
110
144
  '<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M5 5l8 8M13 5l-8 8"/></svg></button>' +
111
145
  '</div>' +
@@ -285,10 +319,18 @@
285
319
  }
286
320
 
287
321
  function onClick(e) {
288
- if (e.target === sheet) { close(); return; }
322
+ if (!docked && e.target === sheet) { close(); return; }
289
323
  var btn = e.target.closest && e.target.closest('button');
290
324
  if (!btn) return;
291
- if (btn.hasAttribute('data-pn-close')) close();
325
+ if (btn.hasAttribute('data-pn-tab')) toggle();
326
+ else if (btn.hasAttribute('data-pn-mode')) {
327
+ var c = craft(), id = btn.getAttribute('data-pn-mode');
328
+ if (c) { if (id) c.review(id); else c.stopReview(); }
329
+ paintModes();
330
+ }
331
+ // Docked, the X tucks the panel away like Escape: removing it would take
332
+ // the tab, the reviewer's only way back in, with it.
333
+ else if (btn.hasAttribute('data-pn-close')) { if (docked) toggle(true); else close(); }
292
334
  else if (btn.hasAttribute('data-pn-submit')) submit();
293
335
  else if (btn.hasAttribute('data-pn-note')) showNote(btn.getAttribute('data-pn-note'));
294
336
  else if (btn.hasAttribute('data-pn-back') || btn.hasAttribute('data-pn-new')) {
@@ -298,6 +340,13 @@
298
340
  }
299
341
  function onKey(e) {
300
342
  if (!sheet) return;
343
+ // Docked, the panel is not modal: the page behind it stays scrollable and
344
+ // reachable, and Escape inside it tucks it away behind its tab, draft kept.
345
+ if (docked) {
346
+ if (e.key === 'Escape' && sheet.contains(document.activeElement)) { e.preventDefault(); toggle(true); }
347
+ return;
348
+ }
349
+ // Opened, it is the modal sheet: Escape closes it, Tab stays inside it.
301
350
  if (e.key === 'Escape') { e.preventDefault(); close(); return; }
302
351
  if (e.key !== 'Tab') return;
303
352
  var f = Array.prototype.slice.call(sheet.querySelectorAll('button, textarea, input, a[href]')).filter(function (x) { return !x.disabled; });
@@ -305,20 +354,46 @@
305
354
  if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
306
355
  else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
307
356
  }
308
- function mountSheet() {
357
+ // Slide the panel off the right edge, leaving only its tab, or bring it back.
358
+ // `quiet` (docking) moves no focus. A docked panel opens its draft only
359
+ // the first time it is shown, so artist mode costs no request until then.
360
+ function toggle(collapse, quiet) {
361
+ if (!sheet) return;
362
+ var on = typeof collapse === 'boolean' ? collapse : !sheet.classList.contains('pn--collapsed');
363
+ sheet.classList.toggle('pn--collapsed', on);
364
+ var tab = sheet.querySelector('[data-pn-tab]');
365
+ if (tab) {
366
+ tab.setAttribute('aria-expanded', on ? 'false' : 'true');
367
+ tab.setAttribute('aria-label', on ? 'Show the review panel' : 'Hide the review panel');
368
+ if (on && !quiet) tab.focus();
369
+ }
370
+ if (on) save();
371
+ else if (state.pending) { state.pending = false; openDraft(); }
372
+ }
373
+ // `dock` mounts the docked shape quietly (no focus moved); otherwise the modal sheet.
374
+ function mountSheet(dock) {
309
375
  if (sheet) return false;
310
- returnFocus = document.activeElement;
376
+ docked = !!dock;
377
+ returnFocus = docked ? null : document.activeElement;
311
378
  state = { note: null, images: [], list: [] };
312
379
  sheet = document.createElement('div');
313
- sheet.className = 'pn';
380
+ sheet.className = docked ? 'pn pn--dock' : 'pn';
314
381
  sheet.id = 'page-notes';
315
382
  sheet.innerHTML = shellHtml(page());
316
383
  sheet.addEventListener('click', onClick);
384
+ if (docked) {
385
+ // A mouse peeks (owner, 2026-10-23): over the tab the panel opens, and the
386
+ // moment the pointer leaves the panel it tucks away again, draft kept.
387
+ // Touch and keyboard keep the tab's click, which a hover cannot replace.
388
+ sheet.querySelector('[data-pn-tab]').addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') toggle(false, true); });
389
+ sheet.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') toggle(true, true); });
390
+ } else {
391
+ document.documentElement.classList.add('pn-open');
392
+ }
317
393
  document.addEventListener('keydown', onKey, true);
318
394
  document.body.appendChild(sheet);
319
- document.documentElement.classList.add('pn-open');
320
395
  var c = sheet.querySelector('[data-pn-close]');
321
- if (c) c.focus();
396
+ if (c && !docked) c.focus();
322
397
  return true;
323
398
  }
324
399
  function close() {
@@ -328,14 +403,24 @@
328
403
  document.removeEventListener('keydown', onKey, true);
329
404
  if (sheet.parentNode) sheet.parentNode.removeChild(sheet);
330
405
  sheet = null;
406
+ docked = false;
331
407
  document.documentElement.classList.remove('pn-open');
332
408
  if (returnFocus && returnFocus.focus) { try { returnFocus.focus(); } catch (_) { /* gone */ } }
333
409
  returnFocus = null;
334
410
  }
335
411
 
336
412
  window.OTBPageNotes = {
337
- open: function () { if (mountSheet()) openDraft(); },
338
- openNote: function (id) { if (mountSheet()) refreshList().then(function () { return showNote(id); }); },
413
+ open: function () { if (mountSheet()) openDraft(); else if (docked) toggle(false); },
414
+ // Artist mode (craft-mode.js, task 1004771): the panel waits at the right
415
+ // edge, tucked behind its tab; already there, it repaints its mode row.
416
+ dock: function () {
417
+ if (mountSheet(true)) { state.pending = true; toggle(true, true); }
418
+ else paintModes();
419
+ },
420
+ openNote: function (id) {
421
+ if (!mountSheet() && docked) { state.pending = false; toggle(false); }
422
+ refreshList().then(function () { return showNote(id); });
423
+ },
339
424
  close: close,
340
425
  configure: configure,
341
426
  ideaHref: ideaHref,