@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.
@@ -1,12 +1,12 @@
1
1
  {
2
- "_": "Drachmae & karma — a READING ROOM, not a board: it shares the primer .primer-layout and takes .page-head--room. Public (no page gate), so the signed-out render is the one most readers get. Written with the re-skin (task 1003307) so this page is MEASURED at three widths in both modes rather than looked at once.",
2
+ "_": "Drachmae & karma — the hall's first REFERENCE room (task 1003473): the reading-room archetype (room.css) with the reference rules on (`room--reference`). Public (no page gate), so the signed-out render is the one most readers get. Measured at three widths in both modes rather than looked at once.",
3
3
  "page": "/builders/drachmae",
4
4
  "surface": "hall-ui",
5
5
  "stub": {
6
6
  "prefix": "/builders/"
7
7
  },
8
8
  "modeQuery": false,
9
- "_feeds": "The kit's own stub carries no feed for this page, so against it the page renders its EMPTY and SKELETON states — which is the point: those are states the floors must hold in. Against the hall-preview harness the same states render populated. Every declared 404 below is a fixture the harness does not carry, named rather than hidden.",
9
+ "_feeds": "The kit's own stub serves neither the markdown nor the bounty feed, so against it the page renders its SKELETON and then its FAILURE note — states the floors must hold in. The POPULATED document, the outline, the anchors and the live table need the hall-preview harness (node scripts/hall-preview/server.js --port 4620 --fixture-me, then this file with --base-in/--base-out pointed at it): it serves docs/onboarding/drachmae-and-karma.md and the checked-in public__bounty-table.json. Every declared 404 below is a fixture the harness does not carry, named rather than hidden.",
10
10
  "ignoreRequests": [
11
11
  "/api/(gds|bongos)/me$",
12
12
  "/api/(gds|bongos)/me/",
@@ -26,39 +26,33 @@
26
26
  "Not Implemented",
27
27
  "load failed"
28
28
  ],
29
+ "_expect": "read pins the article, so this file is run against the harness (the kit stub can only ever show the failure note). jump is the deep link: the browser's own hash jump finds nothing on a page whose document arrives after load, so drachmae.js lands it, and the outline's lit entry with its trail, plus the phone toggle naming the part, are what the shot proves.",
29
30
  "states": {
30
31
  "read": {
31
32
  "auth": true,
32
- "actions": [
33
- [
34
- "wait",
35
- 600
36
- ]
37
- ],
38
- "expect": {
39
- "visible": [
40
- "#primer-article",
41
- "#primer-toc"
42
- ]
43
- }
33
+ "actions": [["wait", 600]],
34
+ "expect": { "visible": ["#primer-article", "#primer-toc"] }
35
+ },
36
+ "toc-open": {
37
+ "auth": true,
38
+ "actions": [["clickIfVisible", "#primer-toc-toggle"], ["wait", 250]],
39
+ "_": "the room's one disclosure. Below 760px the contents collapse behind this toggle; above it the toggle is not rendered at all, so clickIfVisible is the right verb and the state is a no-op at 1440.",
40
+ "expect": { "visible": ["#primer-toc"] }
41
+ },
42
+ "jump": {
43
+ "auth": true,
44
+ "url": "/builders/drachmae#how-karma-is-earned-and-lost",
45
+ "actions": [["wait", 700]],
46
+ "_": "a deep link to an h3 under Part 3. At 1440 the outline shows the entry lit and its part in the trail; at 390 the collapsed toggle reads the part's name.",
47
+ "expect": { "visible": ["#primer-article", "#primer-toc"] }
44
48
  },
45
49
  "out": {
46
50
  "auth": false,
47
- "actions": [
48
- [
49
- "wait",
50
- 600
51
- ]
52
- ]
51
+ "actions": [["wait", 600]]
53
52
  }
54
53
  },
55
54
  "reducedMotion": {
56
55
  "auth": true,
57
- "actions": [
58
- [
59
- "wait",
60
- 600
61
- ]
62
- ]
56
+ "actions": [["wait", 600]]
63
57
  }
64
58
  }
@@ -3,7 +3,7 @@
3
3
  // Carved out of builders.js by task 1003206 (goal 1000079, the R03 de-monolith
4
4
  // plan): that file was 2,558 lines, the largest first-party file left over the
5
5
  // 1,500-line ratchet. This holds the view painters — landing, profile, welcome,
6
- // onboarding, recent ships, the AI-dev curve, the leaderboard, the nav and hall
6
+ // onboarding, recent ships, the AI-dev curve, the nav and hall
7
7
  // widgets — plus the helpers and view state only they touch.
8
8
  //
9
9
  // WHY A FACTORY, and not a flat namespace like goals-lib.js. goals-lib.js can be
@@ -85,10 +85,13 @@
85
85
  `</span>` +
86
86
  `</span>`;
87
87
  }
88
+ // Mirrors dom-utils.js HALL_VIEWS. task 1003441 dropped the `leaderboard`
89
+ // key with the view: no widget can declare view:’leaderboard’ any more, so
90
+ // widgetHost() could never have read it, and index.html no longer carries the
91
+ // #zone-leaderboard mount it named.
88
92
  const VIEW_CONTAINERS = {
89
93
  home: 'zone-home-main',
90
94
  standing: 'zone-standing',
91
- leaderboard: 'zone-leaderboard',
92
95
  };
93
96
  const STANDING_GROUPS = [
94
97
  { key: 'work', label: 'Work' },
@@ -1099,99 +1102,10 @@
1099
1102
  kindsEl.innerHTML = '';
1100
1103
  }
1101
1104
  }
1102
- const ACH_EMOJI = {
1103
- 'first-ship': '🚢',
1104
- 'first-claim': '🪙',
1105
- 'first-parallel-claim': '⚓',
1106
- 'streak-3': '🔥',
1107
- 'streak-7': '🌋',
1108
- 'art-pipeline-shipper': '🎨',
1109
- 'gds-shipper': '⚙️',
1110
- 'breaking-the-100c-bar': '💯',
1111
- };
1112
- function renderAchievementRow(ids) {
1113
- if (!Array.isArray(ids) || ids.length === 0) return '';
1114
- const inner = ids
1115
- .map((id) => {
1116
- const emoji = ACH_EMOJI[id];
1117
- if (!emoji) return '';
1118
- // Title attribute is the achievement id (kebab) — terse enough that hover
1119
- // discloses the laurel without overstating the prose.
1120
- return `<span class="lb-ach" title="${escapeHtml(id)}" aria-label="${escapeHtml(id)}">${emoji}</span>`;
1121
- })
1122
- .join('');
1123
- return inner ? `<span class="lb-ach-row" aria-label="achievements">${inner}</span>` : '';
1124
- }
1125
- const lbState = {
1126
- rows: [],
1127
- sortBy: 'drachmae', // 'rank' | 'drachmae' | 'karma' | 'tokens'
1128
- sortDir: 'desc', // 'desc' | 'asc'
1129
- };
1130
- const lbPager = window.OTBKit.makePager('lb', 'otb.standing.leaderboardPerPage', { defaultPerPage: 25 });
1131
- function sortedLeaderboardRows() {
1132
- const indexed = lbState.rows.map((row, idx) => ({ row, idx }));
1133
- const dir = lbState.sortDir === 'asc' ? 1 : -1;
1134
- if (lbState.sortBy === 'rank') {
1135
- return indexed
1136
- .sort((a, b) => {
1137
- const diff = (rankWeight(a.row.rank) - rankWeight(b.row.rank)) * dir;
1138
- return diff !== 0 ? diff : a.idx - b.idx;
1139
- })
1140
- .map(({ row }) => row);
1141
- }
1142
- if (lbState.sortBy === 'karma') {
1143
- return indexed
1144
- .sort((a, b) => {
1145
- const ak = Number(a.row.karma) || 0;
1146
- const bk = Number(b.row.karma) || 0;
1147
- const diff = (ak - bk) * dir;
1148
- if (diff !== 0) return diff;
1149
- // Tie-break on drachmae DESC, then stable on server order.
1150
- const ac = Number(a.row.total_credits) || 0;
1151
- const bc = Number(b.row.total_credits) || 0;
1152
- if (ac !== bc) return bc - ac;
1153
- return a.idx - b.idx;
1154
- })
1155
- .map(({ row }) => row);
1156
- }
1157
- if (lbState.sortBy === 'tokens') {
1158
- // task 1473: sort by total tokens used. tokens_used arrives as a string
1159
- // (pg bigint), so coerce. Tie-break on drachmae DESC then server order,
1160
- // matching the karma branch.
1161
- return indexed
1162
- .sort((a, b) => {
1163
- const at = Number(a.row.tokens_used) || 0;
1164
- const bt = Number(b.row.tokens_used) || 0;
1165
- const diff = (at - bt) * dir;
1166
- if (diff !== 0) return diff;
1167
- const ac = Number(a.row.total_credits) || 0;
1168
- const bc = Number(b.row.total_credits) || 0;
1169
- if (ac !== bc) return bc - ac;
1170
- return a.idx - b.idx;
1171
- })
1172
- .map(({ row }) => row);
1173
- }
1174
- if (lbState.sortBy === 'drachmae') {
1175
- // Server already DESCs by drachmae, so the default mode is a no-op
1176
- // copy. The ASC branch flips it.
1177
- if (lbState.sortDir === 'desc') return lbState.rows.slice();
1178
- return indexed
1179
- .sort((a, b) => {
1180
- const ac = Number(a.row.total_credits) || 0;
1181
- const bc = Number(b.row.total_credits) || 0;
1182
- const diff = (ac - bc) * dir;
1183
- return diff !== 0 ? diff : a.idx - b.idx;
1184
- })
1185
- .map(({ row }) => row);
1186
- }
1187
- return lbState.rows.slice();
1188
- }
1189
- const LB_SORT_HEADERS = {
1190
- rank: 'lb-sort-rank',
1191
- drachmae: 'lb-sort-drachmae',
1192
- karma: 'lb-sort-karma',
1193
- tokens: 'lb-sort-tokens',
1194
- };
1105
+ // The Roll’s rows, kept ONLY for leaderboardPlace() below — the Standing
1106
+ // card’s "#N on the leaderboard" stat. sortBy/sortDir went to /roster with
1107
+ // the table (task 1003441); nothing on this page sorts them any more.
1108
+ const lbState = { rows: [] };
1195
1109
  function leaderboardPlace(login) {
1196
1110
  const rows = Array.isArray(lbState.rows) ? lbState.rows : [];
1197
1111
  if (!rows.length || !login) return null;
@@ -1201,53 +1115,7 @@
1201
1115
  const idx = ranked.findIndex((r) => r.github_login === login);
1202
1116
  return idx === -1 ? null : { place: idx + 1, total: ranked.length };
1203
1117
  }
1204
- function renderLeaderboard() {
1205
- const rows = sortedLeaderboardRows();
1206
- // paginate() clamps the page, slices, and paints the whole #lb-* bar.
1207
- const slice = window.OTBKit.paginate(rows, lbPager);
1208
- const start = (lbPager.page - 1) * lbPager.perPage;
1209
- const tbody = $('#leaderboard tbody');
1210
-
1211
- if (!slice.length) {
1212
- tbody.innerHTML = emptyRow(6, 'No more builders to show.');
1213
- } else {
1214
- tbody.innerHTML = slice.map((b, i) => {
1215
- const place = start + i + 1;
1216
- const name = b.display_name || b.github_login || 'unknown builder';
1217
- const badges = renderAchievementRow(b.achievements);
1218
- const rank = renderRankBadge(normalizeRank(b.rank));
1219
- // task 1467: link the name to the builder's public profile page. Guarded
1220
- // on b.id (the /leaderboard row carries it) so a row without an id degrades
1221
- // to plain text rather than a broken /profile?id=undefined link.
1222
- const nameCell = b.id != null
1223
- ? `<a class="lb-name" href="/profile?id=${encodeURIComponent(b.id)}">${escapeHtml(name)}</a>`
1224
- : escapeHtml(name);
1225
- return `
1226
- <tr>
1227
- <td class="ledger__num">${place}</td>
1228
- <td>${nameCell} <span style="color:var(--ink-faint);font-size:0.86rem">@${escapeHtml(b.github_login || '')}</span>${badges}</td>
1229
- <td class="ledger__rank">${rank}</td>
1230
- <td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
1231
- <td class="ledger__drachmae">${fmtNum(b.karma)}</td>
1232
- <td class="ledger__drachmae" title="${Number(b.total_credits) > 0 ? `${fmtNum(Math.round(Number(b.tokens_used) / Number(b.total_credits)))} tokens per drachma` : 'no drachmae earned yet'}">${fmtNum(b.tokens_used)}</td>
1233
- </tr>
1234
- `;
1235
- }).join('');
1236
- }
1237
-
1238
- // Reflect the active sort on every sortable header. Only one column
1239
- // shows its arrow at a time; the others reset to "none" — matches the
1240
- // ARIA-sort spec.
1241
- for (const [key, headerId] of Object.entries(LB_SORT_HEADERS)) {
1242
- const head = document.getElementById(headerId);
1243
- if (!head) continue;
1244
- const ariaSort = (lbState.sortBy === key && lbState.sortDir)
1245
- ? (lbState.sortDir === 'asc' ? 'ascending' : 'descending')
1246
- : 'none';
1247
- head.setAttribute('aria-sort', ariaSort);
1248
- }
1249
- }
1250
1118
 
1251
- return { LB_SORT_HEADERS, SETUP_CONFIG_KEY, analyticsState, lbPager, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderLeaderboard, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey };
1119
+ return { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey };
1252
1120
  };
1253
1121
  })();
@@ -3,7 +3,7 @@
3
3
  // Every widget — core here, module-contributed from a module's own client script —
4
4
  // registers into the window.OTB hall-widget registry (dom-utils.js), and builders.js
5
5
  // mounts the registered set into the shell's view containers (task 1723 redesign):
6
- // view: 'home' | 'standing' | 'leaderboard' (default 'home')
6
+ // view: 'home' | 'standing' (default 'home')
7
7
  // zone: a finer mount target inside the Home view
8
8
  // ('home-banner' | 'home-stats' | 'home-band' | 'home-main')
9
9
  // group: a finer mount target inside Standing — which rail section this card
@@ -198,19 +198,9 @@
198
198
  '<div class="ach-grid" id="ach-grid"><div class="profile__placeholder">…</div></div>',
199
199
  { view: 'standing', group: 'achievements', labelledby: 'ach-h' });
200
200
 
201
- /* ---------------- Leaderboard ---------------- */
202
-
203
- // 8. Leaderboard.
204
- w('leaderboard-scroll', 'Leaderboard', 90,
205
- '<h2 id="lb-h" class="sr-only">Leaderboard</h2>' +
206
- '<table class="ledger" id="leaderboard"><thead><tr>' +
207
- '<th>Place</th><th>Builder</th>' +
208
- '<th class="ledger__sortable" id="lb-sort-rank" aria-sort="none" title="Sort by rank">Rank</th>' +
209
- '<th class="ledger__sortable" id="lb-sort-drachmae" aria-sort="descending" title="Sort by ' + curLabel() + ' (work shipped)" data-currency-label="caps">Credits</th>' +
210
- '<th class="ledger__sortable" id="lb-sort-karma" aria-sort="none" title="Sort by karma (peer recognition + approvals)">Karma</th>' +
211
- '<th class="ledger__sortable" id="lb-sort-tokens" aria-sort="none" title="Sort by tokens used across recorded sessions — efficiency is tokens ÷ credits (hover a row for tokens-per-drachma)">Tokens</th>' +
212
- '</tr></thead><tbody><tr><td colspan="6" class="ledger__empty">…</td></tr></tbody></table>' +
213
- // task 2174: OTBKit pager — same #lb-* id prefix as the bar it replaces.
214
- window.OTBKit.pagerBarHtml('lb', { label: 'builders' }),
215
- { view: 'leaderboard', labelledby: 'lb-h' });
201
+ // task 1003441: the Leaderboard widget is GONE from this page. It is a real
202
+ // page now (/roster — roster.html + roster.js), which is what the Community
203
+ // nav group needed: it used to read as one page plus a link into index.html.
204
+ // Nothing here declares view:'leaderboard' any more, and dom-utils.js's
205
+ // HALL_VIEWS no longer accepts it.
216
206
  })();
@@ -71,9 +71,6 @@
71
71
  </div>
72
72
  </section>
73
73
 
74
- <section class="view" id="view-leaderboard" data-view-title="Leaderboard" hidden>
75
- <div class="view-zone" id="zone-leaderboard"></div>
76
- </section>
77
74
  </main>
78
75
 
79
76
  <footer class="app-foot" id="app-foot"></footer>
@@ -7,10 +7,11 @@
7
7
  "_feeds": "As work.states.json: the kit's stub is not the hall's feed set, so Home renders its skeletons and empty states here. The populated dashboard is the hall-preview harness.",
8
8
  "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard"],
9
9
  "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
10
+ "_1003441": "The `leaderboard` state left with the view: the Roll is the /roster page now and #leaderboard REDIRECTS there, so there is no #view-leaderboard on this page to make visible. The redirect is proven in tests/hall_nav.mjs (it asserts location.replace('/roster') fires on the index and nowhere else) rather than by a render state, because a state whose page navigates away measures the destination, not this page. `standing-karma` replaces the coverage: task 2178 made each Standing rail section addressable and none had a measured state.",
10
11
  "states": {
11
- "home": { "auth": true, "expect": { "visible": ["#view-home"], "hidden": ["#view-standing", "#view-leaderboard"] } },
12
+ "home": { "auth": true, "expect": { "visible": ["#view-home"], "hidden": ["#view-standing"] } },
12
13
  "standing": { "auth": true, "url": "/builders/#standing", "actions": [["wait", 400]], "expect": { "visible": ["#view-standing"] } },
13
- "leaderboard": { "auth": true, "url": "/builders/#leaderboard", "actions": [["wait", 400]], "expect": { "visible": ["#view-leaderboard"] } },
14
+ "standing-karma": { "auth": true, "url": "/builders/#standing-karma", "actions": [["wait", 400]], "expect": { "visible": ["#view-standing"] } },
14
15
  "out": { "auth": false }
15
16
  },
16
17
  "reducedMotion": { "auth": true }
@@ -33,6 +33,9 @@
33
33
  // Stable slug for heading anchors — lowercase, strip punctuation, dash-join.
34
34
  // Matches the primer's slugify so in-doc anchor links behave identically.
35
35
  function slugify(text) {
36
+ // A [link](target) in a heading slugs by its TEXT alone (task 1003473):
37
+ // the target's path is not part of the address a reader copies.
38
+ text = String(text).replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
36
39
  return String(text)
37
40
  .toLowerCase()
38
41
  .replace(/[—–]/g, '-')
@@ -113,7 +116,9 @@
113
116
  const attrs = external ? ' target="_blank" rel="noopener"' : '';
114
117
  return `<a href="${escapeHtml(resolved)}"${attrs}>${text}</a>`;
115
118
  });
116
- out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
119
+ // Bold first, and lazily, so an *emphasis* INSIDE a bold run still renders
120
+ // (the "**It's the API *list* price**" line showed its asterisks before).
121
+ out = out.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
117
122
  out = out.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>');
118
123
  out = out.replace(/ CODE(\d+) /g, (_m, idx) => `<code>${escapeHtml(codeSpans[Number(idx)])}</code>`);
119
124
  return out;
@@ -147,9 +152,10 @@
147
152
  continue;
148
153
  }
149
154
 
150
- // Headings.
155
+ // Headings, to h4: the economy doc's six earning sources are #### and
156
+ // rendered as paragraphs with a literal "####" until task 1003473.
151
157
  let hm;
152
- if ((hm = raw.match(/^(#{1,3})\s+(.+?)\s*$/))) {
158
+ if ((hm = raw.match(/^(#{1,4})\s+(.+?)\s*$/))) {
153
159
  const level = hm[1].length;
154
160
  const text = hm[2];
155
161
  const id = slugify(text);
@@ -215,7 +221,7 @@
215
221
  i += 1;
216
222
  while (i < lines.length &&
217
223
  !/^\s*$/.test(lines[i]) &&
218
- !/^(#{1,3})\s/.test(lines[i]) &&
224
+ !/^(#{1,4})\s/.test(lines[i]) &&
219
225
  !/^---+\s*$/.test(lines[i]) &&
220
226
  !/^```/.test(lines[i]) &&
221
227
  !/^>\s?/.test(lines[i]) &&
@@ -555,7 +555,7 @@
555
555
  head.innerHTML = `
556
556
  <div class="profile__placeholder">
557
557
  No builder selected. Open a builder from the
558
- <a href="/#leaderboard">leaderboard</a>, or
558
+ <a href="/roster">roster</a>, or
559
559
  <a href="${API}/auth/web/start">sign in</a> to see your own profile.
560
560
  </div>`;
561
561
  }
@@ -1,6 +1,7 @@
1
1
  /* room.css — the READING-ROOM archetype: a long document read end to end
2
- (task 1003306, goal 1000081). Shared by the primer (primer.html) and the
3
- diagrams index (diagrams.html).
2
+ (task 1003306, goal 1000081). Shared by the primer (primer.html), the
3
+ diagrams index (diagrams.html) and, as the hall's first REFERENCE, drachmae
4
+ (task 1003473; §8 below is what the room owes a document that is scanned).
4
5
 
5
6
  THE ARCHETYPE is v3 "the glass hall", the owner's pick from the composition
6
7
  round — docs/design/hall-direction-v2.md §6, mock
@@ -203,7 +204,8 @@ body.room .page-head { max-width: var(--room-max); }
203
204
  .primer-article > ol,
204
205
  .primer-article > blockquote,
205
206
  .primer-article > h2,
206
- .primer-article > h3 { max-width: var(--measure); }
207
+ .primer-article > h3,
208
+ .primer-article > h4 { max-width: var(--measure); }
207
209
 
208
210
  /* the document's own h1 is suppressed — the page head already names the room,
209
211
  and a second h1 in <main> fails the one-header-main-h1 floor outright */
@@ -244,6 +246,20 @@ body.room .page-head { max-width: var(--room-max); }
244
246
  scroll-margin-top: calc(var(--topbar-h) + 28px);
245
247
  }
246
248
 
249
+ /* the fourth level: a run-in head in the body face at the body size, one weight
250
+ step up. The parser only reached h3 until task 1003473, so the economy doc's
251
+ six earning sources rendered as paragraphs with a literal "####". */
252
+ .primer-article h4 {
253
+ margin: var(--sp-lg) 0 var(--sp-xs);
254
+ font-family: var(--font-body);
255
+ font-size: var(--room-size);
256
+ font-weight: 600;
257
+ line-height: 1.4;
258
+ letter-spacing: -0.01em;
259
+ color: var(--ink);
260
+ scroll-margin-top: calc(var(--topbar-h) + 28px);
261
+ }
262
+
247
263
  .primer-article p { margin: 0 0 var(--sp-md); }
248
264
  .primer-article p:last-child { margin-bottom: 0; }
249
265
  /* the subtitle line at the very top of the document */
@@ -439,3 +455,100 @@ body.room .page-head { max-width: var(--room-max); }
439
455
  overflow-x: auto;
440
456
  }
441
457
  }
458
+
459
+ /* ---- 8. the reference ----------------------------------------------------
460
+ The same room, a second document (task 1003473). The primer is read once,
461
+ start to finish, with a gate at the end; drachmae is a REFERENCE that people
462
+ open to answer one question about how they get paid. Nothing about the
463
+ composition changes — the head, the glass contents beside the card, the
464
+ measure — because the room is right for both. What changes is what the room
465
+ does for a reader who is looking rather than reading, and `room--reference`
466
+ on the body switches those content rules on:
467
+ * the counters are off (no numerals in the contents, no "01" over a
468
+ heading), because a reference has no order to keep;
469
+ * the contents nest h3s under their h2s, since the sub-questions are what
470
+ a reader scans for, and the spy lights the entry AND its part (.is-trail);
471
+ * every heading carries its own link (.room-anchor), always visible in the
472
+ faint ink — an address is part of the content, not a hover secret — and
473
+ in the accent when it is the one the reader arrived at (:target);
474
+ * the phone toggle's middle names the part the reader is in.
475
+ There is no progress hairline on a reference: the page omits the markup and
476
+ §1 above stays for the primer, whose gate is fed by it. Why a modifier and
477
+ not a second archetype: the third document that is scanned (the ranks, the
478
+ API docs) takes these four rules by one body class, and a sheet of its own
479
+ would be room.css again with the counter deleted. */
480
+ .room--reference .primer-article h2::before,
481
+ .room--reference .primer-toc__list a::before { content: none; }
482
+ .room--reference .primer-toc__list a.is-trail { color: var(--ink); }
483
+ .room--reference .primer-toc__sub {
484
+ list-style: none;
485
+ margin: 0 0 6px var(--sp-xs);
486
+ padding: 0 0 0 10px;
487
+ border-left: var(--line) solid var(--rule-soft);
488
+ }
489
+ .room--reference .primer-toc__sub a {
490
+ min-height: 30px;
491
+ padding: 6px var(--sp-xs);
492
+ font-size: 13px;
493
+ color: var(--ink-faint);
494
+ }
495
+ .room--reference .primer-toc__sub a:hover,
496
+ .room--reference .primer-toc__sub a.is-active { color: var(--ink); }
497
+
498
+ /* the toggle's middle: the part the reader is in, in the faint ink, cut with an
499
+ ellipsis before it can push the chevron off the row. Empty until the spy has
500
+ something to say, and then it takes no room at all. */
501
+ .room--reference .primer-toc__toggle-label { flex: none; }
502
+ .room--reference .primer-toc__current {
503
+ flex: 1 1 auto;
504
+ min-width: 0;
505
+ overflow: hidden;
506
+ text-overflow: ellipsis;
507
+ white-space: nowrap;
508
+ text-align: right;
509
+ font-size: 13px;
510
+ color: var(--ink-faint);
511
+ }
512
+ .room--reference .primer-toc__current:empty { display: none; }
513
+
514
+ /* the address. Outranks the prose-link rule (.primer-article a): no underline,
515
+ no padding, a 24px box of its own so the floor holds without the padding
516
+ trick, and the glass under it on hover so the target reads as one. */
517
+ .room--reference .primer-article .room-anchor {
518
+ display: inline-flex;
519
+ align-items: center;
520
+ justify-content: center;
521
+ width: 24px;
522
+ height: 24px;
523
+ margin-left: 6px;
524
+ padding: 0;
525
+ vertical-align: middle;
526
+ color: var(--ink-faint);
527
+ text-decoration: none;
528
+ border-radius: var(--radius-sm);
529
+ transition: color 200ms cubic-bezier(.2, .7, .3, 1), background 200ms cubic-bezier(.2, .7, .3, 1);
530
+ }
531
+ .room--reference .primer-article .room-anchor:hover { color: var(--accent-ink); background: var(--glass); }
532
+ /* the one the reader arrived at. :target alone misses a deep link into a
533
+ document that renders after load, so drachmae.js marks the landing itself. */
534
+ .room--reference .primer-article :is(:target, .is-target) > .room-anchor { color: var(--accent-ink); }
535
+
536
+ /* On a phone the collapsed outline rides under the top bar, so the reader can
537
+ open it from anywhere in the document and read which part they are in. The
538
+ toggle is glass, the same material as the bar it sits under, and below the
539
+ bar in the z ladder for the same reason the progress hairline is. */
540
+ @media (max-width: 760px) {
541
+ .room--reference .primer-toc {
542
+ position: sticky;
543
+ top: calc(var(--topbar-h) + 8px);
544
+ z-index: var(--z-progress);
545
+ }
546
+ /* Riding over the article, the toggle and the open list take the card's
547
+ ground at 92% rather than the bare glass: light's glass has no blur, so
548
+ over scrolling prose the bare tint was the prose showing through the pill
549
+ (measured on the deep-link render at 390). The edge stays the glass edge. */
550
+ .room--reference .primer-toc__toggle,
551
+ .room--reference .primer-toc__inner { background: color-mix(in srgb, var(--bg-card) 92%, transparent); }
552
+ /* a landed heading has to clear the sticky toggle as well as the top bar */
553
+ .room--reference .primer-article :is(h2, h3, h4) { scroll-margin-top: calc(var(--topbar-h) + 72px); }
554
+ }
@@ -0,0 +1,63 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Builders — {{worldName}}</title>
7
+ <meta name="description" content="Who builds on this project: the roster, ranked by work shipped, with rank, credits, karma and tokens.">
8
+ <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
+ <!-- task 1003441: the identity cell (avatar, name, login) is shared with the
13
+ cross-project directory — one definition, both rosters. -->
14
+ <link rel="stylesheet" href="/builders/people.css">
15
+ </head>
16
+ <body data-page="roster" data-page-title="Builders">
17
+
18
+ <div class="app">
19
+ <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
20
+ <div class="app-scrim" id="app-scrim" hidden></div>
21
+ <div class="app-body">
22
+ <header class="app-topbar" id="app-topbar"></header>
23
+ <div class="app-content">
24
+ <!-- The board page head (task 1003307): the label voice. The sub is
25
+ rewritten by roster.js once the roster has loaded. -->
26
+ <div class="page-head page-head--board">
27
+ <h1 class="page-head__h">Builders</h1>
28
+ <p class="page-head__sub" id="roster-sub">Who builds on this project, ranked by work shipped. This project's own ledger — unlike the cross-project directory, it lists everyone who builds here.</p>
29
+ </div>
30
+
31
+ <main id="hall-main">
32
+ <!-- task 1003441: this was the #leaderboard HASH VIEW on the home page,
33
+ which is why the Community group used to read as one real page plus a
34
+ link into index.html. It is a page now; /#leaderboard redirects here. -->
35
+ <section class="scroll" id="roster-scroll" aria-labelledby="roster-h">
36
+ <h2 id="roster-h" class="scroll__h">The roster<span class="count-pill" id="roster-count" hidden></span></h2>
37
+ <div id="roster-filters"></div>
38
+ <div id="roster-body">
39
+ <div class="profile__placeholder">Loading the roster…</div>
40
+ </div>
41
+ </section>
42
+ </main>
43
+
44
+ <footer class="app-foot" id="app-foot"></footer>
45
+ </div>
46
+ </div>
47
+ </div>
48
+
49
+ <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
50
+
51
+ <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
52
+ chrome exists before the page script runs. -->
53
+ <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
54
+ <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
55
+ <script src="/builders/palette.js" defer></script>
56
+ <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
57
+ branding pack's currency label, loaded BEFORE roster.js. -->
58
+ <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
59
+ <script src="/builders/brand-apply.js"></script>
60
+ <script src="/builders/hall-kit.js"></script>
61
+ <script src="/builders/roster.js"></script>
62
+ </body>
63
+ </html>