@bongos/core 1.21.32 → 1.21.34

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 (41) hide show
  1. package/.bongos-core.json +68 -43
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0338-modules-travel-through-a-bongos-hosted-store.md +7 -0
  8. package/docs/api/openapi.json +96 -3
  9. package/docs/api-reference.md +3 -2
  10. package/docs/architecture.md +1 -0
  11. package/docs/copy-inventory.md +29 -25
  12. package/docs/copy-registry.json +65 -29
  13. package/docs/design/craft-mode-direction.md +69 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +5 -1
  16. package/docs/page-readings.json +270 -201
  17. package/modules/government/catalog.js +3 -0
  18. package/modules/government/migrations/government_025_module_delist.sql +36 -0
  19. package/modules/hall-ui/public/modules.js +8 -1
  20. package/modules/hall-ui/public/street-desk.js +285 -0
  21. package/modules/hall-ui/public/street-inside.js +8 -4
  22. package/modules/hall-ui/public/street.css +59 -0
  23. package/modules/hall-ui/public/street.html +9 -3
  24. package/modules/hall-ui/public/street.js +219 -17
  25. package/modules/hall-ui/public/street.states.json +83 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +16 -0
  29. package/scripts/gds/module.js +21 -5
  30. package/src/bongos/module-store.js +31 -0
  31. package/src/bongos/route-rank-check.js +3 -0
  32. package/src/bongos/routes/modules.js +35 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/hall_catalog_world.mjs +1 -1
  35. package/tests/hall_matter_card.mjs +1 -1
  36. package/tests/hall_street.mjs +1 -1
  37. package/tests/hall_street_amend.mjs +1 -1
  38. package/tests/hall_street_desk.mjs +328 -0
  39. package/tests/hall_street_inside.mjs +1 -1
  40. package/tests/module_catalog.mjs +34 -6
  41. package/tests/module_store_delist.mjs +146 -0
@@ -228,6 +228,9 @@ const PERMISSIONS = [
228
228
  // Re-run a store version's automatic assessment (task 1003796, ADR 0359). Never a
229
229
  // hand-set score: the owner ruled a person may ask for a re-check, not change the number.
230
230
  { key: 'module.assessment.rerun', system: false, floor: 'metic', guards: 'POST /store/modules/:key/versions/:version/reassess' },
231
+ // Take a module out of the store (task 1003797, ADR 0338 D2). Stops new installs,
232
+ // versions and downloads; never reaches an instance that already holds it.
233
+ { key: 'module.delist', system: false, floor: 'metic', guards: 'POST /store/modules/:key/delist' },
231
234
  { key: 'security.report.file', system: false, floor: 'metic', guards: 'POST /security/reports, GET /security/adr' },
232
235
  { key: 'llm_cache.use', system: false, floor: 'metic', guards: 'POST /llm-cache/{lookup,store}' },
233
236
  { key: 'backup.manage', system: false, floor: 'metic', guards: 'GET /backup/status, POST /backup/trigger' },
@@ -0,0 +1,36 @@
1
+ -- government_025_module_delist.sql — grant the new `module.delist` atom to Metic
2
+ -- and Archon (task 1003797, ADR 0338 D2).
3
+ --
4
+ -- WHAT IT IS. A Metic+ person may take a module out of the store. It leaves the
5
+ -- catalog and can no longer be newly installed, updated or downloaded. Instances
6
+ -- that already hold it keep it, and it keeps working: a delist never revokes an
7
+ -- entitlement or reaches into an installed copy (ADR 0338 D2), and it never
8
+ -- changes a score (ADR 0359). A reason is required and the audit log records it.
9
+ --
10
+ -- WHY THIS FILE EXISTS. RANK_SEED is derived from the catalog's floors, and the
11
+ -- drift guard (tests/government_seed.mjs) compares it against the union of the
12
+ -- run-once seed plus every later grant migration, per rank. A metic-floor key is
13
+ -- held by metic AND archon, so both are granted here.
14
+ --
15
+ -- Additive only: one new permission key granted to two ranks; nothing is revoked.
16
+ -- Approved by the owner (Will, Archon) on 2026-10-01.
17
+ -- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
18
+ --
19
+ -- The `SELECT '<rank>', unnest(ARRAY[…])` shape is load-bearing: the drift guard
20
+ -- scans for it literally.
21
+
22
+ BEGIN;
23
+
24
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
25
+ SELECT 'metic', unnest(ARRAY[
26
+ 'module.delist'
27
+ ])
28
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
29
+
30
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
31
+ SELECT 'archon', unnest(ARRAY[
32
+ 'module.delist'
33
+ ])
34
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
35
+
36
+ COMMIT;
@@ -106,6 +106,13 @@
106
106
  return `<a class="ov-fact mod-howto" href="${escapeHtml(href)}">how-to</a>`;
107
107
  }
108
108
 
109
+ // A store module this instance holds that the store has since delisted (task
110
+ // 1003797, ADR 0338 D2): it keeps working here, but no more versions will come.
111
+ function delistedFact(m) {
112
+ if (m.store_delisted !== true) return '';
113
+ return '<span class="fact-pill fact-pill--warn" title="Taken out of the store. Your copy keeps working, but it will get no more updates.">delisted · no more updates</span>';
114
+ }
115
+
109
116
  // The lines the row's details disclosure opens onto: the facts the payload
110
117
  // carries that the card never rendered — what the module contributes, the
111
118
  // core it needs, who maintains it and what it hands off to.
@@ -152,7 +159,7 @@
152
159
  <div class="ov-row__main">
153
160
  <span class="ov-row__title">${escapeHtml(m.title || m.key)}<span class="ov-mono">${escapeHtml(m.key)}</span></span>
154
161
  ${desc ? `<p class="ov-row__sub mod-row__desc">${escapeHtml(desc)}</p>` : ''}
155
- <div class="ov-row__facts">${provenancePill(m)}${attributionFacts(m)}${upkeepFact(m)}${howtoFact(m)}</div>
162
+ <div class="ov-row__facts">${provenancePill(m)}${attributionFacts(m)}${upkeepFact(m)}${howtoFact(m)}${delistedFact(m)}</div>
156
163
  ${lines ? `<details class="mod-more"${openKeys.has(m.key) ? ' open' : ''}><summary aria-label="Details for ${escapeHtml(m.title || m.key)}"></summary>${lines}</details>` : ''}
157
164
  </div>
158
165
  <div class="ov-row__right mod-row__right">${rightHtml(m)}</div>
@@ -0,0 +1,285 @@
1
+ // modules/hall-ui/public/street-desk.js — THE FLOOR DESK AND THE LINE RULE
2
+ // (task 1004558, goal 1000125; round 2 of the Governor City).
3
+ //
4
+ // WHAT THIS IS. The desk from the reference board "Round 2 · floor desks"
5
+ // (docs/design/mocks/governor-city/Desks.dc.html, its Component class): the
6
+ // chosen floor of a building, as one panel you act from in place. A header
7
+ // (the floor's sign, its name, its status), up to three tiles, the to-do rows
8
+ // (a name, one fact, at most two buttons; tap the name for at most three facts
9
+ // in place; reasons as one-tap chips), the quiet state when nothing needs you,
10
+ // and the record line. The game layer around it (the today meter, rows
11
+ // clearing away, a cleared floor lighting up, the toast) is drawn by
12
+ // street.js; this file is pure.
13
+ //
14
+ // THE LINE RULE (Ground.dc.html, "the line rule and ground level"):
15
+ // a ROW is a name of at most 3 words, one fact, at most two buttons, and
16
+ // at most three facts that open in place;
17
+ // a TILE is one number and two words, and is hidden when it reads zero;
18
+ // a STATUS is count, noun and oldest age, orange only when it needs you;
19
+ // a QUIET floor shows one line and lights on, never an empty list;
20
+ // REASONS and options are one-tap chips, never free text.
21
+ // fit() is the ONE line formatter every desk string goes through, and breaks()
22
+ // is the rule as a checker: tests/hall_street_desk.mjs runs every desk the
23
+ // docket can produce through it.
24
+ //
25
+ // THE ACTS. A row's buttons are the matter card's own actions
26
+ // (GovMatterCard.model, street-card.js) under the board's shorter words, and an
27
+ // act is sent through GovMatterCard.request(): the same existing routes the
28
+ // card uses, with their own permission checks. Nothing here invents a route.
29
+ // Two of the board's touches have no route under them and are left out rather
30
+ // than faked: a reason for "not now" on a join request (the route takes none),
31
+ // and the abstain text action (it records nothing; a row has two buttons).
32
+ //
33
+ // API (what the next desks plug into):
34
+ // GovDesk.deskModel(input) → desk pure; input below
35
+ // GovDesk.rowsFor(key, floor, docket, ctx) the rows of one floor
36
+ // GovDesk.floorMatters(key, floor, docket) which open matters sit on a floor
37
+ // GovDesk.firstNeeds(key, docket) the floor that needs you first, or null
38
+ // GovDesk.fit(s, n, words) the line formatter
39
+ // GovDesk.breaks(desk) [] when the desk keeps the line rule
40
+ // GovDesk.todayPips(n) the street header's meter
41
+ // GovDesk.LIMITS, ACT_WORDS, CHIPS, NOUNS, CLEAR
42
+ // A desk row is { key, matter, ini, name, fact, facts[], actions[{ key, label,
43
+ // tone, go, ask }], chips { for, ask, options[{ key, label, input }] } | null,
44
+ // wait, needs, card }: `card` is the GovMatterCard model the act is sent with.
45
+
46
+ (function (root) {
47
+ 'use strict';
48
+ const S = root.GovStreet || (typeof require === 'function' ? require('./street-draw.js') : null);
49
+ const I = root.GovInside || (typeof require === 'function' ? require('./street-inside.js') : null);
50
+ const C = root.GovMatterCard || (typeof require === 'function' ? require('./street-card.js') : null);
51
+
52
+ // Every limit of the line rule, in characters unless named otherwise.
53
+ const LIMITS = Object.freeze({
54
+ nameWords: 3, name: 20, fact: 26, facts: 3, factLen: 24, actions: 2,
55
+ tiles: 3, tileValue: 8, tileWords: 2, tileLabel: 14,
56
+ status: 44, clearLine: 30, clearSub: 40, record: 4, recordLen: 30,
57
+ chips: 5, chip: 16, todayTarget: 10,
58
+ });
59
+
60
+ // One line, never more: the house rule for every row, tile and status. A cap
61
+ // on words first (when given), then on characters; anything cut says so.
62
+ function fit(s, n, words) {
63
+ let t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
64
+ let cut = false;
65
+ if (words) {
66
+ const w = t.split(' ');
67
+ if (w.length > words) { t = w.slice(0, words).join(' ').replace(/[,.;:·-]+$/, ''); cut = true; }
68
+ }
69
+ if (t.length > n) cut = true;
70
+ if (!cut) return t;
71
+ t = t.replace(/…$/, '');
72
+ if (t.length > n - 1) t = t.slice(0, n - 1).trimEnd();
73
+ return t + '…';
74
+ }
75
+ const wordCount = (s) => String(s).replace(/…$/, '').split(' ').filter(Boolean).length;
76
+
77
+ // An age in the board's words: '40 min', '5 hours', '2 days', '3 weeks'.
78
+ function ageWord(at, now) {
79
+ const t = at ? Date.parse(at) : NaN;
80
+ if (!isFinite(t) || !now) return '';
81
+ const m = Math.max(0, Math.round((now - t) / 60000));
82
+ if (m < 60) return (m || 1) + ' min';
83
+ const h = Math.round(m / 60);
84
+ if (h < 48) return h + (h === 1 ? ' hour' : ' hours');
85
+ const d = Math.round(h / 24);
86
+ if (d < 14) return d + ' days';
87
+ return Math.round(d / 7) + ' weeks';
88
+ }
89
+
90
+ // The words a floor counts its rows in, and says when it is cleared.
91
+ const NOUNS = Object.freeze({
92
+ access_requests: 'waiting', goal_join_requests: 'asking', override_requests: 'asked', gate_approvals: 'ready',
93
+ core_update: 'update', blockers: 'waiting', board: 'open vote', idea: 'open vote',
94
+ });
95
+ const CLEAR = Object.freeze({
96
+ access_requests: 'Everyone is in', goal_join_requests: 'No one is asking', override_requests: 'No overrides asked',
97
+ gate_approvals: 'Everything ready is live', blockers: 'Nothing blocked on you', board: 'Every vote is in', idea: 'No ideas waiting',
98
+ });
99
+ // The card's acts under the desk's words (the board's button labels).
100
+ const ACT_WORDS = Object.freeze({ for: 'yes', object: 'no', fine: 'let in', refuse: 'not now', release: 'release', hold: 'hold' });
101
+ // An objection's reason as one tap: the five sections the server accepts
102
+ // (street-card.js SECTIONS), each in at most 16 characters.
103
+ const CHIPS = Object.freeze({ scope: 'what it changes', purpose: 'its purpose', why_matters: 'why it matters', fit: 'how it fits', success: 'how to measure' });
104
+ const STATE = Object.freeze({ needs: 'needs you', moving: 'moving', settled: 'settled' });
105
+ const PEOPLE = Object.freeze(['access_requests', 'goal_join_requests']);
106
+
107
+ function str(v) { return v == null ? '' : String(v); }
108
+ function personOf(m) { return m && m.proposer ? str(m.proposer.name || m.proposer.login) : ''; }
109
+ function initials(t) {
110
+ const w = str(t).replace(/[^A-Za-z0-9 ]+/g, ' ').trim().split(/\s+/).filter(Boolean);
111
+ return (w.length > 1 ? w[0].charAt(0) + w[1].charAt(0) : str(w[0]).slice(0, 2)).toUpperCase() || '·';
112
+ }
113
+ function stateOf(m) { return m && STATE[m.state] ? m.state : 'moving'; }
114
+ function isIdea(m) { return !!m && m.source === 'board' && m.short === 'idea to ratify'; }
115
+
116
+ // ---------- which matters sit on which floor ----------
117
+ // A floor bound to a source holds that source's matters in this building; the
118
+ // ideas floor holds the board's open full ideas; every other open matter of
119
+ // the building (a board sitting, a source no floor names) sits on the first
120
+ // floor, the building's front desk. Settled matters are the record, not rows.
121
+ function floorMatters(key, floor, docket) {
122
+ const plan = I && I.PLAN[key];
123
+ if (!plan || !docket || !Array.isArray(docket.matters)) return [];
124
+ const specs = plan.floors;
125
+ const spec = specs[floor];
126
+ if (!spec) return [];
127
+ const bind = spec[2] || {};
128
+ const open = docket.matters.filter((m) => m && stateOf(m) !== 'settled');
129
+ const own = open.filter((m) => m.building === key);
130
+ const sources = new Set(specs.map((s) => s[2] && s[2].source).filter(Boolean));
131
+ const ideasHere = specs.some((s) => s[2] && s[2].floor === 'planning.ideas');
132
+ if (bind.source) return own.filter((m) => m.source === bind.source);
133
+ if (bind.floor === 'planning.ideas') return open.filter(isIdea);
134
+ if (floor !== 0) return [];
135
+ return own.filter((m) => !sources.has(m.source) && !(ideasHere && isIdea(m)));
136
+ }
137
+ // The floor that needs you first (so a building opens on its work), or null.
138
+ function firstNeeds(key, docket) {
139
+ const plan = I && I.PLAN[key];
140
+ if (!plan) return null;
141
+ for (let i = 0; i < plan.floors.length; i++) if (floorMatters(key, i, docket).some((m) => stateOf(m) === 'needs')) return i;
142
+ return null;
143
+ }
144
+
145
+ // ---------- one row ----------
146
+ // ctx { docket, room, constitution, now, viewerId }
147
+ function rowOf(m, ctx) {
148
+ const now = ctx.now || 0;
149
+ const person = personOf(m);
150
+ const people = PEOPLE.indexOf(m.source) >= 0;
151
+ const name = fit(people && person ? person : (m.title || m.short || 'a matter'), LIMITS.name, LIMITS.nameWords);
152
+ const age = ageWord(m.opened_at, now);
153
+ const lead = people ? (m.source === 'goal_join_requests' ? 'goal team' : 'to join') : person ? person.split(/\s+/)[0] : str(m.short || STATE[stateOf(m)]);
154
+ const fact = fit([lead, age].filter(Boolean).join(' · '), LIMITS.fact);
155
+ const when = S && S.whenWord ? S.whenWord(m.deadline, now) : '';
156
+ const facts = [];
157
+ const add = (t) => { const v = fit(t, LIMITS.factLen); if (v && facts.indexOf(v) < 0 && facts.length < LIMITS.facts) facts.push(v); };
158
+ if (m.title && name !== str(m.title)) add(m.title);
159
+ if (person && !people) add('by ' + person);
160
+ if (typeof m.tally === 'string') add(m.tally);
161
+ if (when) add('closes ' + when);
162
+ if (m.where) add(str(m.where).toLowerCase());
163
+ add(STATE[stateOf(m)]);
164
+ const cv = C && C.model ? C.model({ docket: ctx.docket, key: m.id, room: ctx.room, constitution: ctx.constitution, now: now, viewerId: ctx.viewerId }) : null;
165
+ const actions = [];
166
+ let chips = null;
167
+ if (cv && !cv.readOnly && !cv.loading) {
168
+ cv.actions.forEach((a) => {
169
+ if (actions.length >= LIMITS.actions || !ACT_WORDS[a.key]) return;
170
+ actions.push({ key: a.key, label: ACT_WORDS[a.key], tone: actions.length ? 'quiet' : 'primary', go: a.go || null, ask: a.key === 'object' });
171
+ });
172
+ if (actions.some((a) => a.ask)) {
173
+ chips = { for: 'object', ask: 'why', options: Object.keys(CHIPS).map((k) => ({ key: k, label: CHIPS[k], input: { section: k, reason: 'Objects to ' + CHIPS[k] + ' (one tap on the floor desk).' } })) };
174
+ }
175
+ }
176
+ const wait = actions.length ? '' : cv && cv.loading ? 'reading…' : stateOf(m) === 'needs' ? 'not from here' : 'moving';
177
+ return {
178
+ key: str(m.id), matter: str(m.id), ini: initials(name), name: name, fact: fact, facts: facts,
179
+ actions: actions, chips: chips, wait: wait, needs: stateOf(m) === 'needs', source: m.source, opened: m.opened_at || null, card: cv,
180
+ };
181
+ }
182
+ function rowsFor(key, floor, docket, ctx) {
183
+ const c = Object.assign({ docket: docket }, ctx || {});
184
+ const done = c.done || {};
185
+ return floorMatters(key, floor, docket).filter((m) => !done[str(m.id)]).map((m) => rowOf(m, c))
186
+ .sort((a, b) => (b.needs - a.needs) || ((Date.parse(a.opened || 0) || 0) - (Date.parse(b.opened || 0) || 0)));
187
+ }
188
+
189
+ // ---------- the whole desk ----------
190
+ // input { key, floor (index), docket, goals (open), now, room, constitution,
191
+ // viewerId, done {matterId: true}, mine [what you did here, newest
192
+ // first], cleared (you cleared it this visit), today (decisions
193
+ // today), sym {d, f} (the floor's sign, from buildingModel) }
194
+ function deskModel(input) {
195
+ const o = input || {};
196
+ const plan = I && I.PLAN[o.key];
197
+ if (!plan) return null;
198
+ const docket = o.docket && Array.isArray(o.docket.matters) ? o.docket : null;
199
+ const floor = Math.max(0, Math.min(Number(o.floor) || 0, plan.floors.length - 1));
200
+ const spec = plan.floors[floor];
201
+ const fl = I.floorFor(spec, { key: o.key, docket: docket, goals: o.goals || [], now: o.now || 0 });
202
+ const ctx = { room: o.room, constitution: o.constitution, now: o.now || 0, viewerId: o.viewerId, done: o.done };
203
+ const rows = docket ? rowsFor(o.key, floor, docket, ctx) : [];
204
+ const bind = spec[2] || {};
205
+ const src = bind.source ? bind.source : bind.floor === 'planning.ideas' ? 'idea' : floor === 0 ? 'board' : null;
206
+ const needs = rows.filter((r) => r.needs);
207
+ const noun = NOUNS[src] || 'open';
208
+ const oldestOf = (list) => list.reduce((a, r) => (r.opened && (!a || Date.parse(r.opened) < Date.parse(a)) ? r.opened : a), null);
209
+ const cleared = !!o.cleared && rows.length === 0;
210
+ const quiet = rows.length === 0;
211
+ let status, hot = false;
212
+ if (!docket) status = 'the docket could not be read';
213
+ else if (needs.length) { hot = true; const age = ageWord(oldestOf(needs), o.now); status = needs.length + ' ' + noun + (age ? ' · oldest ' + age : ''); }
214
+ else if (rows.length) { const age = ageWord(oldestOf(rows), o.now); status = rows.length + ' moving' + (age ? ' · oldest ' + age : ''); }
215
+ else if (cleared) status = 'floor clear';
216
+ else status = 'nothing needs you · ' + fl.line;
217
+ const history = docket && Array.isArray(docket.history) ? docket.history.filter((h) => h && h.building === o.key) : [];
218
+ // tiles: one number and two words, never a zero
219
+ const tiles = [];
220
+ const tile = (v, l) => {
221
+ const val = str(v).trim();
222
+ if (!/[0-9]/.test(val) || /^[$]?[0.,]+%?$/.test(val)) return;
223
+ // two words, read as a label: the rest of a longer line is left off, not marked cut
224
+ const words = str(l).replace(/\s+/g, ' ').trim().split(' ').slice(0, LIMITS.tileWords).join(' ').replace(/[,.;:·-]+$/, '');
225
+ tiles.push({ key: 't' + tiles.length, v: fit(val, LIMITS.tileValue), l: fit(words, LIMITS.tileLabel) });
226
+ };
227
+ if (rows.length) { tile(needs.length, noun); tile(rows.length - needs.length, 'moving'); }
228
+ else if (fl.count) tile(fl.count, str(fl.line).replace(/^[$]?[0-9][0-9,.%]*\s*/, '') || 'on this floor');
229
+ tile(history.length, 'on record');
230
+ const record = (o.mine || []).map((t, i) => ({ key: 'm' + i, text: fit(t + ' · now', LIMITS.recordLen), mine: true }))
231
+ .concat(history.map((h, i) => ({ key: 'h' + i, text: fit([h.title, h.outcome, ageWord(h.at, o.now)].filter(Boolean).join(' · '), LIMITS.recordLen), mine: false })))
232
+ .slice(0, LIMITS.record);
233
+ return {
234
+ key: o.key + ':' + floor, building: o.key, floor: floor, name: spec[0], sym: o.sym || { d: '', f: '' },
235
+ status: fit(status, LIMITS.status), hot: hot,
236
+ tiles: tiles.slice(0, LIMITS.tiles), rows: rows,
237
+ quiet: quiet, cleared: cleared,
238
+ clearLine: fit(cleared ? (CLEAR[src] || 'Floor clear') : 'Nothing needs you here', LIMITS.clearLine),
239
+ clearSub: fit(cleared ? 'lit up · ' + (o.today || 0) + ' decided today' : fl.line, LIMITS.clearSub),
240
+ record: record,
241
+ };
242
+ }
243
+
244
+ // ---------- the rule as a checker ----------
245
+ function breaks(desk) {
246
+ const out = [];
247
+ if (!desk) return out;
248
+ const bad = (what, v) => out.push(desk.key + ' ' + what + ': ' + JSON.stringify(v));
249
+ const oneLine = (s) => typeof s === 'string' && !/[\r\n]/.test(s);
250
+ if (!oneLine(desk.status) || desk.status.length > LIMITS.status) bad('status too long', desk.status);
251
+ if (desk.hot !== desk.rows.some((r) => r.needs)) bad('status is orange only when something needs you', desk.status);
252
+ if (desk.tiles.length > LIMITS.tiles) bad('more than three tiles', desk.tiles.length);
253
+ desk.tiles.forEach((t) => {
254
+ if (!/[0-9]/.test(t.v) || t.v.length > LIMITS.tileValue || /^[$]?[0.,]+%?$/.test(t.v)) bad('tile value', t.v);
255
+ if (!t.l || wordCount(t.l) > LIMITS.tileWords || t.l.length > LIMITS.tileLabel) bad('tile label', t.l);
256
+ });
257
+ if (desk.quiet !== (desk.rows.length === 0)) bad('quiet and rows disagree', desk.rows.length);
258
+ if (desk.quiet && (!desk.clearLine || desk.clearLine.length > LIMITS.clearLine || !oneLine(desk.clearSub) || desk.clearSub.length > LIMITS.clearSub)) bad('quiet line', [desk.clearLine, desk.clearSub]);
259
+ desk.rows.forEach((r) => {
260
+ if (!r.name || !oneLine(r.name) || wordCount(r.name) > LIMITS.nameWords || r.name.length > LIMITS.name) bad('row name', r.name);
261
+ if (!oneLine(r.fact) || r.fact.length > LIMITS.fact) bad('row fact', r.fact);
262
+ if (r.actions.length > LIMITS.actions) bad('more than two buttons', r.actions.map((a) => a.label));
263
+ if (r.facts.length > LIMITS.facts || r.facts.some((f) => !oneLine(f) || f.length > LIMITS.factLen)) bad('row facts', r.facts);
264
+ if (r.chips && (r.chips.options.length > LIMITS.chips || r.chips.options.some((c) => c.label.length > LIMITS.chip))) bad('chips', r.chips.options.map((c) => c.label));
265
+ if (r.actions.some((a) => a.ask) && !r.chips) bad('a reason without chips', r.name);
266
+ });
267
+ if (desk.record.length > LIMITS.record || desk.record.some((x) => !oneLine(x.text) || x.text.length > LIMITS.recordLen)) bad('record line', desk.record.map((x) => x.text));
268
+ return out;
269
+ }
270
+
271
+ // The street header's meter: one pip per decision today, ten to a row.
272
+ function todayPips(n) {
273
+ const k = Math.max(0, Math.floor(Number(n) || 0));
274
+ const pips = [];
275
+ for (let i = 0; i < LIMITS.todayTarget; i++) pips.push({ key: i, on: i < k, fresh: k > 0 && i === Math.min(k, LIMITS.todayTarget) - 1 });
276
+ return { pips: pips, text: k + ' decided' };
277
+ }
278
+
279
+ const api = {
280
+ LIMITS: LIMITS, ACT_WORDS: ACT_WORDS, CHIPS: CHIPS, NOUNS: NOUNS, CLEAR: CLEAR,
281
+ fit: fit, ageWord: ageWord, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips,
282
+ };
283
+ root.GovDesk = api;
284
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
285
+ }(typeof window !== 'undefined' ? window : globalThis));
@@ -208,7 +208,9 @@
208
208
  const used = !!fl, sel = used && i === selFloor;
209
209
  const pend = used && fl.status === 'pending';
210
210
  const prop = used && (fl.status === 'proposed' || QUIET[fl.status] !== undefined);
211
- const lit = sel || (used && st.evening);
211
+ // a floor you cleared this visit lights its windows and shows a tick (task 1004558)
212
+ const clr = used && Array.isArray(st.cleared) && st.cleared.indexOf(i) >= 0;
213
+ const lit = sel || clr || (used && st.evening);
212
214
  push(shapes, rect(X - 6, y1 - 5, W + 12, 5), fillOr(M2.slab), line, dash);
213
215
  push(shapes, rect(X + 9, y0, W - 18, FH - 5), fillOr(lit ? M2.roomLit : M2.room), line, dash);
214
216
  [X, X + W - 9].forEach((wx) => {
@@ -237,7 +239,8 @@
237
239
  if (pend) push(shapes, rect(X + 11, y0 + 2, W - 22, FH - 9), 'none', '#fb9569', '4 4');
238
240
  txt(X + 24, y0 + 24, cut(fl.name, 34), ghost || prop ? M2.faint : M2.name, 14.5, 500);
239
241
  txt(X + 24, y0 + 40, cut(fl.line, 44), pend ? T.accentText : M2.line, 12, 400);
240
- if (fl.count) txt(X + W - 22, y0 + 34, fl.count, M2.name, 20, 300, 'end', sans, '-0.02em');
242
+ if (clr) push(shapes, 'M' + (X + W - 44) + ' ' + (y0 + 22) + 'l6 6 12-13', 'none', M2.name, 'none', 1.8);
243
+ else if (fl.count) txt(X + W - 22, y0 + 34, fl.count, M2.name, 20, 300, 'end', sans, '-0.02em');
241
244
  else if (pend) txt(X + W - 22, y0 + 32, 'being decided', T.accentText, 11.5, 500, 'end');
242
245
  else if (QUIET[fl.status]) txt(X + W - 22, y0 + 32, QUIET[fl.status], M2.faint, 11.5, 400, 'end');
243
246
  }
@@ -540,7 +543,8 @@
540
543
  }
541
544
 
542
545
  // The building interior as data: civicInfo() drawn by buildingView().
543
- // input { key, docket, goals, mode, now, floor, drawer, picks }
546
+ // input { key, docket, goals, mode, now, floor, drawer, picks, cleared }
547
+ // cleared: the floor indexes the viewer cleared at the desk this visit (street-desk.js)
544
548
  function buildingModel(input) {
545
549
  const o = input || {};
546
550
  if (S.CIVIC_KEYS.indexOf(o.key) < 0) return null;
@@ -550,7 +554,7 @@
550
554
  const b = civicInfo(o.key, { docket: o.docket, goals: goals, now: o.now });
551
555
  const docket = o.docket && Array.isArray(o.docket.matters) ? o.docket : null;
552
556
  const evening = !!docket && !(docket.counts && Number(docket.counts.needs) > 0);
553
- const bv = buildingView(T, o.key, b, { floor: o.floor, drawer: o.drawer, picks: o.picks, vacant: b.vacant, reviewHeld: !b.vacant, evening: evening });
557
+ const bv = buildingView(T, o.key, b, { floor: o.floor, drawer: o.drawer, picks: o.picks, vacant: b.vacant, reviewHeld: !b.vacant, evening: evening, cleared: o.cleared });
554
558
  bv.table = bv.table.map((r) => Object.assign({}, r, { color: r.color === 'accent' ? T.accentText : T.ink }));
555
559
  bv.key = o.key; bv.mode = mode; bv.vacant = b.vacant;
556
560
  bv.label = b.name + ' in section: ' + b.floors.length + ' of ' + bv.cap + ' floors in use' + (bv.hasTable ? ', ' + bv.table.length + ' on the table' : '') + '.';
@@ -283,6 +283,58 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
283
283
  .inside-line { display: flex; justify-content: space-between; gap: 14px; padding: 9px 0; border-top: 1px solid var(--street-rule); font-size: 14.5px; line-height: 1.4; }
284
284
  .inside-line__meta { color: var(--ink-faint); white-space: nowrap; font-variant-numeric: tabular-nums; }
285
285
 
286
+ /* THE FLOOR DESK (task 1004558; Desks.dc.html): the chosen floor as one panel
287
+ you act from. Status orange only when it needs you; tiles are one number and
288
+ two words; rows are a name, one fact and at most two chips; a quiet floor is
289
+ one lit line. Tokens only, so both modes come from the pack. */
290
+ .desk { gap: 0; }
291
+ .desk .inside-open__head { padding-bottom: 14px; }
292
+ .desk-status { font-size: 14px; color: var(--ink-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
293
+ .desk-status--hot { color: var(--street-accent-text); }
294
+ .desk-tiles { display: flex; flex-wrap: wrap; border-top: 1px solid var(--street-rule); }
295
+ .desk-tile { min-width: 108px; padding: 10px 16px; border-left: 1px solid var(--street-rule); display: flex; flex-direction: column; gap: 2px; }
296
+ .desk-tile:first-child { border-left: 0; padding-left: 0; }
297
+ .desk-tile__v { font-size: 26px; font-weight: 300; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
298
+ .desk-tile__l { font-size: 12.5px; color: var(--ink-faint); white-space: nowrap; }
299
+ .desk-rows { display: flex; flex-direction: column; border-top: 1px solid var(--street-rule); }
300
+ .desk-row { border-bottom: 1px solid var(--street-rule); padding: 12px 0; animation: street-enter 300ms cubic-bezier(.16,1,.3,1) both; }
301
+ .desk-row--gone { animation: desk-gone 320ms cubic-bezier(.4,0,.2,1) both; }
302
+ .desk-row__main { display: flex; align-items: center; gap: 14px; min-height: 44px; }
303
+ .desk-ini { width: 38px; height: 38px; border-radius: 999px; flex-shrink: 0; box-sizing: border-box; display: flex; align-items: center; justify-content: center; font-size: 12.5px; font-weight: 500; background: var(--street-wash); border: 1px solid var(--street-rule); }
304
+ .desk-row--needs .desk-ini { border-color: var(--accent); }
305
+ .desk-name { flex: 1 1 auto; min-width: 0; background: none; border: 0; padding: 4px 0; min-height: 24px; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: 3px; color: var(--ink); font-family: var(--font-body); }
306
+ .desk-name__t { font-size: 15.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
307
+ .desk-name__f { font-size: 13px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
308
+ .desk-acts { display: flex; align-items: center; gap: 8px; flex: none; margin-left: auto; }
309
+ .desk-chip { height: 34px; padding: 0 16px; border-radius: 999px; font-family: var(--font-body); font-size: 13.5px; font-weight: 500; cursor: pointer; white-space: nowrap; text-transform: lowercase; }
310
+ .desk-chip--primary { background: var(--ink); border: 0; color: var(--bg); }
311
+ .desk-chip--quiet, .desk-chip--choice { background: none; border: 1px solid var(--street-rule-strong); color: var(--ink); }
312
+ .desk-chip--choice { height: 30px; padding: 0 14px; }
313
+ .desk-chip:hover { filter: brightness(1.08); }
314
+ .desk-chip[disabled] { opacity: 0.55; cursor: progress; }
315
+ .desk-wait { font-size: 13px; color: var(--ink-faint); border: 1px dashed var(--street-rule-strong); border-radius: 999px; padding: 6px 12px; white-space: nowrap; }
316
+ .desk-facts, .desk-choices { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; padding: 10px 0 2px 52px; animation: street-enter 300ms cubic-bezier(.16,1,.3,1) both; }
317
+ .desk-fact { font-size: 13px; color: var(--ink-soft); background: var(--street-wash); border-radius: 6px; padding: 6px 10px; white-space: nowrap; }
318
+ .desk-choices__ask { font-size: 12.5px; color: var(--ink-faint); margin-right: 4px; }
319
+ .desk-quiet { min-height: 168px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; border-top: 1px solid var(--street-rule); text-align: center; animation: street-enter 300ms cubic-bezier(.16,1,.3,1) both; }
320
+ .desk-quiet__tick { display: block; fill: none; stroke: var(--ink); stroke-width: 1.4; stroke-linecap: round; animation: desk-pop 420ms cubic-bezier(.2,.7,.3,1) both; }
321
+ .desk-quiet--cleared .desk-quiet__tick { stroke: var(--street-accent-text); }
322
+ .desk-quiet__line { font-size: 20px; font-weight: 300; }
323
+ .desk-quiet__sub { font-size: 13.5px; color: var(--ink-faint); }
324
+ .desk-record { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 22px; border-top: 1px solid var(--street-rule); margin-top: 18px; padding-top: 14px; }
325
+ .desk-record__label { font-size: 11.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-faint); }
326
+ .desk-record__item { font-size: 13px; color: var(--ink-faint); white-space: nowrap; }
327
+ .desk-record__item--mine { color: var(--ink); }
328
+ .desk-pop { animation: desk-pop 420ms cubic-bezier(.2,.7,.3,1) both; }
329
+ .desk-name:focus-visible, .desk-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
330
+ @keyframes desk-gone { to { opacity: 0; transform: translateX(24px); } }
331
+ @keyframes desk-pop { 0% { transform: scale(.6); opacity: 0; } 60% { transform: scale(1.15); opacity: 1; } 100% { transform: scale(1); } }
332
+ /* the today meter in the street header: one pip per decision */
333
+ .street-today__pips { display: flex; gap: 5px; }
334
+ .street-today__pip { width: 10px; height: 10px; border-radius: 3px; box-sizing: border-box; border: 1px solid var(--street-rule-strong); }
335
+ .street-today__pip--on { background: var(--accent); border-color: var(--accent); }
336
+ .street-today__n { font-size: 14px; color: var(--ink-soft); font-variant-numeric: tabular-nums; white-space: nowrap; }
337
+
286
338
  /* the record: one filing drawer a month */
287
339
  .inside-record { display: flex; flex-direction: column; gap: 10px; }
288
340
  .inside-record__head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
@@ -318,6 +370,13 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
318
370
  .inside-panel { order: 3; overflow: visible; }
319
371
  .inside-name { font-size: 32px; }
320
372
  .inside-gov__row { flex-wrap: wrap; row-gap: 4px; }
373
+ .desk-row__main { flex-wrap: wrap; row-gap: 8px; }
374
+ .desk-name { flex-basis: calc(100% - 52px); }
375
+ .desk-acts { margin-left: 52px; }
376
+ .desk-facts, .desk-choices { padding-left: 0; }
377
+ .desk-tile { min-width: 0; padding: 10px 14px; }
378
+ .street-today__pips { gap: 3px; }
379
+ .street-today__pip { width: 8px; height: 8px; }
321
380
  }
322
381
 
323
382
  /* the prototype's motion, verbatim */
@@ -11,7 +11,7 @@
11
11
  links, injected into this head by the server like every hall page's
12
12
  (tests/hall_tokens.mjs: no hall page hardcodes a web-font stylesheet). -->
13
13
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
14
- <link rel="stylesheet" href="/builders/street.css?v=2026-10-02-inside1">
14
+ <link rel="stylesheet" href="/builders/street.css?v=2026-10-03-desk1">
15
15
  <link rel="stylesheet" href="/builders/street-card.css?v=2026-10-02-card1">
16
16
  </head>
17
17
  <!-- THE STREET (task 1004525, goal 1000125, R03). The street view of
@@ -67,6 +67,11 @@
67
67
  <h1 class="street-kicker">the street</h1>
68
68
  </div>
69
69
  <div class="street-hud">
70
+ <div class="street-hud__item street-today" title="decisions made today">
71
+ <span class="street-label">today</span>
72
+ <span class="street-today__pips" id="streetTodayPips" aria-hidden="true"></span>
73
+ <span class="street-today__n" id="streetTodayN">0 decided</span>
74
+ </div>
70
75
  <div class="street-hud__item">
71
76
  <span class="street-hud__dot" id="hudDot"></span>
72
77
  <span class="street-label">needs you</span>
@@ -210,8 +215,9 @@
210
215
  <script src="/builders/city-draw.js?v=2026-10-02-street1"></script>
211
216
  <script src="/builders/street-draw.js?v=2026-10-02-street1"></script>
212
217
  <script src="/builders/street-card.js?v=2026-10-02-amend1"></script>
213
- <script src="/builders/street-inside.js?v=2026-10-02-inside1"></script>
218
+ <script src="/builders/street-inside.js?v=2026-10-03-desk1"></script>
219
+ <script src="/builders/street-desk.js?v=2026-10-03-desk1"></script>
214
220
  <script src="/builders/street-amend.js?v=2026-10-02-amend1"></script>
215
- <script src="/builders/street.js?v=2026-10-02-amend1"></script>
221
+ <script src="/builders/street.js?v=2026-10-03-desk1"></script>
216
222
  </body>
217
223
  </html>