@bongos/core 1.24.38 → 1.24.40

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.
@@ -95,3 +95,13 @@
95
95
  -webkit-mask-position: left top, right top;
96
96
  mask-position: left top, right top;
97
97
  }
98
+
99
+ /* ---- the government columns (task 1005048) -------------------------------
100
+ Watch's roster folded in here. A stale claim takes the danger ink and a
101
+ small badge; the viewer's own row states a fact in the faint ink where the
102
+ others carry a control; the foot line under the table says why. */
103
+ .roster-warn { color: var(--danger); font-weight: 500; }
104
+ .roster-self { color: var(--ink-faint); }
105
+ .badge--sev-medium { color: var(--info); }
106
+ .badge--status { color: var(--ink-faint); }
107
+ .roster-foot { margin: 14px 0 0; font-size: 12.5px; color: var(--ink-faint); max-width: var(--measure); }
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
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.">
7
+ <meta name="description" content="Who builds on this project: the roster, ranked by work shipped, with rank, credits, karma and tokens — and, for a government seat, active claims and standing.">
8
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
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -41,6 +41,9 @@
41
41
  <div id="roster-body">
42
42
  <div class="hall-loading hall-loading--page" role="status">Loading the roster…</div>
43
43
  </div>
44
+ <!-- task 1005048: shown only to a viewer the roster route lets read the
45
+ government columns; says what they are and why one row has no controls. -->
46
+ <p class="roster-foot" id="roster-gov-foot" hidden></p>
44
47
  </section>
45
48
  </main>
46
49
 
@@ -136,6 +136,125 @@
136
136
  var sortBy = '';
137
137
  var role = '';
138
138
 
139
+ // ---- the government columns (task 1005048, owner decision on idea 1001313) --
140
+ // Watch kept a second list of the same people; it is this page now. A viewer
141
+ // the server lets read GET /builders/roster (the builder.roster.read atom) also
142
+ // sees each builder's active claims, the stale-claim flag and standing, and
143
+ // gets the rank and offboard controls that list carried. For anyone else that
144
+ // read is refused and the page draws the public list unchanged: the wall is
145
+ // the route, never this file.
146
+ var gov = null; // builder id → their GET /builders/roster row, once it answers
147
+ var viewerId = null;
148
+ var claims = '';
149
+ var RANK_OPTIONS = ['xenos', 'thetes', 'metic', 'archon'];
150
+ var client = window.BongosClient
151
+ ? window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false })
152
+ : null;
153
+ var toast = function (m) { if (OTB && OTB.toast) OTB.toast(m); };
154
+ // By value: /me and the roster can disagree on string vs number ids (#348).
155
+ function isViewer(b) { return viewerId != null && b && b.id != null && String(b.id) === String(viewerId); }
156
+ function govOf(b) { return gov && b.id != null ? gov[String(b.id)] || null : null; }
157
+ // Resolves to the government rows, or null for every refusal and failure.
158
+ // The roster read is asked only of a viewer whose /me map says they hold
159
+ // builder.roster.read, so a stranger or a builder without the seat costs this
160
+ // public page no extra round trip. /me itself is the shell's read, shared on
161
+ // window.__hallReads (the gov-feeds.js precedent); asked here only without it.
162
+ function readMe() {
163
+ var shared = window.__hallReads && window.__hallReads['/me'];
164
+ if (shared) return Promise.resolve(shared);
165
+ return client.request('GET', '/api/bongos/me').then(function (res) { return res && res.ok ? res.data : null; });
166
+ }
167
+ function loadGov() {
168
+ if (!client) return Promise.resolve(null);
169
+ return readMe().then(function (me) {
170
+ var perms = me && me.permissions;
171
+ if (!perms || perms['builder.roster.read'] !== true) return null;
172
+ var who = me.builder || me;
173
+ // No id from /me marks no row as yours, rather than the wrong one.
174
+ viewerId = who && who.id != null ? who.id : null;
175
+ return client.request('GET', '/api/bongos/builders/roster').then(function (roster) {
176
+ if (!roster || !roster.ok || !roster.data || !Array.isArray(roster.data.builders)) return null;
177
+ return roster.data.builders;
178
+ });
179
+ }).catch(function () { return null; });
180
+ }
181
+ function applyGov(rows) {
182
+ gov = {};
183
+ rows.forEach(function (r) { if (r.id != null) gov[String(r.id)] = r; });
184
+ // The rank a control just changed reads from the fresh row.
185
+ all.forEach(function (b) { var g = govOf(b); if (g && g.rank) b.rank = g.rank; });
186
+ }
187
+ function claimsHtml(g) {
188
+ var stale = g.stale_claim ? ' <span class="badge badge--sev-medium">stale</span>' : '';
189
+ return '<td class="ledger__num' + (g.stale_claim ? ' roster-warn' : '') + '">' + esc(fmtInt(g.active_claim_count || 0)) + stale + '</td>';
190
+ }
191
+ function rankControlHtml(b) {
192
+ var opts = RANK_OPTIONS.slice();
193
+ if (b.rank && opts.indexOf(b.rank) === -1) opts.unshift(b.rank);
194
+ return '<select data-roster-rank data-id="' + esc(b.id) + '" data-current="' + esc(b.rank || '') + '" aria-label="Rank">'
195
+ + opts.map(function (rk) {
196
+ return '<option value="' + esc(rk) + '"' + (rk === b.rank ? ' selected' : '') + '>' + esc(rankLabel(rk)) + '</option>';
197
+ }).join('') + '</select>';
198
+ }
199
+ // The viewer's own row offers nothing (task 1003720): both routes refuse a
200
+ // self-change outright, the guard that stops the last top-rank builder locking
201
+ // everyone out, so a live control there could only answer with a refusal.
202
+ function actionHtml(b, g) {
203
+ if (isViewer(b)) return '<span class="roster-self">That’s you</span>';
204
+ var inactive = String(g.status) === 'inactive';
205
+ return '<button type="button" class="btn-ghost' + (inactive ? '' : ' btn-ghost--danger') + '" data-roster-status="'
206
+ + (inactive ? 'active' : 'inactive') + '" data-id="' + esc(b.id) + '">' + (inactive ? 'Reactivate' : 'Offboard') + '</button>';
207
+ }
208
+ function govFoot() {
209
+ var foot = document.getElementById('roster-gov-foot');
210
+ if (!foot) return;
211
+ if (!gov) { foot.hidden = true; return; }
212
+ foot.textContent = 'Because you hold a government seat you also see active claims and standing, and can change a builder’s rank. '
213
+ + (all.some(isViewer)
214
+ ? 'Your own row carries no controls. Nobody can change their own rank or standing — that guard is what stops the last '
215
+ + rankLabel('archon') + ' locking everyone out — so another ' + rankLabel('archon') + ' does it.'
216
+ : '');
217
+ foot.hidden = false;
218
+ }
219
+ function patchBuilder(id, what, payload) {
220
+ return client.request('PATCH', '/api/bongos/builders/' + encodeURIComponent(id) + '/' + what, { body: payload })
221
+ .then(function (res) {
222
+ if (res && res.ok) return res.data;
223
+ var d = (res && res.data) || {};
224
+ var env = d.error && typeof d.error === 'object' ? d.error : null;
225
+ throw new Error((env && env.message) || d.message || (typeof d.error === 'string' ? d.error : '') || ('http ' + (res && res.status)));
226
+ });
227
+ }
228
+ function refreshGov() {
229
+ return loadGov().then(function (rows) { if (rows) applyGov(rows); else gov = null; makeRosterLedger(); rerender(); govFoot(); });
230
+ }
231
+ function wireGov() {
232
+ if (body['wired:gov']) return;
233
+ body['wired:gov'] = true;
234
+ // Delegated on the mount, so a page flip cannot strand a row without one.
235
+ body.addEventListener('change', function (e) {
236
+ var sel = e.target && e.target.closest ? e.target.closest('[data-roster-rank]') : null;
237
+ if (!sel) return;
238
+ var prev = sel.getAttribute('data-current');
239
+ var rank = sel.value;
240
+ if (rank === prev) return;
241
+ sel.disabled = true;
242
+ patchBuilder(sel.getAttribute('data-id'), 'rank', { rank: rank })
243
+ .then(function () { toast('Rank set to ' + rankLabel(rank) + '.'); return refreshGov(); })
244
+ .catch(function (err) { sel.value = prev; sel.disabled = false; toast('Could not change rank: ' + (err.message || err)); });
245
+ });
246
+ body.addEventListener('click', function (e) {
247
+ var btn = e.target && e.target.closest ? e.target.closest('[data-roster-status]') : null;
248
+ if (!btn) return;
249
+ var status = btn.getAttribute('data-roster-status');
250
+ var reason = status === 'inactive' ? (window.prompt('Reason for offboarding (optional):', '') || null) : null;
251
+ btn.disabled = true;
252
+ patchBuilder(btn.getAttribute('data-id'), 'status', { status: status, reason: reason })
253
+ .then(function () { toast(status === 'inactive' ? 'Builder offboarded.' : 'Builder reactivated.'); return refreshGov(); })
254
+ .catch(function (err) { btn.disabled = false; toast('Could not change standing: ' + (err.message || err)); });
255
+ });
256
+ }
257
+
139
258
  // PLACE IS CANONICAL, not the row's position in the current sort.
140
259
  // hall-render's leaderboardPlace() already defines a builder's place as
141
260
  // credits-descending "independent of whatever column the table is currently
@@ -165,6 +284,12 @@
165
284
 
166
285
  function matches(b) {
167
286
  if (role && shownOf(b).indexOf(role) === -1) return false;
287
+ if (claims) {
288
+ var g = govOf(b);
289
+ if (!g) return false;
290
+ if (claims === 'stale' && !g.stale_claim) return false;
291
+ if (claims === 'holding' && !(Number(g.active_claim_count) > 0)) return false;
292
+ }
168
293
  if (!query) return true;
169
294
  var hay = ((b.github_login || '') + ' ' + (b.display_name || '')).toLowerCase();
170
295
  return hay.indexOf(query) !== -1;
@@ -229,22 +354,31 @@
229
354
  var tokens = hasTokens(b)
230
355
  ? esc(fmtInt(b.tokens_used))
231
356
  : '<span class="lb-none" aria-label="No AI sessions">—</span>';
357
+ var g = govOf(b);
358
+ var govCells = g
359
+ ? claimsHtml(g)
360
+ + '<td><span class="badge badge--status">' + esc(g.status || '—') + '</span></td>'
361
+ + '<td>' + actionHtml(b, g) + '</td>'
362
+ : (gov ? '<td class="ledger__num">—</td><td>—</td><td></td>' : '');
363
+ var rankCell = g && !isViewer(b) ? rankControlHtml(b) : rankBadgeHtml(b.rank);
232
364
  return '<tr>'
233
365
  + '<td class="ledger__num ledger__place">' + esc(String(place || '—')) + idHtml(b) + '</td>'
234
366
  + '<td>' + whoHtml(b) + foundingHtml(b) + achRowHtml(b.achievements) + '</td>'
235
367
  + '<td class="ledger__roles">' + rolesHtml(b) + '</td>'
236
- + '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
368
+ + '<td class="ledger__rank">' + rankCell + '</td>'
237
369
  + '<td class="ledger__num">' + esc(fmtInt(creditOf(b))) + '</td>'
238
370
  + '<td class="ledger__num">' + esc(fmtInt(b.karma)) + '</td>'
239
371
  + '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + tokens + '</td>'
372
+ + govCells
240
373
  + '</tr>';
241
374
  }
242
375
 
243
376
  function columns() {
244
- return ['Place', 'Builder', 'Roles', 'Rank',
377
+ var cols = ['Place', 'Builder', 'Roles', 'Rank',
245
378
  { label: role ? currencyLabel() + ' as ' + CRAFT_WORD[role].toLowerCase() : currencyLabel(), class: 'ledger__num' },
246
379
  { label: 'Karma', class: 'ledger__num' },
247
380
  { label: 'Tokens', class: 'ledger__num' }];
381
+ return gov ? cols.concat([{ label: 'Active claims', class: 'ledger__num' }, 'Standing', 'Action']) : cols;
248
382
  }
249
383
 
250
384
  function makeRosterLedger() {
@@ -294,8 +428,21 @@
294
428
  body.textContent = msg;
295
429
  }
296
430
 
297
- function mountRoster(rows) {
298
- all = stampPlaces(Array.isArray(rows) ? rows : []);
431
+ function mountRoster(rows, govRows) {
432
+ all = Array.isArray(rows) ? rows : [];
433
+ if (govRows) {
434
+ // The government list is every builder; the public one only those who have
435
+ // earned. Nobody drops off the one list for having earned nothing yet.
436
+ var seen = {};
437
+ all.forEach(function (b) { if (b.id != null) seen[String(b.id)] = true; });
438
+ govRows.forEach(function (r) {
439
+ if (r.id == null || seen[String(r.id)]) return;
440
+ all.push({ id: r.id, github_login: r.github_login, display_name: r.display_name, avatar_url: r.avatar_url,
441
+ rank: r.rank, total_credits: r.total_credits, karma: r.karma, tokens_used: 0 });
442
+ });
443
+ }
444
+ all = stampPlaces(all);
445
+ if (govRows) applyGov(govRows);
299
446
  if (!all.length) {
300
447
  notice('Nobody has earned anything on this project yet. A builder joins the roster the '
301
448
  + 'first time they ship a claimed task.');
@@ -307,10 +454,19 @@
307
454
  + ' on this project, ranked by ' + currencyLabel().toLowerCase() + ' earned. This '
308
455
  + 'project’s own ledger — everyone who builds here is listed.';
309
456
  }
457
+ // Watch's stale-claims tile links here as ?claims=stale.
458
+ var asked = '';
459
+ try { asked = new URLSearchParams(window.location.search).get('claims') || ''; } catch (e) { asked = ''; }
460
+ claims = gov && (asked === 'stale' || asked === 'holding') ? asked : '';
461
+ var claimsControl = gov ? [{ id: 'roster-claims', label: 'Claims', value: claims, options: [
462
+ { value: '', label: 'anyone' },
463
+ { value: 'holding', label: 'holding a claim' },
464
+ { value: 'stale', label: 'stale claims' },
465
+ ] }] : [];
310
466
  kit.makeFilterBar({
311
467
  mount: document.getElementById('roster-filters'),
312
468
  search: { id: 'roster-q', label: 'Search', placeholder: 'name or login' },
313
- primary: [
469
+ primary: claimsControl.concat([
314
470
  { id: 'roster-sort',
315
471
  label: 'Sort',
316
472
  options: [
@@ -319,10 +475,11 @@
319
475
  { value: 'karma', label: 'most karma' },
320
476
  { value: 'tokens', label: 'most tokens' },
321
477
  ] },
322
- ],
478
+ ]),
323
479
  onChange: function (vals) {
324
480
  query = String(vals['roster-q'] || '').trim().toLowerCase();
325
481
  sortBy = String(vals['roster-sort'] || '');
482
+ if (gov) claims = String(vals['roster-claims'] || '');
326
483
  pager.page = 1;
327
484
  rerender();
328
485
  },
@@ -330,21 +487,23 @@
330
487
  mountRoleChips();
331
488
  makeRosterLedger();
332
489
  rerender();
490
+ if (gov) { wireGov(); govFoot(); }
333
491
  }
334
492
 
335
493
  var foundingLoad = window.OTBFounding ? window.OTBFounding.load() : Promise.resolve([]);
336
494
  // The PUBLIC list (task 1004437): the same rows the authed /leaderboard
337
495
  // carries plus each builder's roles, and the one the status page reads, so
338
496
  // the hall and the public page cannot rank a role differently.
497
+ var govLoad = loadGov();
339
498
  fetch('/api/bongos/public/leaderboard', { headers: { Accept: 'application/json' } })
340
499
  .then(function (r) {
341
500
  if (!r.ok) throw new Error('http ' + r.status);
342
501
  return r.json();
343
502
  })
344
503
  .then(function (data) {
345
- return foundingLoad.then(function (list) {
346
- marks = window.OTBFounding ? window.OTBFounding.byId(list) : {};
347
- mountRoster(data && data.leaderboard);
504
+ return Promise.all([foundingLoad, govLoad]).then(function (got) {
505
+ marks = window.OTBFounding ? window.OTBFounding.byId(got[0]) : {};
506
+ mountRoster(data && data.leaderboard, got[1]);
348
507
  });
349
508
  })
350
509
  .catch(function () { notice('Couldn’t load the roster right now.'); });
@@ -61,7 +61,7 @@
61
61
 
62
62
  // Every limit of the line rule, in characters unless named otherwise.
63
63
  const LIMITS = Object.freeze({
64
- nameWords: 3, name: 20, fact: 26, facts: 3, factLen: 24, actions: 2,
64
+ nameWords: 14, name: 90, fact: 60, facts: 3, factLen: 48, actions: 2,
65
65
  tiles: 3, tileValue: 8, tileWords: 2, tileLabel: 14,
66
66
  status: 44, clearLine: 30, clearSub: 40, record: 4, recordLen: 30,
67
67
  chips: 5, chip: 24, todayTarget: 10, pips: 12, saveLine: 48,
@@ -20,7 +20,8 @@
20
20
  // /goals/rollups) for the districts, and the viewer's earnings for "your share
21
21
  // this month". Where the real data had to bend the design, it is said once here
22
22
  // and in the ship notes:
23
- // * Districts. One per OPEN goal, in the order the goals come, between
23
+ // * Districts (only with `districts: true`; the page draws none since the
24
+ // owner's 2026-11-04 direction, and centres the civic row). One per OPEN goal, in the order the goals come, between
24
25
  // Automation and Review. Up to four keep the prototype's 90 px plots; past
25
26
  // four each plot narrows (never under 56 px) and past that the street grows
26
27
  // longer than the 1440 px artboard, so the page scrolls it sideways rather
@@ -330,9 +331,13 @@
330
331
  // between districts, 18 otherwise), starting at 43. Past MAX_DISTRICTS open
331
332
  // goals the first seven stand as districts and the eighth plot is the rest,
332
333
  // staked out, a door to the goals page.
333
- function layout(goals) {
334
- const shown = goals.length > MAX_DISTRICTS ? goals.slice(0, MAX_DISTRICTS - 1) : goals;
335
- const more = goals.length - shown.length;
334
+ // The street is the governance square (owner, 2026-11-04): the working areas
335
+ // are the city's own buildings, so by default no district stands on the
336
+ // street and the civic row is centred on the artboard. `districts` keeps the
337
+ // prototype's row (the kit's tests hold it to the prototype).
338
+ function layout(goals, districts) {
339
+ const shown = districts ? (goals.length > MAX_DISTRICTS ? goals.slice(0, MAX_DISTRICTS - 1) : goals) : [];
340
+ const more = districts ? goals.length - shown.length : 0;
336
341
  const dw = districtWidth(shown.length + (more ? 1 : 0));
337
342
  const segs = [];
338
343
  SEGS.forEach((s) => {
@@ -346,6 +351,8 @@
346
351
  if (i > 0) x += segs[i - 1].g !== s.g ? 30 : (s.d ? 10 : 18);
347
352
  s.x = x; x += s.w;
348
353
  });
354
+ const spare = ARTBOARD_W - (x + 43);
355
+ if (!districts && spare > 0) segs.forEach((s) => { s.x += Math.floor(spare / 2); });
349
356
  return { segs: segs, width: Math.max(ARTBOARD_W, x + 43), districtW: dw };
350
357
  }
351
358
 
@@ -510,6 +517,10 @@
510
517
  // living GovLiving.livingModel() over the same feeds, or null (no Leads,
511
518
  // no agents, no construction: the deep street with real data only)
512
519
  // roster GET /builders/roster body, or null (the people reading)
520
+ // districts true: stand a district per open goal between Automation and
521
+ // Review (the prototype's row). The page leaves it off: the
522
+ // street is the governance square and the working areas are the
523
+ // city's (owner, 2026-11-04)
513
524
  function streetModel(input) {
514
525
  const opts = input || {};
515
526
  const docket = opts.docket && typeof opts.docket === 'object' && Array.isArray(opts.docket.matters) ? opts.docket : null;
@@ -529,7 +540,7 @@
529
540
  const goals = (Array.isArray(opts.goals) ? opts.goals : []).filter((g) => g && g.status === 'open' && g.id != null);
530
541
  const byGoal = {};
531
542
  (Array.isArray(opts.rollups) ? opts.rollups : []).forEach((r) => { if (r && r.goal_id != null) byGoal[String(r.goal_id)] = r; });
532
- const L = layout(goals);
543
+ const L = layout(goals, opts.districts === true);
533
544
  const segs = L.segs, W = L.width;
534
545
  const at = {};
535
546
  segs.forEach((s) => { at[s.k || ('d:' + s.d)] = s; });
@@ -725,7 +725,23 @@
725
725
  return dv;
726
726
  }
727
727
 
728
+ // The floor picker (owner, 2026-11-04: a building is a place to work). On a
729
+ // wide window the section is cropped to the building itself (PICKER_CROP: its
730
+ // crown, storeys and ground, without the 800 x 708 section's empty margins)
731
+ // and scaled to the stage height, never under half size (a floor stays a
732
+ // 24 px target) nor over 0.72 (the desk keeps the room). The CSS variables
733
+ // street.js sets on the drawing's box and its column.
734
+ const PICKER_CROP = Object.freeze({ x: 50, y: 120, w: 590, h: 580 });
735
+ function pickerFit(stageH) {
736
+ const c = PICKER_CROP;
737
+ const k = Math.max(0.5, Math.min(0.72, Math.round(((Number(stageH) || 708) - 60) / c.h * 1000) / 1000));
738
+ const px = (n) => Math.round(n * k) + 'px';
739
+ return { k: k, box: { '--inside-art-scale': String(k), '--inside-art-x': px(-c.x), '--inside-art-y': px(-c.y) },
740
+ art: { '--inside-art-w': px(c.w), '--inside-art-h': px(c.h) } };
741
+ }
742
+
728
743
  const api = {
744
+ pickerFit: pickerFit, PICKER_CROP: PICKER_CROP,
729
745
  crown: crown, workItem: workItem, floorSymbol: floorSymbol, symbolPath: symbolPath, buildingView: buildingView, districtView: districtView,
730
746
  civicInfo: civicInfo, floorFor: floorFor, holderOf: holderOf, recordOf: recordOf, buildingModel: buildingModel,
731
747
  districtInfo: districtInfo, districtModel: districtModel,
@@ -530,6 +530,37 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
530
530
  .inside-name:focus { outline: none; }
531
531
  .inside-name:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
532
532
 
533
+ /* THE HALL IS FOR WORK (owner, 2026-11-04; task 1005075). The street's own
534
+ header strip and the six readings along the bottom repeat what the shell's
535
+ top bar and the buildings already say, and inside a building they squeezed
536
+ the floor you came to work on into a corner. Both go. */
537
+ .street-top, .street-signs { display: none; }
538
+
539
+ /* Inside a building: the drawing is the floor picker, at half size in its own
540
+ column (a floor stays a 24 px target); the chosen floor's desk takes the rest
541
+ of the window and scrolls on its own. The building's own facts (who holds it,
542
+ how it decides, its record) follow the desk instead of standing above it. */
543
+ @media (min-width: 900px) {
544
+ .inside--work { display: block; overflow: hidden; }
545
+ .inside--work .inside-sizer { width: auto; height: 100%; margin: 0; overflow: visible; }
546
+ .inside--work .inside-board { position: static; width: auto; height: 100%; transform: none; display: grid;
547
+ grid-template-columns: var(--inside-art-w, 400px) minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
548
+ column-gap: 40px; padding: 0 32px 0 16px; box-sizing: border-box; animation: none; }
549
+ .inside--work .inside-head { position: static; grid-column: 1 / -1; padding: 14px 0 6px 16px; }
550
+ .inside--work .inside-art { position: relative; width: var(--inside-art-w, 400px); height: var(--inside-art-h, 354px); grid-row: 2; align-self: start; overflow: hidden; }
551
+ .inside--work .inside-art__box { width: 800px; transform: translate(var(--inside-art-x, 0), var(--inside-art-y, 0)) scale(var(--inside-art-scale, 1)); }
552
+ .inside--work .inside-panel { position: static; grid-row: 2; width: auto; max-width: 880px; height: 100%; padding: 4px 4px 32px 0; box-sizing: border-box; gap: 20px; }
553
+ .inside--work .inside-title { order: 0; flex-direction: row; align-items: baseline; gap: 14px; flex-wrap: wrap; }
554
+ .inside--work .inside-name { font-size: 28px; }
555
+ .inside--work .inside-holds { font-size: 14px; }
556
+ .inside--work .inside-open { order: 1; }
557
+ .inside--work .govx { order: 2; }
558
+ .inside--work .inside-gov { order: 3; }
559
+ .inside--work .inside-record { order: 4; }
560
+ .inside--work .inside-open__head .inside-plate { width: 44px; height: 44px; border-radius: 10px; }
561
+ .inside--work .inside-open__title { font-size: 24px; }
562
+ }
563
+
533
564
  /* narrow: the drawing in its own sideways strip, the panel below it */
534
565
  @media (max-width: 899px) {
535
566
  .inside { display: block; overflow-x: hidden; overflow-y: auto; }
@@ -543,6 +574,11 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
543
574
  .inside-dhead { order: 1; }
544
575
  .inside-legend { order: 3; flex-direction: column; gap: 8px; }
545
576
  .inside-panel { order: 3; overflow: visible; }
577
+ /* the floor's desk straight under the name, the building's facts after it */
578
+ .inside-open { order: 1; }
579
+ .govx { order: 2; }
580
+ .inside-gov { order: 3; }
581
+ .inside-record { order: 4; }
546
582
  .inside-name { font-size: 32px; }
547
583
  .inside-gov__row { flex-wrap: wrap; row-gap: 4px; }
548
584
  .desk-row__main { flex-wrap: wrap; row-gap: 8px; }
@@ -77,7 +77,7 @@
77
77
  constitution: '/api/bongos/government/constitution',
78
78
  uat: function (id) { return '/api/bongos/done-when/' + encodeURIComponent(id) + '/uat'; },
79
79
  };
80
- var NARROW = 900;
80
+ var NARROW = 900, FOCUS_W = 1120; // FOCUS_W: the civic row and its pins, which a wide window fits
81
81
  var I = window.GovInside;
82
82
  // The artboard's street band: the prototype's 900-high board less its 64 px
83
83
  // header and 128 px footer, which this page lays out as real rows, and less
@@ -159,13 +159,10 @@
159
159
  function here() { return S.parseHash(window.location && window.location.hash); }
160
160
 
161
161
  // ---------- fit the artboard's street band to the stage ----------
162
- // 1440 x 708 at scale 1 is the prototype exactly. A wider or taller stage
163
- // grows it, a smaller one shrinks it; a phone keeps it near full size and the
164
- // street scrolls sideways inside its window, so a pin stays a pin.
165
- // A stage taller than the street needs (a wide window scales the street to its
166
- // width) gives the spare height to the foot of the page, under the readings, so
167
- // the street sits on them with no empty sky above it (task 1004718). The window
168
- // gives the height back whenever a building's inside is open.
162
+ // 1440 x 708 at scale 1 is the prototype exactly. A wide window fits the civic
163
+ // row (FOCUS_W), crops the haze either side and centres the street in the stage
164
+ // (owner, 2026-11-04); a phone keeps it near full size and scrolls it sideways
165
+ // from the first building, so a pin stays a pin.
169
166
  function fit() {
170
167
  var win = $('streetWindow'), inside = $('streetInside');
171
168
  var insideOn = !!(inside && !inside.hidden);
@@ -175,23 +172,29 @@
175
172
  if (!win || !sizer || !board) return;
176
173
  var W = view.model ? view.model.width : S.ARTBOARD_W;
177
174
  var cw = win.clientWidth || window.innerWidth || 1440, ch = win.clientHeight || 708;
178
- var s = Math.min(cw / W, ch / BAND_H);
179
- if (cw / W < 0.6) s = Math.min(1, ch / BAND_H);
175
+ var wide = cw >= NARROW;
176
+ var s = wide ? Math.min(cw / FOCUS_W, ch / BAND_H) : Math.min(cw / W, ch / BAND_H);
177
+ if (cw / W < 0.6 && !wide) s = Math.min(1, ch / BAND_H);
180
178
  s = Math.max(0.4, Math.round(s * 1000) / 1000);
181
179
  view.scale = s;
182
- sizer.style.width = Math.round(W * s) + 'px';
180
+ var sizerW = wide ? Math.min(Math.round(W * s), cw) : Math.round(W * s);
181
+ sizer.style.width = sizerW + 'px';
183
182
  sizer.style.height = Math.round(BAND_H * s) + 'px';
184
- if (!insideOn && win.style && Math.round(BAND_H * s) < ch - 1) win.style.flex = '0 0 ' + Math.round(BAND_H * s) + 'px';
183
+ board.style.left = wide ? Math.round((sizerW - W * s) / 2) + 'px' : '';
184
+ if (win.style) win.style.overflowX = wide ? 'hidden' : '';
185
185
  board.style.width = W + 'px';
186
186
  board.style.setProperty('--street-scale', String(s));
187
187
  var svg = $('streetSvg');
188
188
  if (svg) { svg.setAttribute('width', String(W)); svg.setAttribute('viewBox', '0 0 ' + W + ' 900'); }
189
+ var doors = $('streetDoors'), first = doors && (doors.firstElementChild || (doors.children && doors.children[0]));
190
+ if (!wide && !insideOn && !view.scrolled && first) { win.scrollLeft = Math.max(0, Math.round((parseFloat(first.style.left) || 0) * s) - 16); view.scrolled = true; }
189
191
  placeCard();
190
192
  }
191
193
 
192
- // The interior's artboard: 1440 x 708 scaled to the window like the street;
193
- // under NARROW px the board is a column and only the drawing scales, in its
194
- // own sideways strip, never under 0.6 so a floor stays a 24 px target.
194
+ // The interior: a building on a wide window is laid out, its drawing the floor
195
+ // picker (GovInside.pickerFit) beside the desk; a district's 1440 x 708 board
196
+ // scales to the window like the street; under NARROW px the board is a column
197
+ // and only the drawing scales, in its own sideways strip, never under 0.6.
195
198
  function fitInside() {
196
199
  var box = $('streetInside'), sizer = $('insideSizer'), board = $('insideBoard'), art = $('insideArt'), artBox = $('insideArtBox'), span = $('insideArtSpan');
197
200
  if (!box || !sizer || !board || !art || !artBox || box.hidden) return;
@@ -207,6 +210,14 @@
207
210
  if (span) { span.style.width = Math.round(artW * a) + 'px'; span.style.height = Math.round(708 * a) + 'px'; }
208
211
  return;
209
212
  }
213
+ var work = !(view.inside && view.inside.kind === 'district');
214
+ if (box.classList) box.classList.toggle('inside--work', work);
215
+ if (work) {
216
+ var f = I.pickerFit(ch);
217
+ sizer.style.width = ''; sizer.style.height = ''; board.style.setProperty('--inside-scale', '1');
218
+ [[artBox, f.box], [art, f.art]].forEach(function (e) { Object.keys(e[1]).forEach(function (k) { e[0].style.setProperty(k, e[1][k]); }); });
219
+ if (span) { span.style.width = '0px'; span.style.height = '0px'; } return;
220
+ }
210
221
  var s = Math.max(0.4, Math.round(Math.min(cw / 1440, ch / 708) * 1000) / 1000);
211
222
  sizer.style.width = Math.round(1440 * s) + 'px';
212
223
  sizer.style.height = Math.round(708 * s) + 'px';
@@ -292,7 +303,9 @@
292
303
  // GovStreetDepth.page's (street-depth.js); this file hands it the street's
293
304
  // data, its card opener and a redraw, and keeps the street's own wiring.
294
305
  var X = window.GovStreetDepth.page;
295
- function drawPin(p) { return X.pin(p, { open: view.open, openCard: openCard }); }
306
+ // a pin goes into its building, the matter a desk row there (owner, 2026-11-04: no pop-ups)
307
+ function goPin(key, el, keys) { var b = el && el.getAttribute && el.getAttribute('data-building'); if (b) window.location.hash = 'building=' + b; else openCard(key, el, keys); }
308
+ function drawPin(p) { return X.pin(p, { open: view.open, openCard: goPin }); }
296
309
  function drawSign(sg, tile) { return X.sign(sg, tile); }
297
310
  function drawDepth(dm, W) { return X.depth(dm, W); }
298
311
  // the living model arrives after the street's own reads: the street and the deck draw it
@@ -21,12 +21,6 @@
21
21
  /* Row actions inside a ledger cell: ghost pills in a row. */
22
22
  .watch-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
23
23
 
24
- /* The reader's own roster row (task 1003720). Nobody may change their own rank
25
- or standing — the server refuses both outright — so that row states a fact
26
- where the others carry a control, and a fact is written in the faint ink, not
27
- painted as a disabled pill. The foot line under the table says why. */
28
- .watch-self { color: var(--ink-faint); }
29
-
30
24
  /* A sealed or failed section says so in place. Three states, three sentences:
31
25
  "nothing here" is the kit's empty state, this is "you may not see this" and
32
26
  "could not load". */
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Watch — {{worldName}}</title>
7
- <meta name="description" content="Archon oversight for {{worldName}} — access requests, security, roster, reviews, and repo health.">
7
+ <meta name="description" content="Archon oversight for {{worldName}} — access requests, security, stale claims, reviews, and repo health.">
8
8
  <!-- The hub's origin, for the link from a guild's head in the applications queue to
9
9
  its page on the hub (task 1002304). Empty on a vanilla instance: no link. -->
10
10
  <meta name="hub-origin" content="{{hubOrigin}}">
@@ -48,7 +48,7 @@
48
48
  <div class="stat-row ov-summary" id="watch-attention" hidden>
49
49
  <a class="stat-tile" href="#people"><span class="stat-tile__label">Access requests</span><span class="stat-tile__num" id="att-access">—</span><span class="stat-tile__sub">pending, on the People tab</span></a>
50
50
  <a class="stat-tile" href="#security"><span class="stat-tile__label">Security reports</span><span class="stat-tile__num" id="att-security">—</span><span class="stat-tile__sub">open, on the Security tab</span></a>
51
- <a class="stat-tile" href="#people"><span class="stat-tile__label">Stale claims</span><span class="stat-tile__num" id="att-stale">—</span><span class="stat-tile__sub">builders, on the People tab</span></a>
51
+ <a class="stat-tile" href="/roster?claims=stale"><span class="stat-tile__label">Stale claims</span><span class="stat-tile__num" id="att-stale">—</span><span class="stat-tile__sub">builders, on the Builders page</span></a>
52
52
  <a class="stat-tile" href="#operations"><span class="stat-tile__label">Sessions</span><span class="stat-tile__num" id="att-sessions">—</span><span class="stat-tile__sub">recorded, on the Operations tab</span></a>
53
53
  </div>
54
54
 
@@ -66,13 +66,6 @@
66
66
  <div id="gate-body"><span class="kit-skel"></span></div>
67
67
  </section>
68
68
 
69
- <!-- 2. ROSTER -->
70
- <section class="scroll" id="roster-scroll" aria-labelledby="roster-h" hidden>
71
- <h2 id="roster-h" class="scroll__h">Roster</h2>
72
- <p class="scroll__lede">Every builder in {{worldName}} — rank, {{currency}}, karma, and active claims.</p>
73
- <div id="roster-body"><span class="kit-skel"></span></div>
74
- </section>
75
-
76
69
  <!-- 3. REVIEWS BY BUILDER -->
77
70
  <section class="scroll" id="marks-scroll" aria-labelledby="marks-h" hidden>
78
71
  <h2 id="marks-h" class="scroll__h">Reviews by builder</h2>
@@ -175,6 +168,6 @@
175
168
  <script src="/builders/hall-kit.js"></script>
176
169
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
177
170
  <script src="/builders/brand-apply.js?v=2026-06-19-r56"></script>
178
- <script src="/builders/watch.js?v=2026-09-02-ledgers"></script>
171
+ <script src="/builders/watch.js?v=2026-11-05-roster"></script>
179
172
  </body>
180
173
  </html>