@bongos/core 1.19.473 → 1.19.475

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.
@@ -1383,5 +1383,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1383
1383
  landed since 1.19.471 with no explicit bump. run 33646171957. (task 1002620)
1384
1384
  1.19.473 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1385
1385
  landed since 1.19.472 with no explicit bump. run 33646919877. (task 1002620)
1386
+ 1.19.474 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1387
+ landed since 1.19.473 with no explicit bump. run 33650008858. (task 1002620)
1388
+ 1.19.475 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1389
+ landed since 1.19.474 with no explicit bump. run 33653632532. (task 1002620)
1386
1390
  ---------------------------------------------------------------------------
1387
1391
  ```
@@ -230,7 +230,7 @@
230
230
 
231
231
  // task 1723: which container a widget mounts into. A widget may declare
232
232
  // `zone` (a fine-grained Home slot), `group` (a Standing rail section, task
233
- // 2174) or `view` (home/standing/leaderboard); all default to the Home main
233
+ // 2174) or `view` (home/standing); all default to the Home main
234
234
  // zone so pre-redesign module widgets still land somewhere visible. Every
235
235
  // container lives INSIDE <main>, so the `main .scroll` sweeps (newcomer gate,
236
236
  // landing hide-all) keep working.
@@ -910,26 +910,12 @@
910
910
  }
911
911
  }
912
912
 
913
- // ---- leaderboard ---------------------------------------------------------
914
-
915
- // Locked at 8 for V2 (per limitations/pms-v2.md). Hardcoding here avoids an
916
- // extra API round-trip per page load — the catalog won't shift in V2.
917
-
918
-
919
- // task 2174: OTBKit pager. 25/page is what task 1750 set for the top-100 Roll;
920
- // the per-page select now lets a builder widen it and the choice persists.
921
-
922
- // Stable sort. Server returns rows in drachmae-DESC order, which is also the
923
- // default view; for other modes (rank, karma, drachmae-ASC), re-sort here.
924
- // The original server order is preserved as a tie-breaker so equal-value rows
925
- // don't jitter between renders. Drachmae is the secondary tie-breaker when
926
- // sorting by karma (V3.R26 #246) so the karma toggle still reads sensibly
927
- // when many builders share the same karma value (e.g. zero).
928
-
929
- // Map a sortable column id → the lbState.sortBy key. Adding a new sortable
930
- // column means adding an entry here + a header in hall-widgets.js with id
931
- // `lb-sort-<key>`. The handler walks aria-sort across all sortable headers so
932
- // only one column shows its arrow at a time.
913
+ // ---- the Roll’s rows, for the Standing card -----------------------------
914
+ //
915
+ // task 1003441 moved the leaderboard TABLE to its own page (/roster,
916
+ // roster.js): the pager, the sort lenses, LB_SORT_HEADERS and the achievement
917
+ // laurels went with it. Only the place lookup below stays — it needs the same
918
+ // rows, and the Standing card that shows it is on THIS page.
933
919
 
934
920
  // #757: this builder's place on the Roll, ranked by drachmae (total_credits)
935
921
  // descending — the canonical leaderboard order, independent of whatever column
@@ -939,28 +925,6 @@
939
925
  // builder isn't on the roll). Matched by github_login (rows carry no id).
940
926
 
941
927
 
942
- // Cycle: pick column → desc → asc → back to default (drachmae desc).
943
- // For a click on the active column we just flip direction; for a click on
944
- // a different column we activate it at 'desc' (the natural "best first"
945
- // direction for all three columns). Clicking the same column a third time
946
- // returns to the default drachmae-desc — discoverable, no dead state.
947
- function onLbSortClick(key) {
948
- if (lbState.sortBy === key) {
949
- if (lbState.sortDir === 'desc') {
950
- lbState.sortDir = 'asc';
951
- } else {
952
- // Third click returns to the default view.
953
- lbState.sortBy = 'drachmae';
954
- lbState.sortDir = 'desc';
955
- }
956
- } else {
957
- lbState.sortBy = key;
958
- lbState.sortDir = 'desc';
959
- }
960
- lbPager.page = 1;
961
- renderLeaderboard();
962
- }
963
-
964
928
  // ---- onboarding live-tick poll (task 1444) -------------------------------
965
929
  //
966
930
  // The onboarding checklist is rendered once from the page-load /me snapshot.
@@ -1160,14 +1124,15 @@
1160
1124
 
1161
1125
  if (lb.status === 'fulfilled') {
1162
1126
  lbState.rows = lb.value.leaderboard || [];
1163
- lbPager.page = 1;
1164
- renderLeaderboard();
1165
1127
  // #757: the Standing card painted before the Roll loaded, so re-render it
1166
1128
  // now that lbState.rows exists — this is when its "#N on the Roll" stat
1167
1129
  // can resolve.
1168
1130
  if (lastMeState && lastMeState.builder) renderProfile(lastMeState.builder);
1169
1131
  } else {
1170
- $('#leaderboard tbody').innerHTML = emptyRow(5, 'The leaderboard could not be loaded.');
1132
+ // The Roll’s rows only feed the Standing card’s "#N" stat now — the table
1133
+ // itself is /roster (task 1003441) — so a failed fetch degrades that one
1134
+ // stat rather than blanking a panel this page no longer has.
1135
+ lbState.rows = [];
1171
1136
  }
1172
1137
  }
1173
1138
 
@@ -1241,25 +1206,9 @@
1241
1206
  // task 2174: the kit wires prev/next/per-page for both tables against the
1242
1207
  // #ships-*/#lb-* ids its own pagerBarHtml() stamped in hall-widgets.js.
1243
1208
  window.OTBKit.wirePager(shipsPager, renderRecentShips);
1244
- window.OTBKit.wirePager(lbPager, renderLeaderboard);
1209
+
1245
1210
  window.OTBKit.wirePager(acclaimPager, renderAcclaimRows);
1246
1211
 
1247
- // Sortable leaderboard headers — clickable + keyboard-operable. Each
1248
- // sortable column registers under LB_SORT_HEADERS; the click handler
1249
- // routes the column key into the shared cycle.
1250
- for (const [key, headerId] of Object.entries(LB_SORT_HEADERS)) {
1251
- const head = document.getElementById(headerId);
1252
- if (!head) continue;
1253
- head.setAttribute('tabindex', '0');
1254
- head.setAttribute('role', 'button');
1255
- head.addEventListener('click', () => onLbSortClick(key));
1256
- head.addEventListener('keydown', (e) => {
1257
- if (e.key === 'Enter' || e.key === ' ') {
1258
- e.preventDefault();
1259
- onLbSortClick(key);
1260
- }
1261
- });
1262
- }
1263
1212
 
1264
1213
  // Sign-out is wired centrally in shell.js (doLogout + a delegated click
1265
1214
  // listener on the sign-out anchors), so it runs the real POST -> cookie-clear
@@ -1365,7 +1314,7 @@
1365
1314
  // reads what this page holds NOW rather than what it held at boot. Everything
1366
1315
  // else is stable for the life of the page. This call sits AFTER every
1367
1316
  // declaration it passes — moving it earlier is a temporal-dead-zone crash.
1368
- const { LB_SORT_HEADERS, SETUP_CONFIG_KEY, analyticsState, lbPager, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderLeaderboard, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
1317
+ const { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
1369
1318
  API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd,
1370
1319
  get instanceRepo() { return instanceRepo; },
1371
1320
  get lastCostState() { return lastCostState; },
@@ -153,7 +153,8 @@
153
153
  // third-party widget can't blank the hall (the structural id/label/mount
154
154
  // contract below still throws — that one is first-party programmer error).
155
155
  const HALL_ZONES = ['home-banner', 'home-stats', 'home-band', 'home-main'];
156
- const HALL_VIEWS = ['home', 'standing', 'leaderboard'];
156
+ // task 1003441: 'leaderboard' left — it is a page (/roster) now, not a view.
157
+ const HALL_VIEWS = ['home', 'standing'];
157
158
 
158
159
  const _hallWidgets = [];
159
160
  function contributeHallWidget(widget) {
@@ -9,18 +9,17 @@
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
11
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
- <!-- task 1003307: the reading-room archetype's own sheet. drachmae reuses the
13
- primer's layout and loaded primer.css for it, but never room.css — so it
14
- had the room's DOM and none of the room's world: no reading measure, no
15
- prose-link 24px floor, no table wrapper. Loading it is the fix; the order
16
- matches primer.html (room first, the page's own sheet on top). -->
12
+ <!-- The reading-room archetype is the whole of this page's sheet. primer.css
13
+ holds only the acknowledge gate, which a reference has no use for, so it
14
+ is no longer loaded here (task 1003473; it had been since task 1003307). -->
17
15
  <link rel="stylesheet" href="/builders/room.css">
18
- <link rel="stylesheet" href="/builders/primer.css?v=2026-07-02-shell1">
19
16
  </head>
20
- <body class="room primer-body" data-page="drachmae" data-page-title="Drachmae &amp; karma">
21
-
22
- <!-- Reading-progress bar — fills as the reader scrolls through the article. -->
23
- <div class="primer-progress" aria-hidden="true"><div class="primer-progress__bar" id="primer-progress-bar"></div></div>
17
+ <!-- `room` opts into the archetype's composition tokens (room.css); they sit on
18
+ the body, never in a page :root. `room--reference` is what the room owes a
19
+ document people SCAN rather than read (task 1003473): the outline goes to
20
+ h3 depth, every heading carries its own link, and the linear-read
21
+ instruments (the progress hairline, the section counters) are off. -->
22
+ <body class="room room--reference" data-page="drachmae" data-page-title="Drachmae &amp; karma">
24
23
 
25
24
  <div class="app">
26
25
  <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
@@ -29,59 +28,66 @@
29
28
  <header class="app-topbar" id="app-topbar"></header>
30
29
  <div class="app-content">
31
30
 
32
- <!-- task 1003307: the reading-room head. drachmae shares the primer's
33
- .primer-layout and is read as a document, so it takes .page-head--room
34
- (task 1003306's archetype) rather than the board's label voice — and
35
- it gets the <h1> it never had. -->
31
+ <!-- The reading room announces its document (task 1003306's archetype):
32
+ the label voice on a one-word eyebrow, then the room's name lowercase
33
+ in the display face. The sub line says what KIND of document this is,
34
+ not how long it takes: nobody reads a reference to the end. -->
36
35
  <div class="page-head page-head--room">
37
36
  <p class="page-head__eyebrow">Docs</p>
38
37
  <h1 class="page-head__h">Drachmae &amp; karma</h1>
39
- <p class="page-head__sub">How you get paid, and how you earn trust — the reward model in plain language. About a five-minute read.</p>
38
+ <p class="page-head__sub">How you get paid, and how you earn trust. A reference to jump around in: every section has its own link, and the bounty rates are read live.</p>
40
39
  </div>
41
40
 
42
41
  <main id="hall-main">
43
42
  <div class="primer-layout">
44
43
 
45
- <!-- Sticky left-rail table of contents, built from the article's h2s. -->
46
- <aside class="primer-toc" id="primer-toc" aria-label="Sections in this guide">
44
+ <!-- The outline. Built from the article's h2s AND h3s once the
45
+ markdown renders: a reference is found by its sub-questions, not
46
+ read in order, so the contents go a level deeper than the
47
+ primer's and carry no numerals. Under 760px it collapses behind a
48
+ toggle whose middle names the part the reader is in. -->
49
+ <aside class="primer-toc" id="primer-toc" aria-label="Sections in this reference">
47
50
  <button type="button" class="primer-toc__toggle" id="primer-toc-toggle" aria-expanded="false" aria-controls="primer-toc-list">
48
- <span>Sections</span>
49
- <span class="primer-toc__chevron" aria-hidden="true">▾</span>
51
+ <span class="primer-toc__toggle-label">On this page</span>
52
+ <span class="primer-toc__current" id="primer-toc-current"></span>
53
+ <svg class="primer-toc__chevron" viewBox="0 0 10 6" width="10" height="6" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 1.2 5 4.8 9 1.2"/></svg>
50
54
  </button>
51
55
  <nav class="primer-toc__inner">
52
- <p class="primer-toc__heading" id="primer-toc-heading">In this guide</p>
56
+ <p class="primer-toc__heading" id="primer-toc-heading">On this page</p>
53
57
  <ol class="primer-toc__list" id="primer-toc-list" aria-labelledby="primer-toc-heading">
54
58
  <li class="primer-toc__placeholder">Loading…</li>
55
59
  </ol>
56
60
  </nav>
57
61
  </aside>
58
62
 
59
- <!-- Article column — markdown rendered here. -->
60
- <!-- The room's loading shape: skeleton lines in the article's own
61
- measure, not a sentence. (.primer-loading, which used to sit in the
62
- article, is defined in no stylesheet and rendered unstyled.) -->
63
- <div class="primer-skeleton scroll" id="primer-skeleton" aria-hidden="true">
64
- <span class="primer-sk primer-skeleton__h"></span>
65
- <div class="primer-skeleton__p">
66
- <span class="primer-sk"></span>
67
- <span class="primer-sk"></span>
68
- <span class="primer-sk"></span>
69
- </div>
70
- <div class="primer-skeleton__rule"></div>
71
- <div class="primer-skeleton__p">
72
- <span class="primer-sk"></span>
73
- <span class="primer-sk"></span>
63
+ <!-- The article column: three panels of the same card at the same
64
+ width (skeleton, document, failure), so the column never changes
65
+ shape as the markdown resolves. -->
66
+ <div class="primer-col">
67
+
68
+ <div class="primer-skeleton scroll" id="primer-skeleton" aria-hidden="true">
69
+ <span class="primer-sk primer-skeleton__h"></span>
70
+ <div class="primer-skeleton__p">
71
+ <span class="primer-sk"></span><span class="primer-sk"></span>
72
+ <span class="primer-sk"></span><span class="primer-sk"></span>
73
+ <span class="primer-sk"></span>
74
+ </div>
75
+ <div class="primer-skeleton__rule"></div>
76
+ <div class="primer-skeleton__p">
77
+ <span class="primer-sk"></span><span class="primer-sk"></span>
78
+ <span class="primer-sk"></span>
79
+ </div>
74
80
  </div>
75
- </div>
76
81
 
77
- <article class="primer-article scroll" id="primer-article" aria-busy="true" hidden></article>
82
+ <article class="primer-article scroll" id="primer-article" aria-busy="true" hidden></article>
78
83
 
79
- <div class="primer-note primer-note--error scroll" id="primer-error" hidden>
80
- <p class="primer-note__label">The guide did not load</p>
81
- <p class="primer-note__body">The markdown behind this page could not be fetched. Reload to try again, or read it in the repo.</p>
82
- <p class="primer-note__acts"><a class="btn btn--ghost" href="/builders">Back to home</a></p>
83
- </div>
84
+ <div class="primer-note primer-note--error scroll" id="primer-error" hidden>
85
+ <p class="primer-note__label">The reference did not load</p>
86
+ <p class="primer-note__body">The markdown behind this page could not be fetched. Reload to try again, or read it in the repo.</p>
87
+ <p class="primer-note__acts"><a class="btn btn--ghost" href="/builders">Back to home</a></p>
88
+ </div>
84
89
 
90
+ </div>
85
91
  </div>
86
92
  </main>
87
93
 
@@ -95,8 +101,8 @@
95
101
  <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
96
102
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
97
103
  <script src="/builders/palette.js" defer></script>
98
- <script src="/builders/md-reader.js?v=2026-07-05-mdreader-1"></script>
99
- <script src="/builders/drachmae.js?v=2026-07-05-drachmae-1" defer></script>
104
+ <script src="/builders/md-reader.js?v=2026-09-02-mdreader-2"></script>
105
+ <script src="/builders/drachmae.js?v=2026-09-02-drachmae-2" defer></script>
100
106
  <script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
101
107
  </body>
102
108
  </html>
@@ -1,18 +1,28 @@
1
- // drachmae.js — the in-hall reader for the "Drachmae & karma" onboarding doc
2
- // (task 1472). Fetches the canonical markdown at /builders/drachmae-and-karma.md
3
- // (mounted from docs/onboarding/drachmae-and-karma.md — single source of truth,
4
- // also the one the primer links to) and renders it with the shared OTBMdReader,
5
- // so builders can read exactly how they get paid without leaving the hall for
6
- // GitHub. Reuses the primer's layout/TOC/progress DOM + primer.css; no ack gate
7
- // (this is reference reading, not an onboarding checkpoint).
1
+ // drachmae.js — the in-hall reader for the "Drachmae & karma" doc (task 1472),
2
+ // rebuilt as the hall's first REFERENCE room (task 1003473, goal 1000081).
3
+ // Fetches the canonical markdown at /builders/drachmae-and-karma.md (mounted
4
+ // from docs/onboarding/drachmae-and-karma.md — single source of truth, also the
5
+ // one the primer links to) and renders it through the shared OTBMdReader, so
6
+ // builders can read exactly how they get paid without leaving the hall.
8
7
  //
9
- // task 2174: the red-team bounty rate card moved here off Standing. It is a
10
- // published reward SCHEDULE — reference material that reads the same for every
11
- // builder — so it belongs with the rest of the reward model, not in a personal
12
- // standing view. The doc's §"Red-team / security bounty" describes it in prose;
13
- // the LIVE numbers come from /public/bounty-table and are appended as a real
14
- // section (h2 + table) so the TOC, scrollspy and progress bar pick it up like
15
- // any other section of the article.
8
+ // WHAT A REFERENCE OWES THAT THE PRIMER DOES NOT. People arrive at this page to
9
+ // answer one question about how they get paid, so it is scanned and deep-linked
10
+ // rather than read start to finish. Three things follow, and they are the whole
11
+ // difference between this file and primer.js (the room itself — the layout, the
12
+ // glass contents, the card — is room.css's, shared):
13
+ // * the outline goes to h3 depth and the scrollspy lights the section AND
14
+ // its part (the trail), with the phone toggle naming where the reader is;
15
+ // * every h2/h3/h4 carries its own link (.room-anchor), which copies the
16
+ // address on click, and a deep link lands after the async render;
17
+ // * there is no progress hairline and no acknowledge gate: a reference has
18
+ // no end to reach.
19
+ //
20
+ // task 2174: the red-team bounty rate card lives here. It is a published reward
21
+ // SCHEDULE — reference material that reads the same for every builder — so it
22
+ // belongs with the rest of the reward model. The doc's §"Red-team / security
23
+ // bounty" describes it in prose; the LIVE numbers come from /public/bounty-table
24
+ // and are inserted as a real section (h2 + table) ahead of the closing links,
25
+ // so the outline and the scrollspy pick it up like any other section.
16
26
 
17
27
  (() => {
18
28
  'use strict';
@@ -23,12 +33,31 @@
23
33
  const BOUNTY_URL = '/api/gds/public/bounty-table';
24
34
  const BOUNTY_SECTION_ID = 'security-bounty-rates';
25
35
  const BOUNTY_SECTION_TITLE = 'Security bounty rates';
36
+ // The section the live table is inserted AHEAD of: the doc's closing links.
37
+ // Matched by its opening words, so a retitled footer falls back to appending.
38
+ const CLOSING_H2 = /^see it/i;
39
+ // The y (viewport space) treated as "where the reader is looking": below the
40
+ // sticky top bar, where the eye lands reading down a page.
41
+ const READING_LINE_Y = 100;
42
+ const LINK_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.6 9.4a2.7 2.7 0 0 0 3.8 0l2.3-2.3a2.7 2.7 0 0 0-3.8-3.8L7.7 4.5"/><path d="M9.4 6.6a2.7 2.7 0 0 0-3.8 0L3.3 8.9a2.7 2.7 0 0 0 3.8 3.8l1.2-1.2"/></svg>';
26
43
 
27
44
  let repoBlobBase = null;
28
45
  let repoTreeBase = null;
29
46
 
30
47
  function $(sel) { return document.querySelector(sel); }
31
48
 
49
+ // The shell's toast (style.css .toast[data-visible="1"]), the same contract
50
+ // primer.js and copy-on-click.js speak; this page loads no dom-utils.js.
51
+ function toast(msg) {
52
+ const el = $('#toast');
53
+ if (!el) return;
54
+ el.textContent = msg;
55
+ el.dataset.type = 'info';
56
+ el.dataset.visible = '1';
57
+ clearTimeout(toast._t);
58
+ toast._t = setTimeout(() => { el.dataset.visible = '0'; }, 2400);
59
+ }
60
+
32
61
  async function loadRepoInfoBases() {
33
62
  try {
34
63
  const r = await fetch('/api/gds/public/repo-info', { credentials: 'same-origin' });
@@ -39,10 +68,11 @@
39
68
  } catch (_) { /* links degrade to the original href */ }
40
69
  }
41
70
 
42
- // Append the live bounty rate card to the article and return its TOC heading,
43
- // or null when the schedule can't be read (the prose section stays; we just
44
- // don't show an empty table or a TOC entry that leads nowhere).
45
- async function appendBountySection(article) {
71
+ // The live rate card as a real section of the article, placed ahead of the
72
+ // closing links so the doc still ends on "dig deeper". Returns false when the
73
+ // schedule can't be read: the prose section stays, and no empty table or
74
+ // outline entry that leads nowhere is rendered.
75
+ async function insertBountySection(article) {
46
76
  let payload;
47
77
  try {
48
78
  const r = await fetch(BOUNTY_URL, { credentials: 'same-origin' });
@@ -50,19 +80,19 @@
50
80
  payload = await r.json();
51
81
  } catch (err) {
52
82
  console.warn('[drachmae] bounty schedule unavailable:', err.message);
53
- return null;
83
+ return false;
54
84
  }
55
85
  const rows = Array.isArray(payload && payload.table) ? payload.table : [];
56
- if (!rows.length) return null;
86
+ if (!rows.length) return false;
57
87
 
58
88
  const esc = window.OTBMdReader.escapeHtml;
59
89
  const currency = payload.currency || 'credits';
60
90
  const section = document.createElement('section');
61
91
  section.innerHTML =
62
92
  `<h2 id="${esc(BOUNTY_SECTION_ID)}">${esc(BOUNTY_SECTION_TITLE)}</h2>` +
63
- `<p>Find a flaw in the system’s own walls and report it — rewards are paid in ` +
93
+ `<p>Find a flaw in the system’s own walls and report it: rewards are paid in ` +
64
94
  `${esc(currency)} by severity. These are the live numbers, read from the server ` +
65
- `just now, so a tuned rate shows up here immediately.</p>` +
95
+ `when you opened this page, so a tuned rate shows up here immediately.</p>` +
66
96
  '<table><thead><tr><th>Severity</th>' +
67
97
  `<th>${esc(currency.charAt(0).toUpperCase() + currency.slice(1))}</th>` +
68
98
  '<th>What counts</th></tr></thead><tbody>' +
@@ -74,58 +104,117 @@
74
104
  `<td>${esc(r.blurb || '')}</td></tr>`;
75
105
  }).join('') +
76
106
  '</tbody></table>';
77
- article.appendChild(section);
78
- return { level: 2, id: BOUNTY_SECTION_ID, text: BOUNTY_SECTION_TITLE };
107
+ const closing = [...article.querySelectorAll('h2')].find((h) => CLOSING_H2.test(h.textContent.trim()));
108
+ if (closing) article.insertBefore(section, closing); else article.appendChild(section);
109
+ return true;
110
+ }
111
+
112
+ // The outline is read from the RENDERED headings rather than the parser's raw
113
+ // text, so `*not*` and a [link](…) in a heading arrive as plain words, and so
114
+ // the live section inserted above is simply one more heading in order.
115
+ function collectHeadings(article) {
116
+ return [...article.querySelectorAll('h2[id], h3[id]')].map((el) => ({
117
+ level: Number(el.tagName.charAt(1)), id: el.id, text: el.textContent.trim(),
118
+ }));
79
119
  }
80
120
 
121
+ // h2s as the list, each h3 nested under its part; no numerals (room.css turns
122
+ // the room's counter off for a reference). Returns the flat list in document
123
+ // order, each entry carrying its part's id and title, which is what the
124
+ // scrollspy walks and what the phone toggle names.
81
125
  function renderToc(headings) {
82
126
  const list = $('#primer-toc-list');
83
127
  if (!list) return [];
84
- const h2s = headings.filter((h) => h.level === 2);
85
- if (h2s.length === 0) { list.innerHTML = '<li class="primer-toc__placeholder">(no sections)</li>'; return []; }
128
+ if (headings.length === 0) { list.innerHTML = '<li class="primer-toc__placeholder">(no sections)</li>'; return []; }
86
129
  const esc = window.OTBMdReader.escapeHtml;
87
- list.innerHTML = h2s.map((h) =>
88
- `<li><a href="#${esc(h.id)}" data-section="${esc(h.id)}">${esc(h.text)}</a></li>`
130
+ const link = (h) => `<a href="#${esc(h.id)}" data-section="${esc(h.id)}">${esc(h.text)}</a>`;
131
+ const parts = [];
132
+ for (const h of headings) {
133
+ const last = parts[parts.length - 1];
134
+ if (h.level === 3 && last) last.subs.push(h);
135
+ else parts.push({ head: h, subs: [] });
136
+ }
137
+ list.innerHTML = parts.map((p) =>
138
+ `<li>${link(p.head)}${p.subs.length
139
+ ? `<ol class="primer-toc__sub">${p.subs.map((s) => `<li>${link(s)}</li>`).join('')}</ol>`
140
+ : ''}</li>`
89
141
  ).join('');
90
- return h2s;
142
+ const flat = [];
143
+ for (const p of parts) {
144
+ flat.push({ id: p.head.id, parent: null, part: p.head.text });
145
+ for (const s of p.subs) flat.push({ id: s.id, parent: p.head.id, part: p.head.text });
146
+ }
147
+ return flat;
91
148
  }
92
149
 
93
- function setupScrollspy(tocSections) {
94
- if (tocSections.length === 0) return;
150
+ // The active entry is the LAST heading whose top has scrolled past the
151
+ // reading line, so it stays put through a section's paragraphs. Its part, when
152
+ // the active entry is an h3, is the trail; the toggle's middle names the part.
153
+ function setupScrollspy(sections) {
154
+ if (sections.length === 0) return;
95
155
  const links = new Map();
96
156
  for (const a of document.querySelectorAll('.primer-toc__list a')) links.set(a.dataset.section, a);
97
157
  if (links.size === 0) return;
98
- const READING_LINE_Y = 100;
158
+ const current = $('#primer-toc-current');
99
159
  let last = null;
100
160
  function update() {
101
161
  let active = null;
102
- for (const s of tocSections) {
162
+ for (const s of sections) {
103
163
  const el = document.getElementById(s.id);
104
164
  if (!el) continue;
105
- if (el.getBoundingClientRect().top <= READING_LINE_Y) active = s.id; else break;
165
+ if (el.getBoundingClientRect().top <= READING_LINE_Y) active = s; else break;
166
+ }
167
+ const id = active ? active.id : null;
168
+ if (id === last) return;
169
+ last = id;
170
+ const trail = active && active.parent;
171
+ for (const [sid, a] of links) {
172
+ a.classList.toggle('is-active', sid === id);
173
+ a.classList.toggle('is-trail', !!trail && sid === trail);
106
174
  }
107
- if (active === last) return;
108
- last = active;
109
- for (const [id, a] of links) a.classList.toggle('is-active', id === active);
175
+ if (current) current.textContent = active ? active.part : '';
110
176
  }
111
177
  window.addEventListener('scroll', update, { passive: true });
112
178
  window.addEventListener('resize', update, { passive: true });
113
179
  update();
114
180
  }
115
181
 
116
- function setupProgressBar(articleEl) {
117
- const bar = $('#primer-progress-bar');
118
- if (!bar) return;
119
- function update() {
120
- const rect = articleEl.getBoundingClientRect();
121
- const viewport = window.innerHeight || document.documentElement.clientHeight;
122
- const scrolled = Math.max(0, -rect.top);
123
- const frac = rect.height > 0 ? Math.min(1, scrolled / Math.max(1, rect.height - viewport)) : 0;
124
- bar.style.width = `${Math.round(frac * 100)}%`;
182
+ // Every heading is an address. The link is the heading's own id, so it works
183
+ // with the browser's hash navigation and no script; the click additionally
184
+ // copies the absolute address, so a reader can hand a colleague the exact
185
+ // answer, which is the reason a reference is deep-linked at all.
186
+ function addAnchors(article) {
187
+ for (const h of article.querySelectorAll('h2[id], h3[id], h4[id]')) {
188
+ const a = document.createElement('a');
189
+ a.className = 'room-anchor';
190
+ a.href = `#${h.id}`;
191
+ a.setAttribute('aria-label', `Link to this section: ${h.textContent.trim()}`);
192
+ a.innerHTML = LINK_ICON;
193
+ h.appendChild(a);
125
194
  }
126
- window.addEventListener('scroll', update, { passive: true });
127
- window.addEventListener('resize', update, { passive: true });
128
- update();
195
+ article.addEventListener('click', (e) => {
196
+ const a = e.target.closest('a.room-anchor');
197
+ if (!a) return;
198
+ if (!(navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext)) return;
199
+ const url = new URL(a.getAttribute('href'), window.location.href).href;
200
+ navigator.clipboard.writeText(url).then(() => toast('Link copied.'), () => { /* the hash still navigated */ });
201
+ });
202
+ }
203
+
204
+ // A deep link arrives before the article exists, so the browser's own jump
205
+ // finds nothing and its :target was resolved against nothing. Once the
206
+ // document and the live section are in the DOM the target is scrolled into
207
+ // view (scroll-margin-top clears the top bar) and marked (.is-target) until
208
+ // the next hash change hands the mark back to :target.
209
+ function landOnHash() {
210
+ let id = '';
211
+ // a malformed percent-escape in the fragment throws; there is nothing to land on
212
+ try { id = decodeURIComponent(window.location.hash.slice(1)); } catch (_) { return; }
213
+ const el = id && document.getElementById(id);
214
+ if (!el) return;
215
+ el.classList.add('is-target');
216
+ el.scrollIntoView({ block: 'start' });
217
+ window.addEventListener('hashchange', () => el.classList.remove('is-target'), { once: true });
129
218
  }
130
219
 
131
220
  function setupTocToggle() {
@@ -146,8 +235,7 @@
146
235
  });
147
236
  }
148
237
 
149
- // The three panels the article can resolve into (the primer's show(), which
150
- // this page's DOM has mirrored since task 1472 and now finally matches).
238
+ // The three panels the article can resolve into (the primer's show()).
151
239
  function show(which) {
152
240
  for (const [id, on] of [['#primer-skeleton', which === 'loading'],
153
241
  ['#primer-article', which === 'article'],
@@ -185,18 +273,18 @@
185
273
  // The doc's leading "# …" is the same page name .page-head--room already
186
274
  // renders. Two <h1>s fails one-header-main-h1, and the page head is the one
187
275
  // that exists before the fetch and survives its failure (task 1003307).
188
- const html = parsed.html.replace(/^\s*<h1\b[^>]*>[\s\S]*?<\/h1>\s*/, '');
189
- const headings = parsed.headings.filter((h) => h.level > 1);
190
- article.innerHTML = html;
276
+ article.innerHTML = parsed.html.replace(/^\s*<h1\b[^>]*>[\s\S]*?<\/h1>\s*/, '');
191
277
  article.removeAttribute('aria-busy');
192
278
  show('article');
193
- // Awaited before the TOC is built so the rate card gets a TOC entry and a
279
+ // Awaited before the outline is built so the rate card gets its entry and a
194
280
  // scrollspy target; the article itself is already on screen by now.
195
- const bountyHeading = await appendBountySection(article);
196
- const tocSections = renderToc(bountyHeading ? [...headings, bountyHeading] : headings);
281
+ await insertBountySection(article);
282
+ addAnchors(article);
197
283
  setupTocToggle();
198
- setupScrollspy(tocSections);
199
- setupProgressBar(article);
284
+ setupScrollspy(renderToc(collectHeadings(article)));
285
+ // After the fonts: a swap after the jump moves everything above the target
286
+ // (measured: 300px short on a phone, past it on a desktop).
287
+ (document.fonts ? document.fonts.ready : Promise.resolve()).then(landOnHash);
200
288
  }
201
289
 
202
290
  if (document.readyState === 'loading') {