@bongos/core 1.21.49 → 1.21.51

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.
@@ -338,7 +338,9 @@ function countStates(matters) {
338
338
  function homeLine(counts) {
339
339
  if (!counts.needs && !counts.moving) return 'nothing waits on you.';
340
340
  const parts = [];
341
- if (counts.needs) parts.push(`${counts.needs} ${counts.needs === 1 ? 'matter waits' : 'matters wait'} on you`);
341
+ // with nothing on you but work still moving, the design says so first
342
+ // ("nothing waits on you · 2 moving"), task 1004531
343
+ parts.push(counts.needs ? `${counts.needs} ${counts.needs === 1 ? 'matter waits' : 'matters wait'} on you` : 'nothing waits on you');
342
344
  if (counts.moving) parts.push(`${counts.moving} moving`);
343
345
  return parts.join(' · ');
344
346
  }
@@ -272,7 +272,12 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
272
272
  .inside-draft { display: flex; flex-direction: column; gap: 12px; padding: 14px 0 4px; border-top: 1px solid var(--street-rule); }
273
273
  .inside-draft[hidden] { display: none; }
274
274
  .inside-draft__change { gap: 6px; }
275
- .inside-draft__picks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
275
+ .inside-draft__picks { display: grid; grid-template-columns: 1fr; gap: 12px; }
276
+ /* who and how are one-tap chips (task 1004531); a long choice wraps inside its
277
+ chip. Scoped under .inside-draft so they outrank the desk chip rules below. */
278
+ .inside-draft__chips { display: flex; flex-wrap: wrap; gap: 6px; }
279
+ .inside-draft .inside-draft__chip { height: auto; min-height: 30px; padding: 5px 14px; white-space: normal; text-align: left; line-height: 1.3; }
280
+ .inside-draft .inside-draft__chip--on { background: var(--ink); border-color: var(--ink); color: var(--bg); }
276
281
  .inside-draft__fixed { font-size: 14px; line-height: 1.45; color: var(--ink-soft); min-height: 36px; display: flex; align-items: center; }
277
282
  .inside-meter { display: flex; flex-direction: column; gap: 6px; }
278
283
  .inside-meter__track { height: 6px; border-radius: 999px; background: var(--street-wash); overflow: hidden; }
@@ -382,7 +387,6 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
382
387
 
383
388
  /* narrow: the drawing in its own sideways strip, the panel below it */
384
389
  @media (max-width: 899px) {
385
- .inside-draft__picks { grid-template-columns: 1fr; }
386
390
  .inside { display: block; overflow-x: hidden; overflow-y: auto; }
387
391
  .inside-sizer { width: auto !important; height: auto !important; margin: 0; overflow: visible; }
388
392
  .inside-board { position: static; width: auto; height: auto; transform: none; display: flex; flex-direction: column; gap: 16px; padding: 8px 16px 24px; }
@@ -90,6 +90,7 @@
90
90
  var desk = { done: {}, mine: {}, cleared: {}, order: {}, open: null, ask: null, busy: false, roomAsked: false, model: null };
91
91
  var TODAY_KEY = 'gov-desk-today';
92
92
  var today = { day: '', n: 0 };
93
+ var todayDrawn = null; // the count the meter's pips last drew: they are rebuilt only when it moves
93
94
 
94
95
  // ---------- light / dark ----------
95
96
  var chosen = null;
@@ -811,9 +812,13 @@
811
812
  var D = window.GovDesk, pipsEl = $('streetTodayPips');
812
813
  if (!D || !pipsEl) return;
813
814
  var t = D.todayPips(today.n);
815
+ setText('streetTodayN', t.text);
816
+ // the pips are rebuilt only when the count moves, so the newest pops once
817
+ // and a redraw neither replays nor cuts its pop short (task 1004531)
818
+ if (todayDrawn === today.n && pipsEl.children && pipsEl.children.length) return;
819
+ todayDrawn = today.n;
814
820
  clear(pipsEl);
815
821
  t.pips.forEach(function (p) { pipsEl.appendChild(node('span', 'street-today__pip' + (p.on ? ' street-today__pip--on' : '') + (p.fresh ? ' desk-pop' : ''))); });
816
- setText('streetTodayN', t.text);
817
822
  }
818
823
 
819
824
  function pickFloor(i) {
@@ -850,8 +855,7 @@
850
855
  dr.d = A ? A.draftFor(st.key, c && c.board ? c : null) : { ok: false, reason: 'unread' };
851
856
  dr.rev++;
852
857
  drawDraft(true);
853
- var who = $('insideDraftWho');
854
- if (who && who.focus) who.focus();
858
+ focusDraft('insideDraftWho');
855
859
  return !!(dr.d && dr.d.ok);
856
860
  });
857
861
  }
@@ -868,26 +872,42 @@
868
872
  if (!dr) return;
869
873
  dr.rev++;
870
874
  drawDraft(true);
875
+ focusDraft(focusId);
876
+ }
877
+ // Focus one control of the draft; a group of chips takes it on its picked chip.
878
+ function focusDraft(focusId) {
871
879
  var el = focusId ? $(focusId) : null;
880
+ if (el && el.getAttribute && el.getAttribute('role') === 'radiogroup') {
881
+ var kids = el.children || [], pick = kids[0] || null;
882
+ for (var i = 0; i < kids.length; i++) if (kids[i].getAttribute('aria-checked') === 'true') { pick = kids[i]; break; }
883
+ el = pick;
884
+ }
872
885
  if (el && el.focus) el.focus();
873
886
  }
874
- function draftSelect(id, list, value, onPick) {
875
- var sel = node('select', 'mcard-form__select');
876
- sel.setAttribute('id', id);
887
+ // A draft choice is one tap (the owner's line rule: choices are one-tap
888
+ // chips, task 1004531): a radio group of chips, the picked one filled.
889
+ function draftChips(id, list, value, onPick) {
890
+ var g = node('div', 'inside-draft__chips');
891
+ g.setAttribute('id', id);
892
+ g.setAttribute('role', 'radiogroup');
893
+ g.setAttribute('aria-labelledby', id + 'Label');
877
894
  list.forEach(function (o) {
878
- var op = node('option', '', o.words);
879
- op.setAttribute('value', o.value);
880
- if (o.value === value) op.setAttribute('selected', '');
881
- sel.appendChild(op);
895
+ var on = o.value === value;
896
+ var b = node('button', 'desk-chip desk-chip--choice inside-draft__chip' + (on ? ' inside-draft__chip--on' : ''), o.words);
897
+ b.setAttribute('type', 'button');
898
+ b.setAttribute('role', 'radio');
899
+ b.setAttribute('aria-checked', on ? 'true' : 'false');
900
+ b.setAttribute('data-value', o.value);
901
+ b.addEventListener('click', function () { if (!on) onPick(o.value); });
902
+ g.appendChild(b);
882
903
  });
883
- sel.value = value == null ? '' : value;
884
- sel.addEventListener('change', function () { onPick(sel.value); });
885
- return sel;
904
+ return g;
886
905
  }
887
- function draftField(labelText, forId, control) {
906
+ function draftField(labelText, forId, control, group) {
888
907
  var f = node('div', 'mcard-form');
889
- var l = node(forId ? 'label' : 'span', 'mcard-form__label', labelText);
890
- if (forId) l.setAttribute('for', forId);
908
+ var l = node(forId && !group ? 'label' : 'span', 'mcard-form__label', labelText);
909
+ if (forId && !group) l.setAttribute('for', forId);
910
+ if (group) l.setAttribute('id', forId + 'Label');
891
911
  f.appendChild(l);
892
912
  f.appendChild(control);
893
913
  return f;
@@ -925,13 +945,13 @@
925
945
  } else ch.appendChild(node('p', 'inside-note', 'This is how it works today. Pick who decides, or how, to change it.'));
926
946
  box.appendChild(ch);
927
947
  var picks = node('div', 'inside-draft__picks');
928
- picks.appendChild(draftField('who decides', 'insideDraftWho', draftSelect('insideDraftWho', d.whoOptions, d.decided_by, function (v) {
948
+ picks.appendChild(draftField('who decides', 'insideDraftWho', draftChips('insideDraftWho', d.whoOptions, d.decided_by, function (v) {
929
949
  dr.d = A.pick(dr.d, 'who', v); dr.error = ''; bumpDraft('insideDraftWho');
930
- })));
950
+ }), true));
931
951
  if (d.ruleOptions.length) {
932
- picks.appendChild(draftField('how they decide', 'insideDraftRule', draftSelect('insideDraftRule', d.ruleOptions, d.rule, function (v) {
952
+ picks.appendChild(draftField('how they decide', 'insideDraftRule', draftChips('insideDraftRule', d.ruleOptions, d.rule, function (v) {
933
953
  dr.d = A.pick(dr.d, 'rule', v); dr.error = ''; bumpDraft('insideDraftRule');
934
- })));
954
+ }), true));
935
955
  } else {
936
956
  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')));
937
957
  }
@@ -1210,6 +1230,8 @@
1210
1230
  card.pick = null;
1211
1231
  card.fresh = key === 'for' || key === 'object' ? 'you' : null;
1212
1232
  if (a.building && a.kind) card.answer = { building: a.building, matter: cv.key, kind: a.kind, at: Date.now() };
1233
+ // a decision from a card is a decision today, as one from a desk row is (task 1004531)
1234
+ if (recorded) { bumpToday(); drawToday(); }
1213
1235
  if (a.close) closeCard(true);
1214
1236
  else if (view.open) { fillCard(); var c = $('streetCardClose'); if (c && c.focus) c.focus(); }
1215
1237
  if (!recorded) return true;
@@ -47,3 +47,24 @@ Every building and every floor desk was walked in light, dark and at phone width
47
47
  - **The desk's toast is the board's**: an ink pill with the orange dot, low over the desk. The street's own toast is centred again (its enter animation had undone the centring).
48
48
 
49
49
  Left as found, on purpose: the six readings stay under a building (task 1004526 marks the building's own reading), the old month drawers stay under the desk, and the recording sign-off still goes to its page (an open owner choice).
50
+
51
+ ## The whole city, walked against the prototype (task 1004531)
52
+
53
+ The UAT walkthrough of goal 1000125: open the home city, start governing, find what needs you, enter every building and district, decide a vote from its card and watch the city change, and draft an amendment from a building, in light and dark at 1440 and at phone width (390 px), side by side with `Street.dc.html` (the prototype), `Desks.dc.html` and `Ground.dc.html`, judged by the owner's line rule. The live site is behind its password gate, so the walk ran on the real pages in the hall preview harness (`--fixture-me`). **The real-vote step was done in the harness only**: the vote went to the harness's canned answer for `POST /government/board/items/:id/votes`, and the docket read after it was stubbed in the page to show what the server would now say; no vote was cast on the live site. The amendment was filed the same way (the page's `POST /government/board/decision-rules` answered in the page, the harness refuses writes). The district interior was walked over twelve stand-in tasks answered in the page, since the harness serves one task list for every goal. Screenshots stay out of the repo: they are in the session scratchpad, `walk-1004531/` (53 shots).
54
+
55
+ What it found and fixed:
56
+
57
+ - **The preview fixtures disagreed with themselves.** The docket fixture's counts, home line, per-building counts and fairness reading had been written for the first seven matters and never re-derived as later tasks added more, so the city said "3 matters wait on you", the street header 4, its pins 13, and Disputes read "0 open" under two matters that need you. `buildDocket` derives all four from the matters, so live they cannot disagree; the fixture now holds the same derivation, and `tests/hall_preview_governor_fixtures.mjs` holds it to it.
58
+ - **Change how this works could not be walked.** The constitution fixture had no `decision_rules` block, so the drafter said it could not read how a building decides. It now carries the block the route serves (every building as today), and the same test drafts every building from it. The fixture scrubber had read a building's decided_by (gate, board, nobody) as a login; it keeps those words now.
59
+ - **The drafter's choices were dropdowns.** Who decides and how they decide are one-tap chips now (the line rule: choices are one-tap chips): a labelled radio group each, the picked chip filled in ink, a long choice wrapping inside its chip, stacked one under the other so the chips have the panel's width. Focus lands on the picked chip after each pick.
60
+ - **A vote from a street card did not count today.** The today meter only took a pip for an act on a desk row; a decision from a card is a decision today too, so a recorded card act now fills a pip (an act that records nothing, or is refused, does not).
61
+ - **The meter's newest pip popped on every redraw** (any pick, the card closing, the docket read after an act), and the read after an act cut the pop short. The pips are drawn again only when the count moves, so the newest pops once, as on the board.
62
+ - **The home line with nothing on you.** With nothing waiting on you but work still moving, the server said "2 moving"; the prototype says "nothing waits on you · 2 moving", and now so does the server (`homeLine`).
63
+
64
+ Walked and matching, left as found: the city (pins, the one button, its line), the street (the order, the group brackets, one pin per building in its tier, the crates and the tester count pin, the six readings), every building's section, lift and panel, the card (its pager, the change, the seats and tally, counsel, the yes/no acts under the desk's words from task 1004566), the toast, and a district's road of work.
65
+
66
+ Left on purpose, not counted as differences:
67
+
68
+ - **The city sits inside the hall's shell** (sidebar and top bar). The prototype's home page carries only the wordmark, the switch, one button and one line; standalone hall pages were retired by task 1002868, and the city page adopted the shell after its record was written.
69
+ - **The home line never says "pieces of work stuck at Review".** The prototype adds it while Review is a vacant lot; the city draws Review as held in this project (the header of `city-draw.js`), so the clause has nothing to stand on.
70
+ - **Open owner choices met on the walk, untouched:** the "1" on a single-item pin, the light-mode legend hint wording, the recording sign-off going to its page, failed passed-vote actions on the record, a Discord update on withdraw, and the Work categories link.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.49",
3
+ "version": "1.21.51",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.49",
9
+ "version": "1.21.51",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.49",
3
+ "version": "1.21.51",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9062,5 +9062,17 @@
9062
9062
  "id": "1004575",
9063
9063
  "text": "New builders get setup steps that actually work end to end: the sign-in commands in Settings and the primer now run from a fresh copy of the project, the primer's terminal commands display properly, and the setup no longer lea"
9064
9064
  }
9065
+ ],
9066
+ "1.21.50": [
9067
+ {
9068
+ "id": "1004574",
9069
+ "text": "The builders hall now tells newcomers one sign-in command, npx bongos setup, the same on Mac, Windows and Linux — and copying the setup commands now pastes them line by line instead of jammed onto one line."
9070
+ }
9071
+ ],
9072
+ "1.21.51": [
9073
+ {
9074
+ "id": "1004531",
9075
+ "text": "We walked the whole Governor City against its design, in light and dark and on a phone: the home city, the street, every building, a vote decided from its card, and an amendment drafted from a building. Fixes: drafting a chang"
9076
+ }
9065
9077
  ]
9066
9078
  }
@@ -63,6 +63,14 @@ const isEmailKey = (k) => k === 'email' || /_email$/.test(k);
63
63
  // `by` / `*_by` holds a builder ID almost everywhere (created_by, shipped_by); only a
64
64
  // non-numeric value is a login (sandbox_review.by, who approved a staged change).
65
65
  const isByKey = (k) => k === 'by' || /_by$/.test(k);
66
+ // A building's `decided_by` in the decision table (government decision-rules.js)
67
+ // is a word of its vocabulary, never a person: "gate" (as today), "board" (the
68
+ // charter), "nobody" (vacant). A rank predicate has a colon and never looks like a
69
+ // login. Task 1004531: the constitution fixture carries that table. Only on a
70
+ // table ROW (it names its building in `area`), so a login that happens to be one
71
+ // of the three words anywhere else is still scrubbed.
72
+ const DECISION_WORDS = new Set(['gate', 'board', 'nobody']);
73
+ const isDecisionWord = (k, v, obj) => k === 'decided_by' && DECISION_WORDS.has(v) && !!obj && typeof obj.area === 'string';
66
74
  const looksLikeLogin = (v) => /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(v) && !/^\d+$/.test(v);
67
75
 
68
76
  // The login a name-shaped key belongs to, or undefined when the key is not a person's
@@ -159,7 +167,7 @@ function createScrubber({ firstNumber = 1 } = {}) {
159
167
  const n = isPersonRecord ? self() : numberFor([`email:${value.toLowerCase()}`]);
160
168
  return `builder-${n}@example.com`;
161
169
  }
162
- if (isByKey(key) && looksLikeLogin(value)) {
170
+ if (isByKey(key) && looksLikeLogin(value) && !isDecisionWord(key, value, obj)) {
163
171
  if (STAND_IN_LOGIN.test(value)) return value;
164
172
  return `builder-${numberFor([`login:${value.toLowerCase()}`])}`;
165
173
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.49'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.51'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -144,6 +144,8 @@ test('homeLine speaks the design\'s words', () => {
144
144
  assert.equal(docket.homeLine({ needs: 0, moving: 0, settled: 4 }), 'nothing waits on you.');
145
145
  assert.equal(docket.homeLine({ needs: 3, moving: 2, settled: 0 }), '3 matters wait on you · 2 moving');
146
146
  assert.equal(docket.homeLine({ needs: 1, moving: 0, settled: 0 }), '1 matter waits on you');
147
+ // the prototype's words when only moving work is left (task 1004531 walkthrough)
148
+ assert.equal(docket.homeLine({ needs: 0, moving: 2, settled: 1 }), 'nothing waits on you · 2 moving');
147
149
  });
148
150
 
149
151
  // ── 2. the board, per viewer ─────────────────────────────────────────────────
@@ -0,0 +1,75 @@
1
+ // tests/hall_preview_governor_fixtures.mjs — the hall preview's governor
2
+ // fixtures hold the server's own invariants (task 1004531).
3
+ //
4
+ // The Governor City walkthroughs run on the hall preview harness, so its
5
+ // fixtures are what the UAT record is judged on. Two had drifted from the
6
+ // shapes the routes really serve:
7
+ // * the docket's counts, home line and per-building counts were written for
8
+ // the first seven matters and never re-derived as later tasks added more,
9
+ // so the city said "3 matters wait on you", the street header 4, and its
10
+ // pins 13 — buildDocket derives all three from the matters, so live they
11
+ // can never disagree; the fairness reading had drifted the same way, so
12
+ // Disputes read "0 open" under two matters that need you;
13
+ // * the constitution carried no decision_rules block, so "change how this
14
+ // works" could only say it could not read how a building decides.
15
+ //
16
+ // Run: node --test tests/hall_preview_governor_fixtures.mjs
17
+
18
+ import { test } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { createRequire } from 'node:module';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const FX = path.join(ROOT, 'scripts/hall-preview/fixtures');
27
+ const require = createRequire(import.meta.url);
28
+ const docketLib = require(path.join(ROOT, 'modules/government/docket.js'));
29
+ const DR = require(path.join(ROOT, 'modules/government/decision-rules.js'));
30
+ const DB = require(path.join(ROOT, 'modules/government/decision-board.js'));
31
+ const A = require(path.join(ROOT, 'modules/hall-ui/public/street-amend.js'));
32
+ const fx = (name) => JSON.parse(fs.readFileSync(path.join(FX, name + '.json'), 'utf8'));
33
+
34
+ // buildDocket's countStates, restated: every matter counted once in its state.
35
+ function countStates(ms) {
36
+ const c = { needs: 0, moving: 0, settled: 0 };
37
+ for (const m of ms) c[m.state] += 1;
38
+ return c;
39
+ }
40
+
41
+ test('the docket fixture: counts and the home line are derived from its matters, as buildDocket derives them', () => {
42
+ const d = fx('government__docket');
43
+ assert.ok(d.matters.length > 7, 'the dense docket (task 1004569), not the first seven');
44
+ assert.deepEqual(d.counts, countStates(d.matters));
45
+ assert.equal(d.line, docketLib.homeLine(d.counts), 'the city line under start governing agrees with the street header');
46
+ });
47
+
48
+ test('the docket fixture: every building counts exactly the matters standing at it', () => {
49
+ const d = fx('government__docket');
50
+ for (const b of d.buildings) {
51
+ const here = d.matters.filter((m) => m.building === b.key);
52
+ assert.deepEqual(b.counts, countStates(here), b.key + ': the state line under the building agrees with its pins');
53
+ assert.deepEqual(b.matter_ids, here.map((m) => m.id), b.key + ': its matters, in docket order');
54
+ }
55
+ assert.equal(d.buildings.reduce((n, b) => n + b.matter_ids.length, 0), d.matters.length, 'every matter stands at one building');
56
+ });
57
+
58
+ test('the docket fixture: the fairness reading is derived from the matters at Disputes, as buildDocket derives it', () => {
59
+ // the reading's figure is the line under Disputes on the street, so a stale
60
+ // one says "0 open" under a building whose pin says two matters need you
61
+ const d = fx('government__docket');
62
+ const f = d.readings.find((r) => r.key === 'fairness');
63
+ const want = docketLib.fairnessReading(d.matters, d.sources);
64
+ assert.deepEqual({ value: f.value, figure: f.figure, line: f.line, hot: f.hot }, want);
65
+ });
66
+
67
+ test('the constitution fixture: it carries the decision_rules block the route serves, so every building can draft an amendment', () => {
68
+ const c = fx('government__constitution');
69
+ assert.deepEqual(c.decision_rules, JSON.parse(JSON.stringify(DB.constitutionBlock(DR.resolveAll([], { board: c.board })))), 'the server\'s own block for this board');
70
+ for (const area of c.decision_rules.areas.map((r) => r.area)) {
71
+ const d = A.draftFor(area, c);
72
+ assert.equal(d.ok, true, area + ': change how this works drafts');
73
+ assert.equal(A.check(d), 'Say why, so the council knows what it is voting on.', area + ': the draft is a real change, waiting only for the reason');
74
+ }
75
+ });
@@ -130,6 +130,11 @@ test('what is not a person is left exactly alone', () => {
130
130
  assert.equal(req.requests[0].created_by, '1000025', 'a numeric *_by is a builder id, not a login');
131
131
  assert.equal(req.tasks[0].credits_reward, 30, "a task's bounty is not anyone's balance");
132
132
  assert.equal(req.modules[0].author, 'Cloud Bongos', "a module's author credit names an organisation");
133
+ // the decision table's decided_by is a word of its vocabulary (task 1004531)
134
+ const table = s.scrub({ decision_rules: { areas: [{ area: 'charter', decided_by: 'board' }, { area: 'rules', decided_by: 'gate' }, { area: 'review', decided_by: 'nobody' }, { area: 'people', decided_by: 'rank:metic+' }] } });
135
+ assert.deepEqual(table.decision_rules.areas.map((a) => a.decided_by), ['board', 'gate', 'nobody', 'rank:metic+'], 'who decides a building is not a person');
136
+ assert.equal(s.scrub({ decided_by: 'Real-Person' }).decided_by.startsWith('builder-'), true, 'a login in decided_by is still scrubbed');
137
+ assert.equal(s.scrub({ id: '4', decided_by: 'gate' }).decided_by.startsWith('builder-'), true, 'off a table row, even a login spelled like a decision word is scrubbed');
133
138
  });
134
139
 
135
140
  test('scrubbing is a fixed point, and a new run numbers above the stand-ins already in use', () => {
@@ -184,7 +184,12 @@ test('the markup: the draft lives under the button, hidden, and the model loads
184
184
  assert.ok(order[0] > 0 && order[0] < order[1] && order[1] < order[2], 'card, then the draft model, then the page');
185
185
  const css = read('street.css').replace(/\/\*[\s\S]*?\*\//g, '');
186
186
  assert.ok(css.includes('.inside-draft[hidden] { display: none; }'));
187
- assert.match(css, /@media \(max-width: 899px\) \{\s*\.inside-draft__picks \{ grid-template-columns: 1fr; \}/, 'narrow: the picks stack');
187
+ assert.ok(css.includes('.inside-draft__picks { display: grid; grid-template-columns: 1fr; gap: 12px; }'), 'who, then how, stacked: the chips need the width (task 1004531)');
188
+ // the chips ride .desk-chip, which comes later in the sheet at one class: two
189
+ // classes are what let the wrap and the fill win (a single class lost in the browser)
190
+ assert.match(css, /\.inside-draft \.inside-draft__chip \{[^}]*white-space: normal;/, 'a long choice wraps inside its chip');
191
+ assert.match(css, /\.inside-draft \.inside-draft__chip--on \{ background: var\(--ink\);/, 'the picked chip is filled');
192
+ assert.ok(css.indexOf('.desk-chip--quiet, .desk-chip--choice {') > css.indexOf('.inside-draft .inside-draft__chip--on'), 'the desk rule still comes later, so the scope is what wins');
188
193
  assert.doesNotMatch(css.slice(css.indexOf('.inside-draft')), /#[0-9a-f]{3,8}\b/i, 'tokens only');
189
194
  assert.doesNotMatch(read('street.js'), /not built yet/);
190
195
  });
@@ -261,6 +266,8 @@ async function bootPage({ hash = '#building=review', cons = constitution(), answ
261
266
 
262
267
  test('change how this works opens the drafted amendment: struck, inserted, who, how, why', async () => {
263
268
  const p = await bootPage();
269
+ const chip = (id, v) => p.byId.get(id).kids.find((k) => k.attrs['data-value'] === v);
270
+ const picked = (id) => p.byId.get(id).kids.find((k) => k.attrs['aria-checked'] === 'true');
264
271
  const box = p.byId.get('insideDraft');
265
272
  assert.equal(box.hidden, true);
266
273
  await p.byId.get('insideAmend').fire('click')[0];
@@ -270,17 +277,23 @@ test('change how this works opens the drafted amendment: struck, inserted, who,
270
277
  const del = all(box).find((n) => n.tagName === 'del'), ins = all(box).find((n) => n.tagName === 'ins');
271
278
  assert.equal(del.textContent, 'whoever may mark work as meeting its checks, decides alone');
272
279
  assert.equal(ins.textContent, 'the archon rank, passes unless someone objects');
273
- assert.equal(p.focused.el, p.byId.get('insideDraftWho'), 'focus lands on who decides');
274
- assert.equal(p.byId.get('insideDraftWho').kids.length, 6);
275
- assert.equal(p.byId.get('insideDraftRule').value, 'consent');
276
- // a pick redraws the words and keeps focus where it was
280
+ assert.equal(p.focused.el, picked('insideDraftWho'), 'focus lands on the picked chip of who decides');
281
+ // every choice is a one-tap chip in a labelled radio group, never a select (task 1004531)
277
282
  const who = p.byId.get('insideDraftWho');
278
- who.value = 'nobody';
279
- who.fire('change');
283
+ assert.equal(who.attrs.role, 'radiogroup');
284
+ assert.ok(all(box).some((n) => n.attrs.id === 'insideDraftWhoLabel' && n._text === 'who decides'), 'the group is named by its label');
285
+ assert.ok(!all(box).some((n) => n.tagName === 'select'), 'no dropdowns');
286
+ assert.equal(who.kids.length, 6);
287
+ assert.ok(who.kids.every((k) => k.tagName === 'button' && k.attrs.role === 'radio'));
288
+ assert.equal(who.kids.filter((k) => k.attrs['aria-checked'] === 'true').length, 1, 'one picked');
289
+ assert.equal(picked('insideDraftRule').attrs['data-value'], 'consent');
290
+ // a pick is one tap: it redraws the words and keeps focus in the group
291
+ chip('insideDraftWho', 'nobody').fire('click');
280
292
  assert.equal(all(box).find((n) => n.tagName === 'ins').textContent, 'no one yet');
281
293
  assert.ok(!all(box).some((n) => n.attrs.id === 'insideDraftRule'), 'an empty building has no rule to pick');
282
294
  assert.match(textOf(box), /nothing is decided until someone holds it/);
283
- assert.equal(p.focused.el.attrs.id, 'insideDraftWho');
295
+ assert.equal(p.focused.el, picked('insideDraftWho'));
296
+ assert.equal(p.focused.el.attrs['data-value'], 'nobody');
284
297
  // a feed landing mid-sentence leaves the draft alone
285
298
  const before = p.byId.get('insideDraftWhy');
286
299
  before.value = 'half a thought';
@@ -894,3 +894,44 @@ test('walkthrough: a record item keeps its age, and what you did keeps its "now"
894
894
  assert.deepEqual(desk.record.map((x) => x.text), ['voted yes A returned id… · now', 'The new sign-up · live · 2d', 'Mira holds the Treasurer… · 3w', 'Today it went out · today']);
895
895
  assert.ok(desk.record.every((x) => x.text.length <= D.LIMITS.recordLen));
896
896
  });
897
+
898
+ // ── the live walkthrough against the prototype (task 1004531) ─────────────────
899
+
900
+ const AR = () => [matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 } })];
901
+ const settle = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); };
902
+
903
+ test('walkthrough 1004531: the newest pip pops once and keeps its node, so a redraw neither replays nor cuts the pop', async () => {
904
+ const p = await bootPage('#building=people', AR());
905
+ const pips = () => p.byId.get('streetTodayPips').children;
906
+ const before = pips()[0];
907
+ p.window.GovStreetPage.draw();
908
+ assert.equal(pips()[0], before, 'an unchanged count keeps the meter as it stands');
909
+ p.part('desk-rows').children[0].children[0].children[2].children[1].fire('click');
910
+ await settle();
911
+ assert.equal(p.byId.get('streetTodayN').textContent, '1 decided');
912
+ const fresh = pips()[0];
913
+ assert.notEqual(fresh, before, 'the count moved, so the pips were drawn again');
914
+ assert.match(fresh.className, /street-today__pip--on/);
915
+ assert.match(fresh.className, /desk-pop/, 'the newest pip pops');
916
+ p.window.GovStreetPage.draw();
917
+ assert.equal(pips()[0], fresh, 'the redraws after the act (the docket read again) leave the popping pip alone');
918
+ });
919
+
920
+ test('walkthrough 1004531: a decision from a street card is a decision today, as one from a desk row is', async () => {
921
+ const p = await bootPage('', AR());
922
+ p.window.GovStreetPage.openCard('access_requests:11');
923
+ await p.window.GovStreetPage.actOn('fine', {});
924
+ await settle();
925
+ assert.equal(p.sent.length, 1, 'the card sent its act');
926
+ assert.equal(p.byId.get('streetTodayN').textContent, '1 decided');
927
+ assert.equal(JSON.parse(p.store.get('gov-desk-today')).n, 1, 'the day keeps its count');
928
+ assert.match(p.byId.get('streetTodayPips').children[0].className, /street-today__pip--on desk-pop/);
929
+ });
930
+
931
+ test('walkthrough 1004531: a card act that records nothing takes no pip', async () => {
932
+ const p = await bootPage('', AR(), { refuse: true });
933
+ p.window.GovStreetPage.openCard('access_requests:11');
934
+ await p.window.GovStreetPage.actOn('fine', {});
935
+ await settle();
936
+ assert.equal(p.byId.get('streetTodayN').textContent, '0 decided', 'a refused act is not a decision');
937
+ });