@bongos/core 1.21.33 → 1.21.35

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 (37) hide show
  1. package/.bongos-core.json +51 -41
  2. package/docs/copy-inventory.md +19 -17
  3. package/docs/copy-registry.json +39 -21
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +11 -1
  6. package/docs/page-readings.json +322 -194
  7. package/modules/autonomy/governor-docket.js +68 -1
  8. package/modules/government/docket-floors.js +20 -0
  9. package/modules/government/docket-vocab.js +14 -0
  10. package/modules/government/docket.js +103 -2
  11. package/modules/government/routes/government.js +13 -0
  12. package/modules/hall-ui/public/street-card.js +97 -1
  13. package/modules/hall-ui/public/street-desk.js +386 -0
  14. package/modules/hall-ui/public/street-inside.js +14 -10
  15. package/modules/hall-ui/public/street.css +60 -0
  16. package/modules/hall-ui/public/street.html +9 -3
  17. package/modules/hall-ui/public/street.js +238 -17
  18. package/modules/hall-ui/public/street.states.json +314 -6
  19. package/modules/lifecycle/governor-docket.js +24 -2
  20. package/modules/lifecycle/routes/project-settings.js +24 -0
  21. package/modules/onboarding/routes/access-requests.js +7 -0
  22. package/modules/provisioning/core-upgrade.js +1 -1
  23. package/modules/provisioning/routes/core-upgrade.js +36 -1
  24. package/modules/security/routes/reports.js +23 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +12 -0
  28. package/src/bongos/routes/backup.js +26 -0
  29. package/src/bongos/routes/builders.js +40 -0
  30. package/src/bongos/routes/modules.js +18 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/governor_docket.mjs +28 -1
  33. package/tests/hall_matter_card.mjs +12 -3
  34. package/tests/hall_street.mjs +1 -1
  35. package/tests/hall_street_amend.mjs +1 -1
  36. package/tests/hall_street_desk.mjs +557 -0
  37. package/tests/hall_street_inside.mjs +1 -1
@@ -0,0 +1,386 @@
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({
101
+ for: 'yes', object: 'no', fine: 'let in', refuse: 'not now', release: 'release', hold: 'hold',
102
+ // the acts the server hands a row (task 1004559), each its old page's route
103
+ confirm: 'confirm', dispute: 'dispute', fix: 'fixed', remove: 'remove', keep: 'keep',
104
+ signoff: 'sign off', record: 'record it', force: 'force',
105
+ switch_on: 'switch on', switch_off: 'switch off', allow: 'allow', disallow: 'disallow', first: 'first',
106
+ pause: 'pause', resume: 'resume', tier: 'tier', deactivate: 'deactivate', give: 'give', take: 'take back',
107
+ check: 'check', take_core: 'take it', rot: 'set', module_on: 'turn on', module_off: 'turn off', backup: 'back up', invite: 'invite', open: 'open',
108
+ });
109
+ // What a row's chips ask, per act (a reason or an option is one tap, never text).
110
+ const ASKS = Object.freeze({ check: 'check', take_core: 'move to', rot: 'stale after', module_off: 'sure', invite: 'their GitHub name', object: 'why', force: 'why', switch_off: 'why', pause: 'why', deactivate: 'why', signoff: 'confirm', allow: 'which goal', tier: 'which tier', give: 'to whom', take: 'from whom' });
111
+ // How a floor counts rows nobody is waiting on (desk rows), by its desk.
112
+ const DESK_NOUNS = Object.freeze({
113
+ 'people.builders': 'builders', 'people.ranks': 'ranks', 'disputes.confirmed': 'to fix',
114
+ 'automation.switch': 'switch', 'automation.goals': 'allowed', 'automation.runners': 'runners',
115
+ 'people.invite': 'invites', 'release.core': 'update', 'rules.settings': 'settings', 'rules.modules': 'modules', 'treasury.backup': 'backups',
116
+ });
117
+ // An objection's reason as one tap: the five sections the server accepts
118
+ // (street-card.js SECTIONS), each in at most 16 characters.
119
+ const CHIPS = Object.freeze({ scope: 'what it changes', purpose: 'its purpose', why_matters: 'why it matters', fit: 'how it fits', success: 'how to measure' });
120
+ const STATE = Object.freeze({ needs: 'needs you', moving: 'moving', settled: 'settled' });
121
+ const PEOPLE = Object.freeze(['access_requests', 'goal_join_requests']);
122
+
123
+ function str(v) { return v == null ? '' : String(v); }
124
+ function personOf(m) { return m && m.proposer ? str(m.proposer.name || m.proposer.login) : ''; }
125
+ function initials(t) {
126
+ const w = str(t).replace(/[^A-Za-z0-9 ]+/g, ' ').trim().split(/\s+/).filter(Boolean);
127
+ return (w.length > 1 ? w[0].charAt(0) + w[1].charAt(0) : str(w[0]).slice(0, 2)).toUpperCase() || '·';
128
+ }
129
+ function stateOf(m) { return m && STATE[m.state] ? m.state : 'moving'; }
130
+ function isIdea(m) { return !!m && m.source === 'board' && m.short === 'idea to ratify'; }
131
+
132
+ // ---------- which matters sit on which floor ----------
133
+ // A floor bound to a source holds that source's matters in this building; the
134
+ // ideas floor holds the board's open full ideas; every other open matter of
135
+ // the building (a board sitting, a source no floor names) sits on the first
136
+ // floor, the building's front desk. Settled matters are the record, not rows.
137
+ function floorMatters(key, floor, docket) {
138
+ const plan = I && I.PLAN[key];
139
+ if (!plan || !docket || !Array.isArray(docket.matters)) return [];
140
+ const specs = plan.floors;
141
+ const spec = specs[floor];
142
+ if (!spec) return [];
143
+ const bind = spec[2] || {};
144
+ const open = docket.matters.filter((m) => m && stateOf(m) !== 'settled');
145
+ const own = open.filter((m) => m.building === key);
146
+ const sources = new Set(specs.map((s) => s[2] && s[2].source).filter(Boolean));
147
+ const ideasHere = specs.some((s) => s[2] && s[2].floor === 'planning.ideas');
148
+ if (bind.source) return own.filter((m) => m.source === bind.source);
149
+ if (bind.floor === 'planning.ideas') return open.filter(isIdea);
150
+ if (floor !== 0) return [];
151
+ return own.filter((m) => !sources.has(m.source) && !(ideasHere && isIdea(m)));
152
+ }
153
+ // The floor that needs you first (so a building opens on its work), or null.
154
+ function firstNeeds(key, docket) {
155
+ const plan = I && I.PLAN[key];
156
+ if (!plan) return null;
157
+ for (let i = 0; i < plan.floors.length; i++) if (floorMatters(key, i, docket).some((m) => stateOf(m) === 'needs')) return i;
158
+ return null;
159
+ }
160
+
161
+ // ---------- one row ----------
162
+ // ctx { docket, room, constitution, now, viewerId }
163
+ function rowOf(m, ctx) {
164
+ const now = ctx.now || 0;
165
+ const person = personOf(m);
166
+ const people = PEOPLE.indexOf(m.source) >= 0;
167
+ const name = fit(people && person ? person : (m.title || m.short || 'a matter'), LIMITS.name, LIMITS.nameWords);
168
+ const age = ageWord(m.opened_at, now);
169
+ const lead = people ? (m.source === 'goal_join_requests' ? 'goal team' : 'to join') : person ? person.split(/\s+/)[0] : str(m.short || STATE[stateOf(m)]);
170
+ const fact = fit([lead, age].filter(Boolean).join(' · '), LIMITS.fact);
171
+ const when = S && S.whenWord ? S.whenWord(m.deadline, now) : '';
172
+ const facts = [];
173
+ const add = (t) => { const v = fit(t, LIMITS.factLen); if (v && facts.indexOf(v) < 0 && facts.length < LIMITS.facts) facts.push(v); };
174
+ if (m.title && name !== str(m.title)) add(m.title);
175
+ if (person && !people) add('by ' + person);
176
+ if (typeof m.tally === 'string') add(m.tally);
177
+ if (when) add('closes ' + when);
178
+ if (m.where) add(str(m.where).toLowerCase());
179
+ add(STATE[stateOf(m)]);
180
+ if (Array.isArray(m.acts) && m.acts.length && C && C.actsCard) {
181
+ const ac = C.actsCard({ key: m.id, kind: m.source, id: m.ref && m.ref.id != null ? m.ref.id : str(m.id).replace(/^[^:]*:/, ''), who: person || name, title: m.title, building: m.building, href: m.href });
182
+ return Object.assign(actedRow(m.acts, ac, ctx), {
183
+ key: str(m.id), matter: str(m.id), ini: initials(name), name: name, fact: fact, facts: facts,
184
+ needs: stateOf(m) === 'needs', source: m.source, opened: m.opened_at || null,
185
+ });
186
+ }
187
+ 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;
188
+ const actions = [];
189
+ let chips = null;
190
+ if (cv && !cv.readOnly && !cv.loading) {
191
+ cv.actions.forEach((a) => {
192
+ if (actions.length >= LIMITS.actions || !ACT_WORDS[a.key]) return;
193
+ actions.push({ key: a.key, label: ACT_WORDS[a.key], tone: actions.length ? 'quiet' : 'primary', go: a.go || null, ask: a.key === 'object' });
194
+ });
195
+ if (actions.some((a) => a.ask)) {
196
+ 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).' } })) };
197
+ }
198
+ }
199
+ const wait = actions.length ? '' : cv && cv.loading ? 'reading…' : stateOf(m) === 'needs' ? 'not from here' : 'moving';
200
+ return {
201
+ key: str(m.id), matter: str(m.id), ini: initials(name), name: name, fact: fact, facts: facts,
202
+ actions: actions, chips: chips, choices: chips ? { [chips.for]: chips } : {}, wait: wait, needs: stateOf(m) === 'needs', source: m.source, opened: m.opened_at || null, card: cv,
203
+ };
204
+ }
205
+
206
+ // The buttons and chips of a row whose acts the server handed it (task
207
+ // 1004559). An act with options asks first, with its options as one-tap chips;
208
+ // `pick: 'open_goals'` offers the open goals the street already reads; an act
209
+ // whose chips come out empty is left off rather than drawn dead. 'record'
210
+ // goes to the matter's own page (a sign-off that needs a recording).
211
+ function actedRow(acts, card, ctx) {
212
+ const titles = new Map((ctx.goals || []).map((g) => [str(g.id), str(g.title || 'goal ' + g.id)]));
213
+ const actions = [];
214
+ const choices = {};
215
+ acts.forEach((a) => {
216
+ // the core's 'take' is its own word ('take it'), not the rank's 'take back'
217
+ const word = a.key === 'take' && card.subject && card.subject.kind === 'core_pin' ? 'take_core' : a.key;
218
+ if (actions.length >= LIMITS.actions || !ACT_WORDS[word]) return;
219
+ let options = (a.options || []).map((o) => ({ key: str(o.key), label: fit(o.label, LIMITS.chip), input: { option: str(o.key), label: str(o.label) } }));
220
+ if (a.pick === 'open_goals') {
221
+ const except = new Set((a.except || []).map(str));
222
+ options = (ctx.goals || []).filter((g) => g && !except.has(str(g.id))).slice(0, LIMITS.chips)
223
+ .map((g) => ({ key: str(g.id), label: fit(titles.get(str(g.id)), LIMITS.chip), input: { option: str(g.id), label: titles.get(str(g.id)) } }));
224
+ if (!options.length) return;
225
+ }
226
+ options = options.slice(0, LIMITS.chips);
227
+ const goes = a.key === 'record' || a.key === 'open';
228
+ if (goes && !card.href) return;
229
+ const field = a.field || null;
230
+ const ask = options.length > 0 || !!field;
231
+ if (ask) choices[a.key] = field ? { for: a.key, ask: ASKS[word] || 'which', field: field, options: [] } : { for: a.key, ask: ASKS[word] || 'which', options: options };
232
+ actions.push({ key: a.key, label: ACT_WORDS[word], tone: actions.length ? 'quiet' : 'primary', go: goes ? card.href : null, ask: ask });
233
+ });
234
+ const first = actions.find((a) => a.ask);
235
+ return { actions: actions, chips: first ? choices[first.key] : null, choices: choices, wait: actions.length ? '' : 'not from here', card: card };
236
+ }
237
+
238
+ // A desk row (task 1004559): something on this floor the viewer may act on that
239
+ // no one is waiting on, read by the module that owns its route.
240
+ function deskRowOf(d, ctx) {
241
+ const titles = new Map((ctx.goals || []).map((g) => [str(g.id), str(g.title || '')]));
242
+ const hit = /^goal (\d+)$/.exec(str(d.name));
243
+ const full = hit && titles.get(hit[1]) ? titles.get(hit[1]) : str(d.name);
244
+ const name = fit(full, LIMITS.name, LIMITS.nameWords);
245
+ const age = ageWord(d.opened_at, ctx.now || 0);
246
+ const facts = [];
247
+ const add = (t) => { const v = fit(t, LIMITS.factLen); if (v && facts.indexOf(v) < 0 && facts.length < LIMITS.facts) facts.push(v); };
248
+ if (full !== name) add(full);
249
+ (d.facts || []).forEach(add);
250
+ if (age) add('since ' + age);
251
+ const ref = d.ref || {};
252
+ const card = C && C.actsCard ? C.actsCard({ key: d.id, kind: ref.type, id: ref.id, who: full, title: full, building: d.building, href: d.href }) : null;
253
+ return Object.assign(actedRow(d.acts || [], card || { href: null }, ctx), {
254
+ key: str(d.id), matter: str(d.id), ini: initials(name), name: name, fact: fit(d.fact, LIMITS.fact), facts: facts,
255
+ needs: d.needs === true, source: d.desk, opened: d.opened_at || null, desk: true, tool: d.tool === true, over: d.over || null,
256
+ });
257
+ }
258
+ // The desk rows of one floor: those of the desk its PLAN entry names.
259
+ function deskRows(key, floor, docket, ctx) {
260
+ const plan = I && I.PLAN[key];
261
+ const spec = plan && plan.floors[floor];
262
+ const bind = (spec && spec[2]) || {};
263
+ if (!bind.desk || !docket || !Array.isArray(docket.desks)) return [];
264
+ // a floor may hold more than one desk (Rules in force: the settings and the modules)
265
+ const keys = [].concat(bind.desk);
266
+ return docket.desks.filter((x) => x && keys.indexOf(x.key) >= 0 && x.status === 'live' && Array.isArray(x.rows))
267
+ .reduce((all, x) => all.concat(x.rows.filter((r) => r && r.building === key)), []);
268
+ }
269
+ function rowsFor(key, floor, docket, ctx) {
270
+ const c = Object.assign({ docket: docket }, ctx || {});
271
+ const done = c.done || {};
272
+ const desked = deskRows(key, floor, docket).filter((d) => !done[str(d.id)]).map((d) => deskRowOf(d, c)).filter((r) => r.actions.length);
273
+ // a desk row that stands in for a source (the core update's check-then-take)
274
+ // replaces that source's matter rows on this floor
275
+ const over = desked.map((r) => r.over).filter(Boolean);
276
+ return floorMatters(key, floor, docket).filter((m) => !done[str(m.id)] && over.indexOf(m.source) < 0).map((m) => rowOf(m, c))
277
+ .concat(desked)
278
+ .sort((a, b) => (b.needs - a.needs) || ((Date.parse(a.opened || 0) || 0) - (Date.parse(b.opened || 0) || 0)));
279
+ }
280
+
281
+ // ---------- the whole desk ----------
282
+ // input { key, floor (index), docket, goals (open), now, room, constitution,
283
+ // viewerId, done {matterId: true}, mine [what you did here, newest
284
+ // first], cleared (you cleared it this visit), today (decisions
285
+ // today), sym {d, f} (the floor's sign, from buildingModel) }
286
+ function deskModel(input) {
287
+ const o = input || {};
288
+ const plan = I && I.PLAN[o.key];
289
+ if (!plan) return null;
290
+ const docket = o.docket && Array.isArray(o.docket.matters) ? o.docket : null;
291
+ const floor = Math.max(0, Math.min(Number(o.floor) || 0, plan.floors.length - 1));
292
+ const spec = plan.floors[floor];
293
+ const fl = I.floorFor(spec, { key: o.key, docket: docket, goals: o.goals || [], now: o.now || 0 });
294
+ const ctx = { room: o.room, constitution: o.constitution, now: o.now || 0, viewerId: o.viewerId, done: o.done, goals: o.goals || [] };
295
+ const rows = docket ? rowsFor(o.key, floor, docket, ctx) : [];
296
+ const bind = spec[2] || {};
297
+ const src = bind.source ? bind.source : bind.floor === 'planning.ideas' ? 'idea' : floor === 0 ? 'board' : null;
298
+ const needs = rows.filter((r) => r.needs);
299
+ const noun = NOUNS[src] || 'open';
300
+ // rows nobody waits on: 'moving' for matters, the desk's own noun for desk rows
301
+ const moving = rows.length && rows.every((r) => r.desk) ? (DESK_NOUNS[rows[0].source] || 'here') : 'moving';
302
+ const counted = rows.filter((r) => !r.tool);
303
+ const oldestOf = (list) => list.reduce((a, r) => (r.opened && (!a || Date.parse(r.opened) < Date.parse(a)) ? r.opened : a), null);
304
+ const cleared = !!o.cleared && rows.length === 0;
305
+ const quiet = rows.length === 0;
306
+ let status, hot = false;
307
+ if (!docket) status = 'the docket could not be read';
308
+ else if (needs.length) { hot = true; const age = ageWord(oldestOf(needs), o.now); status = needs.length + ' ' + noun + (age ? ' · oldest ' + age : ''); }
309
+ else if (counted.length) { const age = ageWord(oldestOf(counted), o.now); status = counted.length + ' ' + moving + (age ? ' · oldest ' + age : ''); }
310
+ else if (rows.length) status = fl.line;
311
+ else if (cleared) status = 'floor clear';
312
+ else status = 'nothing needs you · ' + fl.line;
313
+ const history = docket && Array.isArray(docket.history) ? docket.history.filter((h) => h && h.building === o.key) : [];
314
+ // tiles: one number and two words, never a zero
315
+ const tiles = [];
316
+ const tile = (v, l) => {
317
+ const val = str(v).trim();
318
+ if (!/[0-9]/.test(val) || /^[$]?[0.,]+%?$/.test(val)) return;
319
+ // two words, read as a label: the rest of a longer line is left off, not marked cut
320
+ const words = str(l).replace(/\s+/g, ' ').trim().split(' ').slice(0, LIMITS.tileWords).join(' ').replace(/[,.;:·-]+$/, '');
321
+ tiles.push({ key: 't' + tiles.length, v: fit(val, LIMITS.tileValue), l: fit(words, LIMITS.tileLabel) });
322
+ };
323
+ if (rows.length) { tile(needs.length, noun); tile(counted.length - needs.length, moving); }
324
+ else if (fl.count) tile(fl.count, str(fl.line).replace(/^[$]?[0-9][0-9,.%]*\s*/, '') || 'on this floor');
325
+ tile(history.length, 'on record');
326
+ const record = (o.mine || []).map((t, i) => ({ key: 'm' + i, text: fit(t + ' · now', LIMITS.recordLen), mine: true }))
327
+ .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 })))
328
+ .slice(0, LIMITS.record);
329
+ return {
330
+ key: o.key + ':' + floor, building: o.key, floor: floor, name: spec[0], sym: o.sym || { d: '', f: '' },
331
+ status: fit(status, LIMITS.status), hot: hot,
332
+ tiles: tiles.slice(0, LIMITS.tiles), rows: rows,
333
+ quiet: quiet, cleared: cleared,
334
+ clearLine: fit(cleared ? (CLEAR[src] || 'Floor clear') : 'Nothing needs you here', LIMITS.clearLine),
335
+ clearSub: fit(cleared ? 'lit up · ' + (o.today || 0) + ' decided today' : fl.line, LIMITS.clearSub),
336
+ record: record,
337
+ };
338
+ }
339
+
340
+ // ---------- the rule as a checker ----------
341
+ function breaks(desk) {
342
+ const out = [];
343
+ if (!desk) return out;
344
+ const bad = (what, v) => out.push(desk.key + ' ' + what + ': ' + JSON.stringify(v));
345
+ const oneLine = (s) => typeof s === 'string' && !/[\r\n]/.test(s);
346
+ if (!oneLine(desk.status) || desk.status.length > LIMITS.status) bad('status too long', desk.status);
347
+ if (desk.hot !== desk.rows.some((r) => r.needs)) bad('status is orange only when something needs you', desk.status);
348
+ if (desk.tiles.length > LIMITS.tiles) bad('more than three tiles', desk.tiles.length);
349
+ desk.tiles.forEach((t) => {
350
+ if (!/[0-9]/.test(t.v) || t.v.length > LIMITS.tileValue || /^[$]?[0.,]+%?$/.test(t.v)) bad('tile value', t.v);
351
+ if (!t.l || wordCount(t.l) > LIMITS.tileWords || t.l.length > LIMITS.tileLabel) bad('tile label', t.l);
352
+ });
353
+ if (desk.quiet !== (desk.rows.length === 0)) bad('quiet and rows disagree', desk.rows.length);
354
+ 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]);
355
+ desk.rows.forEach((r) => {
356
+ if (!r.name || !oneLine(r.name) || wordCount(r.name) > LIMITS.nameWords || r.name.length > LIMITS.name) bad('row name', r.name);
357
+ if (!oneLine(r.fact) || r.fact.length > LIMITS.fact) bad('row fact', r.fact);
358
+ if (r.actions.length > LIMITS.actions) bad('more than two buttons', r.actions.map((a) => a.label));
359
+ if (r.facts.length > LIMITS.facts || r.facts.some((f) => !oneLine(f) || f.length > LIMITS.factLen)) bad('row facts', r.facts);
360
+ 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));
361
+ if (r.actions.some((a) => a.ask) && !r.chips) bad('a reason without chips', r.name);
362
+ Object.keys(r.choices || {}).forEach((k) => {
363
+ const c = r.choices[k];
364
+ if ((!c.field && !c.options.length) || c.options.length > LIMITS.chips || c.options.some((x) => !x.label || x.label.length > LIMITS.chip)) bad('chips', c.options.map((x) => x.label));
365
+ });
366
+ if (r.actions.some((a) => a.ask && !(r.choices || {})[a.key])) bad('an ask without its own chips', r.name);
367
+ });
368
+ 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));
369
+ return out;
370
+ }
371
+
372
+ // The street header's meter: one pip per decision today, ten to a row.
373
+ function todayPips(n) {
374
+ const k = Math.max(0, Math.floor(Number(n) || 0));
375
+ const pips = [];
376
+ 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 });
377
+ return { pips: pips, text: k + ' decided' };
378
+ }
379
+
380
+ const api = {
381
+ LIMITS: LIMITS, ACT_WORDS: ACT_WORDS, ASKS: ASKS, DESK_NOUNS: DESK_NOUNS, CHIPS: CHIPS, NOUNS: NOUNS, CLEAR: CLEAR,
382
+ fit: fit, ageWord: ageWord, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips,
383
+ };
384
+ root.GovDesk = api;
385
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
386
+ }(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
  }
@@ -328,17 +331,17 @@
328
331
  rules: {
329
332
  holds: 'Standing rules. Each one settles a kind of matter so the council never votes on it again.', rule: 'majority', cap: 8,
330
333
  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',
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' }]],
334
+ floors: [['Rules in force', 'the rules settling matters now', { floor: 'rules.in_force', reading: 'rules', desk: ['rules.settings', 'rules.modules'] }], ['New rules', 'a rule put to the council', { source: 'standing_rules' }]],
332
335
  },
333
336
  disputes: {
334
337
  holds: 'Grade appeals, removal requests and security reports. Every ruling becomes precedent for the next one.', rule: 'decide', cap: 5,
335
338
  growth: 'Busy weeks go to a panel of three once a second room is built.', annex: 'room for a second room, a panel of three',
336
- floors: [['Security reports', 'flaws found in our own walls', { source: 'redteam_reports' }], ['Removals', 'work proposed for removal', { source: 'removal_proposals' }], ['Grade appeals', 'a grade someone asks to change', { source: 'grade_appeals' }]],
339
+ floors: [['Security reports', 'flaws found in our own walls', { source: 'redteam_reports', desk: 'disputes.confirmed' }], ['Removals', 'work proposed for removal', { source: 'removal_proposals' }], ['Grade appeals', 'a grade someone asks to change', { source: 'grade_appeals' }]],
337
340
  },
338
341
  people: {
339
342
  holds: 'Who belongs, the rank each person holds, and who sits in which seat.', rule: 'consent', cap: 8,
340
343
  growth: 'A new floor opens whenever the charter creates a new seat.', annex: 'room for an annex of seats',
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' }]],
344
+ 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', desk: 'people.builders' }], ['Newcomers', 'builders on newcomer tasks', { floor: 'people.newcomers' }], ['Waiting to join', 'asked to join the project', { source: 'access_requests', desk: 'people.invite' }], ['Ranks', 'the ranks, and who holds each', { floor: 'people.ranks', desk: 'people.ranks' }], ['Goal teams', 'asked to join a goal team', { source: 'goal_join_requests' }]],
342
345
  },
343
346
  planning: {
344
347
  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,
@@ -348,7 +351,7 @@
348
351
  automation: {
349
352
  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,
350
353
  growth: 'A new floor opens for each goal you let it build.', annex: 'room for an annex',
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' }]],
354
+ floors: [['The switch', 'unattended building, on or off', { source: 'autobongos_fence', desk: 'automation.switch' }], ['Allowed goals', 'the goals it may build', { floor: 'automation.allowed_goals', desk: 'automation.goals' }], ['Running now', 'builds in progress', { floor: 'automation.running', desk: 'automation.runners' }], ['Spending limit', 'what it may spend', { floor: 'automation.spending_limit' }], ['Reports', 'what it did, told without asking', { floor: 'automation.reports' }]],
352
355
  },
353
356
  review: {
354
357
  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,
@@ -358,12 +361,12 @@
358
361
  release: {
359
362
  holds: 'Putting finished work live, and taking updates to Bongos itself.', rule: 'decide', cap: 7,
360
363
  growth: 'Room for a second gate if releases are ever split by area.', annex: 'room for a second gate',
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' }]],
364
+ 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', desk: 'release.core' }], ['Live this month', 'what went live', { floor: 'release.live_month' }], ['Ready to go', 'signed off, waiting to go live', { source: 'gate_approvals' }]],
362
365
  },
363
366
  treasury: {
364
367
  holds: 'The budget, and who gets paid for what.', rule: 'majority', cap: 7,
365
368
  growth: 'Each new spending line takes a floor.', annex: 'room for an annex of spending lines',
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' }]],
369
+ 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', desk: 'treasury.backup' }], ['Reserve', 'what is left this month', { reading: 'money' }], ['Spending matters', 'spending put to a vote', { source: 'treasury_spending' }]],
367
370
  },
368
371
  });
369
372
  const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
@@ -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,59 @@ 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-field { height: 30px; min-width: 0; flex: 1 1 140px; max-width: 220px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--street-rule-strong); background: none; color: var(--ink); font-family: var(--font-body); font-size: 13.5px; }
314
+ .desk-chip:hover { filter: brightness(1.08); }
315
+ .desk-chip[disabled] { opacity: 0.55; cursor: progress; }
316
+ .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; }
317
+ .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; }
318
+ .desk-fact { font-size: 13px; color: var(--ink-soft); background: var(--street-wash); border-radius: 6px; padding: 6px 10px; white-space: nowrap; }
319
+ .desk-choices__ask { font-size: 12.5px; color: var(--ink-faint); margin-right: 4px; }
320
+ .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; }
321
+ .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; }
322
+ .desk-quiet--cleared .desk-quiet__tick { stroke: var(--street-accent-text); }
323
+ .desk-quiet__line { font-size: 20px; font-weight: 300; }
324
+ .desk-quiet__sub { font-size: 13.5px; color: var(--ink-faint); }
325
+ .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; }
326
+ .desk-record__label { font-size: 11.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-faint); }
327
+ .desk-record__item { font-size: 13px; color: var(--ink-faint); white-space: nowrap; }
328
+ .desk-record__item--mine { color: var(--ink); }
329
+ .desk-pop { animation: desk-pop 420ms cubic-bezier(.2,.7,.3,1) both; }
330
+ .desk-name:focus-visible, .desk-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
331
+ @keyframes desk-gone { to { opacity: 0; transform: translateX(24px); } }
332
+ @keyframes desk-pop { 0% { transform: scale(.6); opacity: 0; } 60% { transform: scale(1.15); opacity: 1; } 100% { transform: scale(1); } }
333
+ /* the today meter in the street header: one pip per decision */
334
+ .street-today__pips { display: flex; gap: 5px; }
335
+ .street-today__pip { width: 10px; height: 10px; border-radius: 3px; box-sizing: border-box; border: 1px solid var(--street-rule-strong); }
336
+ .street-today__pip--on { background: var(--accent); border-color: var(--accent); }
337
+ .street-today__n { font-size: 14px; color: var(--ink-soft); font-variant-numeric: tabular-nums; white-space: nowrap; }
338
+
286
339
  /* the record: one filing drawer a month */
287
340
  .inside-record { display: flex; flex-direction: column; gap: 10px; }
288
341
  .inside-record__head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
@@ -318,6 +371,13 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
318
371
  .inside-panel { order: 3; overflow: visible; }
319
372
  .inside-name { font-size: 32px; }
320
373
  .inside-gov__row { flex-wrap: wrap; row-gap: 4px; }
374
+ .desk-row__main { flex-wrap: wrap; row-gap: 8px; }
375
+ .desk-name { flex-basis: calc(100% - 52px); }
376
+ .desk-acts { margin-left: 52px; }
377
+ .desk-facts, .desk-choices { padding-left: 0; }
378
+ .desk-tile { min-width: 0; padding: 10px 14px; }
379
+ .street-today__pips { gap: 3px; }
380
+ .street-today__pip { width: 8px; height: 8px; }
321
381
  }
322
382
 
323
383
  /* 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>