@bongos/core 1.21.163 → 1.21.164

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 (35) hide show
  1. package/.bongos-core.json +54 -39
  2. package/.gitattributes +6 -0
  3. package/docs/copy-inventory.md +99 -99
  4. package/docs/copy-registry.json +149 -139
  5. package/docs/module-api-changelog.md +2 -0
  6. package/docs/page-readings.json +141 -306
  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/goal-inbox.js +100 -7
  14. package/modules/hall-ui/public/goals.js +2 -4
  15. package/modules/hall-ui/public/hall-render.js +39 -9
  16. package/modules/hall-ui/public/hall-widgets.js +20 -10
  17. package/modules/hall-ui/public/index.html +7 -0
  18. package/modules/hall-ui/public/style.css +95 -0
  19. package/modules/hall-ui/public/task-detail.js +8 -0
  20. package/modules/hall-ui/records/home-reshuffle.md +31 -0
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/release-notes.json +14 -0
  24. package/scripts/gds/copy-apply.js +65 -2
  25. package/scripts/gds/git-merge-regen.js +14 -0
  26. package/scripts/gds/page-reader.js +46 -0
  27. package/src/module-api.js +1 -1
  28. package/tests/conflict_resolve.mjs +6 -0
  29. package/tests/hall_approval_queue.mjs +16 -0
  30. package/tests/hall_board_world.mjs +7 -6
  31. package/tests/home_attention_cards.mjs +119 -0
  32. package/tests/home_bounded_reads.mjs +1 -1
  33. package/tests/promote_goal_id.mjs +8 -0
  34. package/tests/tweak_batch_apply.mjs +40 -0
  35. package/tests/tweak_land_heal.mjs +139 -0
@@ -691,6 +691,8 @@ function approvalFor({ page, pageId, rounds, round, builderId, isArtist }) {
691
691
  const applied = pages.parseAppliedBlock(round.description);
692
692
  const refusedBy = new Map();
693
693
  if (applied.ok) for (const r of applied.applied.refused || []) if (r && r.key) refusedBy.set(String(r.key), String(r.code || 'refused'));
694
+ const pinnedBy = new Map();
695
+ if (applied.ok) for (const p of applied.applied.pinned_tests || []) if (p && p.key && Array.isArray(p.tests)) pinnedBy.set(String(p.key), p.tests.map(String));
694
696
  const lines = batch.ok ? batch.batch.lines.map((l) => ({
695
697
  key: l.key,
696
698
  section: l.section == null ? null : l.section,
@@ -698,6 +700,7 @@ function approvalFor({ page, pageId, rounds, round, builderId, isArtist }) {
698
700
  after: l.after,
699
701
  placement: l.placement || null,
700
702
  refused: refusedBy.get(String(l.key)) || null,
703
+ ...(pinnedBy.has(String(l.key)) ? { pinned_by: pinnedBy.get(String(l.key)) } : {}),
701
704
  })) : [];
702
705
  const shipped = (rounds || []).filter((r) => r.status === 'shipped');
703
706
  const now = countsFor(rounds);
@@ -164,6 +164,11 @@ function composeAppliedBlock(a) {
164
164
  reading_hash_after: a.reading_hash_after,
165
165
  lines_after: (a.lines_after || []).map(String),
166
166
  refused: (a.refused || []).map((x) => ({ key: x.key, code: x.code })),
167
+ // task 1004775: tests that still assert a rewritten line's old words, named
168
+ // at apply time so the artist sees them before approving, not CI after.
169
+ ...(Array.isArray(a.pinned_tests) && a.pinned_tests.length
170
+ ? { pinned_tests: a.pinned_tests.map((p) => ({ key: p.key, tests: (p.tests || []).map(String) })) }
171
+ : {}),
167
172
  });
168
173
  }
169
174
 
@@ -119,6 +119,10 @@
119
119
  marked: after && !line.refused,
120
120
  aside: line.refused ? refusedWords(line.refused) : (after ? `was: ${line.before}` : `becomes: ${line.after}`),
121
121
  refused: !!line.refused,
122
+ // task 1004775: a test still asserts the old words, so it fails once this lands.
123
+ ...(!line.refused && Array.isArray(line.pinned_by) && line.pinned_by.length
124
+ ? { pinned: `A test still checks the old words: ${line.pinned_by.join(', ')}. It needs updating when this ships.` }
125
+ : {}),
122
126
  };
123
127
  }
124
128
 
@@ -226,6 +226,8 @@ html.aq-open { overflow: hidden; }
226
226
  }
227
227
  .aq-line__aside { font-size: 13px; line-height: 1.45; color: var(--aq-ink-faint); overflow-wrap: anywhere; }
228
228
  .aq-line--refused .aq-line__aside { color: var(--aq-link); }
229
+ /* task 1004775: a test that still checks the old words. */
230
+ .aq-line__pinned { font-size: 13px; line-height: 1.45; color: var(--aq-link); overflow-wrap: anywhere; }
229
231
  .aq-lines__none { margin: 0; font-size: 14px; color: var(--aq-ink-faint); }
230
232
 
231
233
  /* The decision row. */
@@ -152,6 +152,7 @@
152
152
  ${r.section ? `<span class="aq-line__section">${esc(r.section)}</span>` : ''}
153
153
  <span class="aq-line__text">${r.marked ? `<mark class="aq-mark">${words(r.text)}</mark>` : words(r.text)}</span>
154
154
  <span class="aq-line__aside">${words(r.aside)}</span>
155
+ ${r.pinned ? `<span class="aq-line__pinned">${esc(r.pinned)}</span>` : ''}
155
156
  </li>`).join('')}</ol>`;
156
157
  }
157
158
 
@@ -929,7 +929,7 @@
929
929
 
930
930
  // ---- Home "Next up" slice (task 2184) -------------------------------------
931
931
 
932
- // Fill the top of the "Your work" card with the first two rows of the
932
+ // Fill the top of the "Your work" card with the first five rows of the
933
933
  // claimable queue (server order: priority, then calibrated estimate) so Home
934
934
  // answers "what's my next action". Best-effort: a primer-gated 403, an auth
935
935
  // hiccup, or an empty queue hides the slice — Home never shows an error for
@@ -947,7 +947,8 @@
947
947
  // already orders by priority then calibrated estimate, so the first two rows ARE
948
948
  // the top two. `claimable_total` comes back so the foot can say how many are
949
949
  // waiting instead of leaving two rows to imply there are only two.
950
- const resp = await getJSON(`${API}/tasks/claimable?limit=2`, { softAuth: true });
950
+ // task 1004774: five rows, level with the five Recently shipped rows beside it.
951
+ const resp = await getJSON(`${API}/tasks/claimable?limit=5`, { softAuth: true });
951
952
  // task 1003464: the greeting's "N tasks are claimable right now" — rank-scoped,
952
953
  // so it is THIS builder's number. It reads claimable_total rather than the length
953
954
  // of a fully-downloaded feed: the same number, without the wire cost that this
@@ -959,7 +960,8 @@
959
960
  ? claimableTotal
960
961
  : (resp && Array.isArray(resp.claimable) ? resp.claimable.length : null);
961
962
  renderGreeting();
962
- const rows = resp && Array.isArray(resp.claimable) ? resp.claimable : [];
963
+ // capped as well as asked for: a server that ignores ?limit must not grow the card.
964
+ const rows = resp && Array.isArray(resp.claimable) ? resp.claimable.slice(0, 5) : [];
963
965
  if (!rows.length) { wrap.hidden = true; if (subhead) subhead.hidden = true; return; }
964
966
  const total = Number(resp.claimable_total);
965
967
  const cur = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits';
@@ -1175,7 +1177,7 @@
1175
1177
  getJSON(`${API}/me/recent-ships?limit=all`),
1176
1178
  getJSON(`${API}/versions`),
1177
1179
  getJSON(`${API}/public/leaderboard`), // task 1004437: the list with each builder's roles
1178
- getJSON(`${API}/public/progress`), // task 1003464: "<version> is N percent shipped"
1180
+ getJSON(`${API}/public/progress`), // task 1003464: "<version> is N percent complete"
1179
1181
  ]);
1180
1182
 
1181
1183
  if (progress.status === 'fulfilled') {
@@ -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
@@ -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.164",
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.164",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",