@bongos/core 1.21.18 → 1.21.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.bongos-core.json +60 -35
  2. package/docs/copy-inventory.md +5 -5
  3. package/docs/copy-registry.json +6 -6
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +1 -0
  6. package/docs/page-readings.json +196 -197
  7. package/modules/autonomy/governor-docket.js +118 -4
  8. package/modules/economy/cost.js +36 -0
  9. package/modules/economy/governor-docket.js +81 -0
  10. package/modules/economy/routes/cost.js +2 -0
  11. package/modules/government/docket-floors.js +222 -0
  12. package/modules/government/docket-vocab.js +20 -0
  13. package/modules/government/docket.js +186 -5
  14. package/modules/hall-ui/public/street-amend.js +240 -0
  15. package/modules/hall-ui/public/street-card.js +4 -1
  16. package/modules/hall-ui/public/street-inside.js +99 -43
  17. package/modules/hall-ui/public/street.css +8 -0
  18. package/modules/hall-ui/public/street.html +5 -3
  19. package/modules/hall-ui/public/street.js +164 -11
  20. package/modules/hall-ui/public/street.states.json +12 -5
  21. package/modules/ideas/governor-docket.js +42 -1
  22. package/modules/lifecycle/governor-docket.js +197 -2
  23. package/modules/security/routes/reports.js +16 -0
  24. package/modules/security/security.js +5 -2
  25. package/modules/ui-design/kit/fixtures/government-constitution-rules.json +184 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +12 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/governor_docket.mjs +186 -2
  31. package/tests/hall_street.mjs +1 -1
  32. package/tests/hall_street_amend.mjs +336 -0
  33. package/tests/hall_street_inside.mjs +101 -5
  34. package/tests/rank_tier_single_source.mjs +5 -0
@@ -24,20 +24,21 @@
24
24
  // are about the government rather than the sample project). A floor the
25
25
  // governor docket (GET /government/docket) already reads is BOUND to it: a
26
26
  // source (its open matters, their count, needs you when one does) or a
27
- // reading (its figure and line), or, for Planning's goals, the open goals the
28
- // street already holds. Every other floor stands in an honest "not recorded
29
- // yet" state: its name, what it holds, no number. Real data for every floor
30
- // is task 1004527, which fills these same descriptors (floorFor()).
31
- // * Who holds a building and how it decides. The docket does not carry a
32
- // holder yet (task 1004529 sets who decides what, per project); until it
33
- // does the seat is drawn empty and says so, and the rule is the one the
34
- // building's own open matters are decided by, else the design's default,
35
- // named as the default. A docket building that carries `holders` / `rule`
36
- // is read first (holderOf()).
27
+ // reading (its figure and line), or a FLOOR (task 1004527: docket.floors, a
28
+ // state the owning module reads live — who sits, what the month cost, what
29
+ // went live). A floor whose engine does not exist yet comes back `coming`
30
+ // and stands faint with "not recorded yet" and the docket's reason; nothing
31
+ // here ever writes a number the docket did not send.
32
+ // * Who holds a building and how it decides: the building's `decision` row
33
+ // (task 1004529's table) and the `holders` that row seats now. Without a row
34
+ // the seat is drawn empty and says so, and the rule is the building's own
35
+ // matters' rule, else the design's default, named as the default
36
+ // (holderOf()).
37
37
  // * On the table here: the docket's matters at this building. Each row opens
38
38
  // the short card (GovStreet.cardFor(); task 1004528 makes it act).
39
- // * The record: those same matters filed by the month they were opened, one
40
- // drawer a month, newest first.
39
+ // * The record: the building's history (docket.history: rulings, amendments,
40
+ // sign-offs, releases, payouts) and its matters, filed by month, one drawer
41
+ // a month, newest first.
41
42
  // * The annex lot stands when the building is full, as the prototype's
42
43
  // "growth" lines say it would.
43
44
  // * A district's work is the goal's tasks (GET /tasks?goal_id=N): shape from
@@ -268,7 +269,7 @@
268
269
  sym: f ? Object.assign(symbolPath(floorSymbol(f.name)), { c: '#fb9569' }) : { d: '', f: '', c: 'none' },
269
270
  flipA: ((st.picks || 0) % 2) === 0, flipB: ((st.picks || 0) % 2) === 1,
270
271
  openTitle: f ? f.name : '', openLines: f ? f.lines.map((l, i) => ({ key: 'l' + i, text: l[0], meta: l[1] })) : [],
271
- drawers: record.map((r, i) => ({ key: r.label, index: i, label: r.label, count: r.items.length, aria: 'Open the ' + r.label + ' drawer', selected: i === selDrawer, bg: i === selDrawer ? T.wash : 'transparent', border: i === selDrawer ? T.ink : T.ruleStrong, handle: i === selDrawer ? T.ink : T.ruleStrong, color: T.ink })),
272
+ drawers: record.map((r, i) => ({ key: r.label, index: i, label: r.label, count: r.count == null ? r.items.length : r.count, aria: 'Open the ' + r.label + ' drawer', selected: i === selDrawer, bg: i === selDrawer ? T.wash : 'transparent', border: i === selDrawer ? T.ink : T.ruleStrong, handle: i === selDrawer ? T.ink : T.ruleStrong, color: T.ink })),
272
273
  drawerItems: selDrawer >= 0 ? record[selDrawer].items.map((it, i) => ({ key: 'r' + i, text: it[0], meta: it[1] })) : [],
273
274
  onFile: b.onFile, shapes: shapes, texts: texts, syms: syms, floors: floors,
274
275
  lift: { x: X - 38, y: GY - FH + 6, ty: -(selFloor * FH), h: FH - 16 },
@@ -314,7 +315,7 @@
314
315
  // its sample project: what it holds, the rule the design gives it, its
315
316
  // capacity, its growth line and its annex.
316
317
  const GROUPS = Object.freeze({ charter: 'how we decide', rules: 'how we decide', disputes: 'how we decide', people: 'who and what', planning: 'who and what', automation: 'the work', review: 'checks and going live', release: 'checks and going live', treasury: 'money' });
317
- const RULE_WORDS = Object.freeze({ decide: 'one person decides', first: 'first yes wins', consent: 'passes unless someone objects', majority: 'more than half must say yes', unanimous: 'everyone has to agree', signoff: 'signed off by someone who did not build it', rule: 'settled by a rule' });
318
+ const RULE_WORDS = Object.freeze({ decide: 'one person decides', first: 'first yes wins', consent: 'passes unless someone objects', majority: 'more than half must say yes', unanimous: 'everyone has to agree', signoff: 'signed off by someone who did not build it', rule: 'settled by a rule', vacant: 'its matters wait until a seat is filled' });
318
319
 
319
320
  // A floor: [name, what it holds, binding]. A binding is { source }, { reading }
320
321
  // or { goals: true }; no binding is a floor not recorded yet.
@@ -322,12 +323,12 @@
322
323
  charter: {
323
324
  holds: 'The form of government. Seven sections say who holds each building and how it decides.', rule: 'unanimous', cap: 9,
324
325
  growth: 'Room for more sections before the charter needs a second volume.', annex: 'room for a second volume',
325
- floors: [['Founding', 'the starting government'], ['§1 Purpose', 'what this project is for'], ['§2 Membership', 'who belongs, and how they join'], ['§3 The council', 'who sits on the council'], ['§4 Rules', 'how a rule passes'], ['§5 Money', 'who may spend, and how much'], ['§6 Review', 'who signs off finished work'], ['§7 Release', 'who puts work live']],
326
+ floors: [['Founding', 'the starting government', { floor: 'charter.founding' }], ['§1 Purpose', 'what this project is for', { floor: 'charter.purpose' }], ['§2 Membership', 'who belongs, and how they join', { floor: 'charter.membership' }], ['§3 The council', 'who sits on the council', { floor: 'charter.council' }], ['§4 Rules', 'how a rule passes', { floor: 'charter.rules' }], ['§5 Money', 'who may spend, and how much', { floor: 'charter.money' }], ['§6 Review', 'who signs off finished work', { floor: 'charter.review' }], ['§7 Release', 'who puts work live', { floor: 'charter.release' }]],
326
327
  },
327
328
  rules: {
328
329
  holds: 'Standing rules. Each one settles a kind of matter so the council never votes on it again.', rule: 'majority', cap: 8,
329
330
  growth: 'When the hall fills, the council can open an annex for one area, such as money rules.', annex: 'room for an annex, such as money rules',
330
- floors: [['Rules in force', 'the rules settling matters now', { reading: 'rules' }], ['New rules', 'a rule put to the council', { source: 'standing_rules' }]],
331
+ floors: [['Rules in force', 'the rules settling matters now', { floor: 'rules.in_force', reading: 'rules' }], ['New rules', 'a rule put to the council', { source: 'standing_rules' }]],
331
332
  },
332
333
  disputes: {
333
334
  holds: 'Grade appeals, removal requests and security reports. Every ruling becomes precedent for the next one.', rule: 'decide', cap: 5,
@@ -337,32 +338,32 @@
337
338
  people: {
338
339
  holds: 'Who belongs, the rank each person holds, and who sits in which seat.', rule: 'consent', cap: 8,
339
340
  growth: 'A new floor opens whenever the charter creates a new seat.', annex: 'room for an annex of seats',
340
- floors: [['The council', 'who sits, and for how long'], ['Testers', 'who signs off finished work'], ['Builders', 'who builds here', { reading: 'people' }], ['Newcomers', 'builders on newcomer tasks'], ['Waiting to join', 'asked to join the project', { source: 'access_requests' }], ['Ranks', 'the ranks, and who holds each'], ['Goal teams', 'asked to join a goal team', { source: 'goal_join_requests' }]],
341
+ floors: [['The council', 'who sits, and for how long', { floor: 'people.council' }], ['Testers', 'who signs off finished work', { floor: 'people.testers' }], ['Builders', 'who builds here', { reading: 'people' }], ['Newcomers', 'builders on newcomer tasks', { floor: 'people.newcomers' }], ['Waiting to join', 'asked to join the project', { source: 'access_requests' }], ['Ranks', 'the ranks, and who holds each', { floor: 'people.ranks' }], ['Goal teams', 'asked to join a goal team', { source: 'goal_join_requests' }]],
341
342
  },
342
343
  planning: {
343
344
  holds: 'Goals, and the promises that say when each one is done. Kept promises are what the project owes its users.', rule: 'majority', cap: 9,
344
345
  growth: 'When it fills, the next new goal opens a second planning office.', annex: 'room for a second planning office',
345
- floors: [['Goals', 'the open goals', { goals: true }], ['Promises', 'what the goals say they deliver', { reading: 'promises' }], ['Ideas to ratify', 'full ideas waiting for a vote'], ['Priorities', 'the order goals are worked in'], ['Versions', 'what each version holds'], ['Waiting on a decision', 'blockers holding work', { source: 'blockers' }]],
346
+ floors: [['Goals', 'the open goals', { floor: 'planning.goals', goals: true }], ['Promises', 'what the goals say they deliver', { reading: 'promises' }], ['Ideas to ratify', 'full ideas waiting for a vote', { floor: 'planning.ideas' }], ['Priorities', 'the order goals are worked in', { floor: 'planning.priorities' }], ['Versions', 'what each version holds', { floor: 'planning.versions' }], ['Waiting on a decision', 'blockers holding work', { source: 'blockers' }]],
346
347
  },
347
348
  automation: {
348
349
  holds: 'AI builders working on their own. You choose which goals they may build and how much they may spend, and you can stop them at any time.', rule: 'decide', cap: 6,
349
350
  growth: 'A new floor opens for each goal you let it build.', annex: 'room for an annex',
350
- floors: [['The switch', 'unattended building, on or off', { source: 'autobongos_fence' }], ['Allowed goals', 'the goals it may build'], ['Running now', 'builds in progress'], ['Spending limit', 'what it may spend a day'], ['Reports', 'what it did, told without asking']],
351
+ floors: [['The switch', 'unattended building, on or off', { source: 'autobongos_fence' }], ['Allowed goals', 'the goals it may build', { floor: 'automation.allowed_goals' }], ['Running now', 'builds in progress', { floor: 'automation.running' }], ['Spending limit', 'what it may spend', { floor: 'automation.spending_limit' }], ['Reports', 'what it did, told without asking', { floor: 'automation.reports' }]],
351
352
  },
352
353
  review: {
353
354
  holds: 'Testing finished work and signing it off. Work does not count until someone who did not build it says it works.', rule: 'signoff', cap: 5,
354
355
  growth: 'When one queue of testers is not enough, an annex can take design reviews.', vacantGrowth: 'Nothing is built here until the charter says who holds the seat.', annex: 'room for an annex, such as design review',
355
- floors: [['Testers', 'who signs off finished work'], ['Waiting outside', 'finished work waiting for a tester', { source: 'criteria_uat' }], ['Signed off', 'work a tester has signed off']],
356
+ floors: [['Testers', 'who signs off finished work', { floor: 'review.testers' }], ['Waiting outside', 'finished work waiting for a tester', { source: 'criteria_uat', held: 'Being tested' }], ['Signed off', 'work a tester has signed off', { floor: 'review.signed_off' }]],
356
357
  },
357
358
  release: {
358
359
  holds: 'Putting finished work live, and taking updates to Bongos itself.', rule: 'decide', cap: 7,
359
360
  growth: 'Room for a second gate if releases are ever split by area.', annex: 'room for a second gate',
360
- floors: [['Overrides', 'past a failed check or grade', { source: 'override_requests' }], ['Modules', 'listed modules, and their re-checks', { source: 'module_rechecks' }], ['Held back', 'work held from going live'], ['Core update', 'a new version of Bongos', { source: 'core_update' }], ['Live this month', 'what went live'], ['Ready to go', 'signed off, waiting to go live', { source: 'gate_approvals' }]],
361
+ floors: [['Overrides', 'past a failed check or grade', { source: 'override_requests' }], ['Modules', 'listed modules, and their re-checks', { source: 'module_rechecks' }], ['Held back', 'work held from going live', { floor: 'release.held_back' }], ['Core update', 'a new version of Bongos', { source: 'core_update' }], ['Live this month', 'what went live', { floor: 'release.live_month' }], ['Ready to go', 'signed off, waiting to go live', { source: 'gate_approvals' }]],
361
362
  },
362
363
  treasury: {
363
364
  holds: 'The budget, and who gets paid for what.', rule: 'majority', cap: 7,
364
365
  growth: 'Each new spending line takes a floor.', annex: 'room for an annex of spending lines',
365
- floors: [['Builder rewards', 'paid for shipped work'], ['AI grading', 'the reviewer that grades every ship'], ['Hosting', 'servers and storage'], ['Reserve', 'what is left this month', { reading: 'money' }], ['Spending matters', 'spending put to a vote', { source: 'treasury_spending' }]],
366
+ floors: [['Builder rewards', 'paid for shipped work', { floor: 'treasury.rewards' }], ['AI grading', 'the reviewer that grades every ship', { floor: 'treasury.grading' }], ['Hosting', 'servers and storage', { floor: 'treasury.hosting' }], ['Reserve', 'what is left this month', { reading: 'money' }], ['Spending matters', 'spending put to a vote', { source: 'treasury_spending' }]],
366
367
  },
367
368
  });
368
369
  const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
@@ -396,6 +397,18 @@
396
397
  const docket = ctx.docket, now = ctx.now;
397
398
  if (!bind) return F(name, what, '', 'coming', [['What this floor holds is not recorded in Bongos yet.', 'coming']]);
398
399
  if (!docket) return F(name, what, '', 'unavailable', [['The docket could not be read just now.', '']]);
400
+ if (bind.floor) {
401
+ const fl = (Array.isArray(docket.floors) ? docket.floors : []).find((x) => x && x.key === bind.floor);
402
+ if (fl && fl.status === 'live') {
403
+ const titles = new Map((ctx.goals || []).map((g) => [String(g.id), String(g.title || '')]));
404
+ // a line that names a goal by id ('goal 12') is lettered with its title
405
+ const word = (t) => { const m = /^goal (\d+)$/.exec(String(t)); return m && titles.get(m[1]) ? titles.get(m[1]) : String(t); };
406
+ const lines = (Array.isArray(fl.lines) ? fl.lines : []).map((l) => [word(l[0]), String(l[1] || '')]);
407
+ return F(name, String(fl.line || what), fl.count == null ? '' : String(fl.count), fl.hot ? 'pending' : 'ok', lines.length ? lines : [[String(fl.line || what), '']]);
408
+ }
409
+ if (fl) return quietFloor(name, what, fl.status, fl.reason);
410
+ if (!bind.goals && !bind.reading) return F(name, what, '', 'unavailable', [['This could not be read just now.', '']]);
411
+ }
399
412
  if (bind.goals) {
400
413
  const goals = ctx.goals || [];
401
414
  const lines = goals.slice(0, MAX_LINES).map((g) => [String(g.title || 'Goal ' + g.id), 'open']);
@@ -423,36 +436,73 @@
423
436
  return F(name, what, '', 'unavailable', [[String(reason || 'It could not be read just now.'), '']]);
424
437
  }
425
438
 
426
- // Who holds the building and how it decides. A docket building that names
427
- // its holders (task 1004529) is read first; otherwise the seat is empty and
428
- // says so, and the rule is its matters' own, else the design's default (and
429
- // the words say 'by default', so a default is never read as a decision).
430
- function holderOf(key, info, matters) {
439
+ // Who holds the building and how it decides. The building's decision row
440
+ // (task 1004529's table) is read first, with the people it seats now
441
+ // (`holders`); a vacant row is an empty seat that says so. A building with no
442
+ // row falls back to the legacy `holders` / `who` / `rule`, then its matters'
443
+ // own rule, then the design's default (and the words say 'by default', so a
444
+ // default is never read as a decision).
445
+ function holderOf(key, info, matters, viewerId) {
431
446
  const plan = PLAN[key];
432
447
  const holders = info && Array.isArray(info.holders) ? info.holders.filter((h) => h && (h.name || h.login)) : [];
433
- const seats = holders.length ? holders.slice(0, 5).map((h) => String(h.name || h.login).charAt(0).toUpperCase()) : ['—'];
434
- const who = info && info.who ? String(info.who) : holders.length ? holders.map((h) => String(h.name || h.login)).join(', ') : 'who holds it is not set on this project yet';
448
+ const nameOf = (h) => String(h.name || h.login);
449
+ const seatOf = (h) => (viewerId != null && h.id != null && String(h.id) === String(viewerId) ? 'you' : nameOf(h).charAt(0).toUpperCase());
450
+ const d = info && info.decision && typeof info.decision === 'object' ? info.decision : null;
451
+ if (d) {
452
+ if (d.rule === 'vacant') return { seats: ['—'], who: 'no one holds it yet', rule: 'vacant', how: RULE_WORDS.vacant };
453
+ const rule = RULE_WORDS[d.rule] ? d.rule : plan.rule;
454
+ const n = info.holder_count == null ? holders.length : Number(info.holder_count) || 0;
455
+ const named = holders.slice(0, 3).map(nameOf);
456
+ const people = !n || !named.length ? '' : n <= 3 ? (named.length > 1 ? named.slice(0, -1).join(', ') + ' and ' + named[named.length - 1] : named[0]) : named.join(', ') + ' and ' + (n - 3) + ' more';
457
+ return {
458
+ seats: holders.length ? holders.slice(0, 5).map(seatOf) : ['—'],
459
+ who: people ? people + ' · ' + String(d.who || '') : String(d.who || 'who holds it is not set on this project yet'),
460
+ rule: rule,
461
+ how: RULE_WORDS[rule] + (RULE_WORDS[d.rule] ? '' : ' · by default'),
462
+ };
463
+ }
464
+ const seats = holders.length ? holders.slice(0, 5).map(seatOf) : ['—'];
465
+ const who = info && info.who ? String(info.who) : holders.length ? holders.map(nameOf).join(', ') : 'who holds it is not set on this project yet';
435
466
  const fromMatter = matters.find((m) => m && RULE_WORDS[m.rule]);
436
467
  const rule = info && RULE_WORDS[info.rule] ? info.rule : fromMatter ? fromMatter.rule : plan.rule;
437
468
  const how = RULE_WORDS[rule] + (info && RULE_WORDS[info.rule] ? '' : fromMatter ? '' : ' · by default');
438
469
  return { seats: seats, who: who, rule: rule, how: how };
439
470
  }
440
471
 
441
- // The record: the building's matters filed by the month each was opened.
442
- function recordOf(matters, now) {
472
+ // The record: the building's history (what closed, dated by when it closed)
473
+ // and its matters (dated by when each was opened), filed by month, newest
474
+ // drawer first. A settled sitting that is also in the history is filed once.
475
+ // A drawer shows MAX_DRAWER items and says how many more; its count is all.
476
+ const MAX_DRAWER = 12;
477
+ function recordOf(matters, now, history) {
443
478
  const by = new Map();
444
- matters.forEach((m) => {
445
- const t = m.opened_at ? new Date(m.opened_at) : null;
479
+ const file = (at, item) => {
480
+ const t = at ? new Date(at) : null;
446
481
  const ok = t && !isNaN(t.getTime());
447
482
  const k = ok ? t.getUTCFullYear() * 12 + t.getUTCMonth() : -1;
448
483
  if (!by.has(k)) by.set(k, []);
449
- by.get(k).push([String(m.title || m.short || ''), STATE_WORDS[stateOf(m)]]);
484
+ by.get(k).push(item);
485
+ };
486
+ const filed = new Set();
487
+ (history || []).forEach((h) => {
488
+ const m = /:(?:board|amendment|founding):(.+)$/.exec(String(h.id || ''));
489
+ if (m) filed.add(m[1]);
490
+ });
491
+ (history || []).forEach((h) => file(h.at, [String(h.title || ''), String(h.outcome || '')]));
492
+ matters.forEach((m) => {
493
+ if (m.ref && m.ref.type === 'board_item' && filed.has(String(m.ref.id))) return;
494
+ file(m.opened_at, [String(m.title || m.short || ''), STATE_WORDS[stateOf(m)]]);
450
495
  });
451
496
  const thisYear = new Date(now || 0).getUTCFullYear();
452
- return [...by.keys()].sort((a, b) => b - a).map((k) => ({
453
- label: k < 0 ? 'undated' : MONTHS[k % 12] + (Math.floor(k / 12) === thisYear ? '' : ' ' + String(Math.floor(k / 12)).slice(2)),
454
- items: by.get(k),
455
- }));
497
+ return [...by.keys()].sort((a, b) => b - a).map((k) => {
498
+ const all = by.get(k);
499
+ const items = all.slice(0, MAX_DRAWER);
500
+ if (all.length > MAX_DRAWER) items.push(['and ' + (all.length - MAX_DRAWER) + ' more', '']);
501
+ return {
502
+ label: k < 0 ? 'undated' : MONTHS[k % 12] + (Math.floor(k / 12) === thisYear ? '' : ' ' + String(Math.floor(k / 12)).slice(2)),
503
+ items: items, count: all.length,
504
+ };
505
+ });
456
506
  }
457
507
 
458
508
  // The whole building as civicInfo() shaped it, from the real feeds.
@@ -464,21 +514,27 @@
464
514
  const c = { key: key, docket: docket, goals: (ctx && ctx.goals) || [], now: (ctx && ctx.now) || 0 };
465
515
  const matters = docket ? docket.matters.filter((m) => m && m.building === key) : [];
466
516
  const info = docket && Array.isArray(docket.buildings) ? docket.buildings.find((b) => b && b.key === key) : null;
467
- const h = holderOf(key, info, matters);
468
- const vacant = key === 'review' && !S.reviewHeldOf(docket);
469
- const floors = plan.floors.map((spec) => floorFor(spec, c));
517
+ const h = holderOf(key, info, matters, docket && docket.viewer ? docket.viewer.id : null);
518
+ const vacant = key === 'review' && (!S.reviewHeldOf(docket) || !!(info && info.decision && info.decision.rule === 'vacant'));
519
+ // a held Review's waiting floor is the prototype's 'Being tested'
520
+ const floors = plan.floors.map((spec) => {
521
+ const fl = floorFor(spec, c);
522
+ if (!vacant && spec[2] && spec[2].held) fl.name = spec[2].held;
523
+ return fl;
524
+ });
470
525
  const order = { needs: 0, moving: 1, settled: 2 };
471
526
  const table = matters.slice().sort((a, b) => order[stateOf(a)] - order[stateOf(b)]).map((m) => {
472
527
  const s = stateOf(m), when = S.whenWord(m.deadline, c.now);
473
528
  return { key: String(m.id), matter: String(m.id), label: String(m.title || m.short || ''), status: s === 'needs' ? 'needs you' + (when ? ' · ' + when : '') : s === 'moving' ? 'moving' + (when ? ' · ' + when : '') : (when || 'settled'), state: s, color: s === 'needs' ? 'accent' : 'ink' };
474
529
  });
475
- const record = recordOf(matters, c.now);
530
+ const history = docket && Array.isArray(docket.history) ? docket.history.filter((x) => x && x.building === key) : [];
531
+ const record = recordOf(matters, c.now, history);
476
532
  return {
477
533
  key: key, group: GROUPS[key], name: info && info.name ? String(info.name) : key.charAt(0).toUpperCase() + key.slice(1), holds: plan.holds,
478
534
  seats: h.seats, who: h.who, rule: h.rule, how: h.how, cap: plan.cap,
479
535
  growth: vacant ? plan.vacantGrowth : plan.growth,
480
536
  floors: floors, record: record,
481
- onFile: !docket ? 'the docket could not be read just now' : matters.length ? matters.length + (matters.length === 1 ? ' matter' : ' matters') + ' on the docket' : 'nothing on file yet',
537
+ onFile: !docket ? 'the docket could not be read just now' : matters.length ? matters.length + (matters.length === 1 ? ' matter' : ' matters') + ' on the docket' + (history.length ? ' · ' + history.length + ' on the record' : '') : history.length ? history.length + ' on the record' : 'nothing on file yet',
482
538
  table: table, annex: !vacant && floors.length >= plan.cap ? plan.annex : null, vacant: vacant,
483
539
  };
484
540
  }
@@ -240,6 +240,13 @@ html:has(body[data-page="street"]) { height: 100%; }
240
240
  .inside-glyph path + path { fill: none; stroke: var(--ink); stroke-width: 1.4; }
241
241
  .inside-textbtn { background: none; border: 0; padding: 6px 0; min-height: 24px; text-decoration: underline; text-underline-offset: 4px; font-family: var(--font-body); font-size: 14px; cursor: pointer; text-align: left; color: var(--street-accent-text); flex: none; }
242
242
  .inside-note { margin: 0; font-size: 13px; line-height: 1.45; color: var(--ink-soft); }
243
+ /* change how this works: the drafted amendment (task 1004530) — the struck and
244
+ inserted words, then who, how and why, on the card's own form pieces */
245
+ .inside-draft { display: flex; flex-direction: column; gap: 12px; padding: 14px 0 4px; border-top: 1px solid var(--street-rule); }
246
+ .inside-draft[hidden] { display: none; }
247
+ .inside-draft__change { gap: 6px; }
248
+ .inside-draft__picks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
249
+ .inside-draft__fixed { font-size: 14px; line-height: 1.45; color: var(--ink-soft); min-height: 36px; display: flex; align-items: center; }
243
250
  .inside-meter { display: flex; flex-direction: column; gap: 6px; }
244
251
  .inside-meter__track { height: 6px; border-radius: 999px; background: var(--street-wash); overflow: hidden; }
245
252
  .inside-meter__fill { height: 6px; width: 0; background: color-mix(in srgb, var(--ink) 30%, var(--bg)); }
@@ -284,6 +291,7 @@ html:has(body[data-page="street"]) { height: 100%; }
284
291
 
285
292
  /* narrow: the drawing in its own sideways strip, the panel below it */
286
293
  @media (max-width: 899px) {
294
+ .inside-draft__picks { grid-template-columns: 1fr; }
287
295
  .inside { display: block; overflow-x: hidden; overflow-y: auto; }
288
296
  .inside-sizer { width: auto !important; height: auto !important; margin: 0; overflow: visible; }
289
297
  .inside-board { position: static; width: auto; height: auto; transform: none; display: flex; flex-direction: column; gap: 16px; padding: 8px 16px 24px; }
@@ -138,9 +138,10 @@
138
138
  <svg class="inside-glyph" width="18" height="18" aria-hidden="true" focusable="false"><path id="insideGlyphF" d=""></path><path id="insideGlyphL" d=""></path></svg>
139
139
  <span class="inside-gov__text" id="insideHow"></span>
140
140
  <span class="inside-gov__fill"></span>
141
- <button type="button" class="inside-textbtn" id="insideAmend" aria-describedby="insideAmendNote">change how this works</button>
141
+ <button type="button" class="inside-textbtn" id="insideAmend" aria-expanded="false" aria-controls="insideDraft">change how this works</button>
142
142
  </div>
143
143
  <p class="inside-note" id="insideAmendNote" role="status" hidden></p>
144
+ <div class="inside-draft" id="insideDraft" hidden></div>
144
145
  <div class="inside-meter">
145
146
  <div class="inside-meter__track"><div class="inside-meter__fill" id="insideFill"></div></div>
146
147
  <span class="inside-meter__line" id="insideGrowth"></span>
@@ -188,8 +189,9 @@
188
189
 
189
190
  <script src="/builders/city-draw.js?v=2026-10-02-street1"></script>
190
191
  <script src="/builders/street-draw.js?v=2026-10-02-street1"></script>
191
- <script src="/builders/street-card.js?v=2026-10-02-card1"></script>
192
+ <script src="/builders/street-card.js?v=2026-10-02-amend1"></script>
192
193
  <script src="/builders/street-inside.js?v=2026-10-02-inside1"></script>
193
- <script src="/builders/street.js?v=2026-10-02-inside1"></script>
194
+ <script src="/builders/street-amend.js?v=2026-10-02-amend1"></script>
195
+ <script src="/builders/street.js?v=2026-10-02-amend1"></script>
194
196
  </body>
195
197
  </html>
@@ -362,7 +362,7 @@
362
362
  function drawInside(h, mode) {
363
363
  var box = $('streetInside');
364
364
  var same = view.inside && view.inside.kind === h.kind && view.inside.key === h.key;
365
- if (!same) view.inside = { kind: h.kind, key: h.key, floor: null, drawer: null, picks: 0, note: false };
365
+ if (!same) view.inside = { kind: h.kind, key: h.key, floor: null, drawer: null, picks: 0, draft: null };
366
366
  var arriving = box.hidden;
367
367
  box.hidden = false;
368
368
  box.setAttribute('data-kind', h.kind);
@@ -448,8 +448,7 @@
448
448
  if (gf) gf.setAttribute('d', bv.gf || '');
449
449
  if (gl) gl.setAttribute('d', bv.gl || '');
450
450
  setText('insideHow', bv.howText);
451
- var note = $('insideAmendNote');
452
- if (note) { note.textContent = st.note ? AMEND_NOTE : ''; note.hidden = !st.note; }
451
+ drawDraft(false);
453
452
  var fill = $('insideFill');
454
453
  if (fill) { fill.style.width = bv.fill + '%'; fill.className = 'inside-meter__fill' + (bv.fill >= 80 ? ' inside-meter__fill--full' : ''); }
455
454
  setText('insideGrowth', bv.growth);
@@ -528,14 +527,167 @@
528
527
  for (var k = 0; k < kids.length; k++) if (kids[k].getAttribute('data-floor') === String(i)) { if (kids[k].focus) kids[k].focus(); break; }
529
528
  }
530
529
 
531
- // Change how this works: drafting the amendment is task 1004530. Until then
532
- // the button is present and says so, rather than doing nothing silently.
533
- var AMEND_NOTE = 'Drafting an amendment from here is not built yet. For now, raise it with the council on the board.';
530
+ // ---------- change how this works (task 1004530) ----------
531
+ // The button opens a drafted amendment to this building's row of the
532
+ // who-decides-what table: GovAmend (street-amend.js) builds the draft from the
533
+ // constitution as it stands (read fresh, so the struck words are the rule in
534
+ // force), this draws it — the struck and inserted words, who, which rule, why
535
+ // — and files it. The server's permission checks decide; a refusal is said in
536
+ // the draft, plainly. Filed, the street answers: a toast, back to the street,
537
+ // the Charter lights and its new pin settles. No settings page anywhere.
534
538
  function amend() {
535
- if (!view.inside) return;
536
- view.inside.note = true;
537
- var note = $('insideAmendNote');
538
- if (note) { note.textContent = AMEND_NOTE; note.hidden = false; }
539
+ var st = view.inside;
540
+ if (!st || st.kind !== 'building') return Promise.resolve(false);
541
+ if (st.draft) { closeDraft(); return Promise.resolve(false); }
542
+ var dr = st.draft = { d: null, error: '', busy: false, reading: true, rev: 1, drawn: 0 };
543
+ drawDraft(true);
544
+ return getJson(FEEDS.constitution).then(function (c) {
545
+ if (view.inside !== st || st.draft !== dr) return false;
546
+ if (c && c.board) data.constitution = c;
547
+ var A = window.GovAmend;
548
+ dr.reading = false;
549
+ dr.d = A ? A.draftFor(st.key, c && c.board ? c : null) : { ok: false, reason: 'unread' };
550
+ dr.rev++;
551
+ drawDraft(true);
552
+ var who = $('insideDraftWho');
553
+ if (who && who.focus) who.focus();
554
+ return !!(dr.d && dr.d.ok);
555
+ });
556
+ }
557
+ function closeDraft() {
558
+ var st = view.inside;
559
+ if (!st || !st.draft) return;
560
+ st.draft = null;
561
+ drawDraft(true);
562
+ var am = $('insideAmend');
563
+ if (am && am.focus) am.focus();
564
+ }
565
+ function bumpDraft(focusId) {
566
+ var dr = view.inside && view.inside.draft;
567
+ if (!dr) return;
568
+ dr.rev++;
569
+ drawDraft(true);
570
+ var el = focusId ? $(focusId) : null;
571
+ if (el && el.focus) el.focus();
572
+ }
573
+ function draftSelect(id, list, value, onPick) {
574
+ var sel = node('select', 'mcard-form__select');
575
+ sel.setAttribute('id', id);
576
+ list.forEach(function (o) {
577
+ var op = node('option', '', o.words);
578
+ op.setAttribute('value', o.value);
579
+ if (o.value === value) op.setAttribute('selected', '');
580
+ sel.appendChild(op);
581
+ });
582
+ sel.value = value == null ? '' : value;
583
+ sel.addEventListener('change', function () { onPick(sel.value); });
584
+ return sel;
585
+ }
586
+ function draftField(labelText, forId, control) {
587
+ var f = node('div', 'mcard-form');
588
+ var l = node(forId ? 'label' : 'span', 'mcard-form__label', labelText);
589
+ if (forId) l.setAttribute('for', forId);
590
+ f.appendChild(l);
591
+ f.appendChild(control);
592
+ return f;
593
+ }
594
+ // Draw the draft into #insideDraft. A redraw of the building leaves an
595
+ // unchanged draft alone (force false), so a feed landing mid-sentence never
596
+ // takes the governor's place in the form.
597
+ function drawDraft(force) {
598
+ var st = view.inside, box = $('insideDraft'), note = $('insideAmendNote'), am = $('insideAmend');
599
+ var dr = st && st.draft;
600
+ if (am) am.setAttribute('aria-expanded', dr ? 'true' : 'false');
601
+ if (note) {
602
+ var said = !dr ? '' : dr.reading ? 'Reading how this building decides…' : dr.d && dr.d.ok ? '' : 'How this building decides could not be read just now.';
603
+ note.textContent = said;
604
+ note.hidden = !said;
605
+ }
606
+ if (!box) return;
607
+ if (!dr || !dr.d || !dr.d.ok) { box.hidden = true; clear(box); return; }
608
+ if (!force && dr.drawn === dr.rev) return;
609
+ dr.drawn = dr.rev;
610
+ var A = window.GovAmend, d = dr.d;
611
+ clear(box);
612
+ box.hidden = false;
613
+ box.appendChild(node('span', 'street-label', 'drafted amendment · ' + d.name));
614
+ var rows = A.change(d);
615
+ var ch = node('div', 'mcard-change inside-draft__change');
616
+ if (rows.length) {
617
+ rows.forEach(function (r) {
618
+ var p = node('p', 'mcard-change__row');
619
+ p.appendChild(node('span', 'mcard-change__label', r.label));
620
+ if (r.struck) p.appendChild(node('del', 'mcard-del', r.struck));
621
+ p.appendChild(node('ins', 'mcard-ins', r.inserted));
622
+ ch.appendChild(p);
623
+ });
624
+ } else ch.appendChild(node('p', 'inside-note', 'This is how it works today. Pick who decides, or how, to change it.'));
625
+ box.appendChild(ch);
626
+ var picks = node('div', 'inside-draft__picks');
627
+ picks.appendChild(draftField('who decides', 'insideDraftWho', draftSelect('insideDraftWho', d.whoOptions, d.decided_by, function (v) {
628
+ dr.d = A.pick(dr.d, 'who', v); dr.error = ''; bumpDraft('insideDraftWho');
629
+ })));
630
+ if (d.ruleOptions.length) {
631
+ picks.appendChild(draftField('how they decide', 'insideDraftRule', draftSelect('insideDraftRule', d.ruleOptions, d.rule, function (v) {
632
+ dr.d = A.pick(dr.d, 'rule', v); dr.error = ''; bumpDraft('insideDraftRule');
633
+ })));
634
+ } else {
635
+ picks.appendChild(draftField('how they decide', null, node('span', 'inside-draft__fixed', d.decided_by === 'nobody' ? 'nothing is decided until someone holds it' : 'decides alone, as today')));
636
+ }
637
+ box.appendChild(picks);
638
+ var why = node('textarea', 'mcard-form__text');
639
+ why.setAttribute('id', 'insideDraftWhy');
640
+ why.setAttribute('rows', '3');
641
+ why.setAttribute('maxlength', '5000');
642
+ why.value = d.reason;
643
+ why.addEventListener('input', function () { dr.d = A.pick(dr.d, 'reason', why.value); });
644
+ box.appendChild(draftField('why', 'insideDraftWhy', why));
645
+ box.appendChild(node('p', 'inside-note', 'Filed, it goes on the Charter and the council votes on it there.'));
646
+ if (dr.error) {
647
+ var err = node('p', 'mcard-error', dr.error);
648
+ err.setAttribute('role', 'alert');
649
+ err.setAttribute('id', 'insideDraftError');
650
+ box.appendChild(err);
651
+ }
652
+ var acts = node('div', 'mcard-actions');
653
+ var file = node('button', 'mcard-btn mcard-btn--primary', dr.busy ? 'filing…' : 'file the amendment');
654
+ file.setAttribute('type', 'button');
655
+ file.setAttribute('id', 'insideDraftFile');
656
+ if (dr.busy) file.setAttribute('disabled', '');
657
+ file.addEventListener('click', fileDraft);
658
+ acts.appendChild(file);
659
+ var not = node('button', 'mcard-btn mcard-btn--quiet', 'not now');
660
+ not.setAttribute('type', 'button');
661
+ not.addEventListener('click', closeDraft);
662
+ acts.appendChild(not);
663
+ box.appendChild(acts);
664
+ }
665
+ function fileDraft() {
666
+ var A = window.GovAmend, st = view.inside, dr = st && st.draft;
667
+ if (!A || !dr || !dr.d || dr.busy) return Promise.resolve(false);
668
+ var why = $('insideDraftWhy');
669
+ if (why && why.value != null) dr.d = A.pick(dr.d, 'reason', why.value);
670
+ var refused = A.check(dr.d);
671
+ if (refused) { dr.error = refused; bumpDraft(refused.indexOf('Say why') === 0 ? 'insideDraftWhy' : 'insideDraftWho'); return Promise.resolve(false); }
672
+ dr.error = '';
673
+ dr.busy = true;
674
+ bumpDraft(null);
675
+ return send(A.request(dr.d)).then(function (out) {
676
+ dr.busy = false;
677
+ if (!out.ok) {
678
+ dr.error = A.refusal(out.status, out.body);
679
+ if (view.inside === st && st.draft === dr) bumpDraft('insideDraftFile');
680
+ return false;
681
+ }
682
+ var a = A.answer(dr.d, out.body);
683
+ say(a.toast);
684
+ if (view.inside === st) st.draft = null;
685
+ card.answer = { building: a.building, matter: a.matter, kind: a.kind, at: Date.now() };
686
+ data.room = null;
687
+ data.constitution = null;
688
+ goStreet();
689
+ return load().then(function () { return true; });
690
+ });
539
691
  }
540
692
 
541
693
  function drawDistrict(mode) {
@@ -744,6 +896,7 @@
744
896
 
745
897
  function onKey(e) {
746
898
  if (e.key === 'Escape' && view.open) { e.preventDefault(); closeCard(true); }
899
+ else if (e.key === 'Escape' && view.inside && view.inside.draft) { e.preventDefault(); closeDraft(); }
747
900
  }
748
901
 
749
902
  function boot() {
@@ -767,6 +920,6 @@
767
920
  return load();
768
921
  }
769
922
 
770
- window.GovStreetPage = { boot: boot, draw: draw, load: load, fit: fit, openCard: openCard, closeCard: closeCard, actOn: actOn, pickFloor: pickFloor, goStreet: goStreet, FEEDS: FEEDS, THEME_KEY: THEME_KEY };
923
+ window.GovStreetPage = { boot: boot, draw: draw, load: load, fit: fit, openCard: openCard, closeCard: closeCard, actOn: actOn, pickFloor: pickFloor, goStreet: goStreet, amend: amend, fileDraft: fileDraft, FEEDS: FEEDS, THEME_KEY: THEME_KEY };
771
924
  window.GovStreetPage.ready = boot();
772
925
  }());
@@ -3,7 +3,10 @@
3
3
  "page": "/builders/street",
4
4
  "surface": "hall-ui",
5
5
  "stub": {
6
- "prefix": "/builders/"
6
+ "prefix": "/builders/",
7
+ "fixtures": {
8
+ "government-constitution": "government-constitution-rules"
9
+ }
7
10
  },
8
11
  "modeQuery": false,
9
12
  "ignoreRequests": [
@@ -311,8 +314,8 @@
311
314
  },
312
315
  "amend": {
313
316
  "auth": true,
314
- "_": "Change how this works pressed: it says, in words, that drafting an amendment from here is not built yet (task 1004530).",
315
- "url": "/builders/street#building=charter",
317
+ "_": "Change how this works pressed inside Review: the drafted amendment to its row, the rule in force struck and the proposed one inserted, who and how to pick, why, and file (task 1004530).",
318
+ "url": "/builders/street#building=review",
316
319
  "actions": [
317
320
  [
318
321
  "wait",
@@ -324,12 +327,16 @@
324
327
  ],
325
328
  [
326
329
  "wait",
327
- 200
330
+ 600
328
331
  ]
329
332
  ],
330
333
  "expect": {
331
334
  "visible": [
332
- "#insideAmendNote"
335
+ "#insideDraft",
336
+ "#insideDraftWho",
337
+ "#insideDraftRule",
338
+ "#insideDraftWhy",
339
+ "#insideDraftFile"
333
340
  ]
334
341
  }
335
342
  },