@bongos/core 1.21.34 → 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.
@@ -97,7 +97,23 @@
97
97
  gate_approvals: 'Everything ready is live', blockers: 'Nothing blocked on you', board: 'Every vote is in', idea: 'No ideas waiting',
98
98
  });
99
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' });
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
+ });
101
117
  // An objection's reason as one tap: the five sections the server accepts
102
118
  // (street-card.js SECTIONS), each in at most 16 characters.
103
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' });
@@ -161,6 +177,13 @@
161
177
  if (when) add('closes ' + when);
162
178
  if (m.where) add(str(m.where).toLowerCase());
163
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
+ }
164
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;
165
188
  const actions = [];
166
189
  let chips = null;
@@ -176,13 +199,82 @@
176
199
  const wait = actions.length ? '' : cv && cv.loading ? 'reading…' : stateOf(m) === 'needs' ? 'not from here' : 'moving';
177
200
  return {
178
201
  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,
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,
180
203
  };
181
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
+ }
182
269
  function rowsFor(key, floor, docket, ctx) {
183
270
  const c = Object.assign({ docket: docket }, ctx || {});
184
271
  const done = c.done || {};
185
- return floorMatters(key, floor, docket).filter((m) => !done[str(m.id)]).map((m) => rowOf(m, c))
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)
186
278
  .sort((a, b) => (b.needs - a.needs) || ((Date.parse(a.opened || 0) || 0) - (Date.parse(b.opened || 0) || 0)));
187
279
  }
188
280
 
@@ -199,19 +291,23 @@
199
291
  const floor = Math.max(0, Math.min(Number(o.floor) || 0, plan.floors.length - 1));
200
292
  const spec = plan.floors[floor];
201
293
  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 };
294
+ const ctx = { room: o.room, constitution: o.constitution, now: o.now || 0, viewerId: o.viewerId, done: o.done, goals: o.goals || [] };
203
295
  const rows = docket ? rowsFor(o.key, floor, docket, ctx) : [];
204
296
  const bind = spec[2] || {};
205
297
  const src = bind.source ? bind.source : bind.floor === 'planning.ideas' ? 'idea' : floor === 0 ? 'board' : null;
206
298
  const needs = rows.filter((r) => r.needs);
207
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);
208
303
  const oldestOf = (list) => list.reduce((a, r) => (r.opened && (!a || Date.parse(r.opened) < Date.parse(a)) ? r.opened : a), null);
209
304
  const cleared = !!o.cleared && rows.length === 0;
210
305
  const quiet = rows.length === 0;
211
306
  let status, hot = false;
212
307
  if (!docket) status = 'the docket could not be read';
213
308
  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 : ''); }
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;
215
311
  else if (cleared) status = 'floor clear';
216
312
  else status = 'nothing needs you · ' + fl.line;
217
313
  const history = docket && Array.isArray(docket.history) ? docket.history.filter((h) => h && h.building === o.key) : [];
@@ -224,7 +320,7 @@
224
320
  const words = str(l).replace(/\s+/g, ' ').trim().split(' ').slice(0, LIMITS.tileWords).join(' ').replace(/[,.;:·-]+$/, '');
225
321
  tiles.push({ key: 't' + tiles.length, v: fit(val, LIMITS.tileValue), l: fit(words, LIMITS.tileLabel) });
226
322
  };
227
- if (rows.length) { tile(needs.length, noun); tile(rows.length - needs.length, 'moving'); }
323
+ if (rows.length) { tile(needs.length, noun); tile(counted.length - needs.length, moving); }
228
324
  else if (fl.count) tile(fl.count, str(fl.line).replace(/^[$]?[0-9][0-9,.%]*\s*/, '') || 'on this floor');
229
325
  tile(history.length, 'on record');
230
326
  const record = (o.mine || []).map((t, i) => ({ key: 'm' + i, text: fit(t + ' · now', LIMITS.recordLen), mine: true }))
@@ -263,6 +359,11 @@
263
359
  if (r.facts.length > LIMITS.facts || r.facts.some((f) => !oneLine(f) || f.length > LIMITS.factLen)) bad('row facts', r.facts);
264
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));
265
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);
266
367
  });
267
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));
268
369
  return out;
@@ -277,7 +378,7 @@
277
378
  }
278
379
 
279
380
  const api = {
280
- LIMITS: LIMITS, ACT_WORDS: ACT_WORDS, CHIPS: CHIPS, NOUNS: NOUNS, CLEAR: CLEAR,
381
+ LIMITS: LIMITS, ACT_WORDS: ACT_WORDS, ASKS: ASKS, DESK_NOUNS: DESK_NOUNS, CHIPS: CHIPS, NOUNS: NOUNS, CLEAR: CLEAR,
281
382
  fit: fit, ageWord: ageWord, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips,
282
383
  };
283
384
  root.GovDesk = api;
@@ -331,17 +331,17 @@
331
331
  rules: {
332
332
  holds: 'Standing rules. Each one settles a kind of matter so the council never votes on it again.', rule: 'majority', cap: 8,
333
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',
334
- 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' }]],
335
335
  },
336
336
  disputes: {
337
337
  holds: 'Grade appeals, removal requests and security reports. Every ruling becomes precedent for the next one.', rule: 'decide', cap: 5,
338
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',
339
- 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' }]],
340
340
  },
341
341
  people: {
342
342
  holds: 'Who belongs, the rank each person holds, and who sits in which seat.', rule: 'consent', cap: 8,
343
343
  growth: 'A new floor opens whenever the charter creates a new seat.', annex: 'room for an annex of seats',
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' }], ['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' }]],
345
345
  },
346
346
  planning: {
347
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,
@@ -351,7 +351,7 @@
351
351
  automation: {
352
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,
353
353
  growth: 'A new floor opens for each goal you let it build.', annex: 'room for an annex',
354
- 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' }]],
355
355
  },
356
356
  review: {
357
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,
@@ -361,12 +361,12 @@
361
361
  release: {
362
362
  holds: 'Putting finished work live, and taking updates to Bongos itself.', rule: 'decide', cap: 7,
363
363
  growth: 'Room for a second gate if releases are ever split by area.', annex: 'room for a second gate',
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' }], ['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' }]],
365
365
  },
366
366
  treasury: {
367
367
  holds: 'The budget, and who gets paid for what.', rule: 'majority', cap: 7,
368
368
  growth: 'Each new spending line takes a floor.', annex: 'room for an annex of spending lines',
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' }], ['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' }]],
370
370
  },
371
371
  });
372
372
  const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
@@ -310,6 +310,7 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
310
310
  .desk-chip--primary { background: var(--ink); border: 0; color: var(--bg); }
311
311
  .desk-chip--quiet, .desk-chip--choice { background: none; border: 1px solid var(--street-rule-strong); color: var(--ink); }
312
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; }
313
314
  .desk-chip:hover { filter: brightness(1.08); }
314
315
  .desk-chip[disabled] { opacity: 0.55; cursor: progress; }
315
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; }
@@ -621,7 +621,7 @@
621
621
  r.actions.slice().reverse().forEach(function (a) {
622
622
  acts.appendChild(chipBtn('desk-chip--' + a.tone, a.label, function () {
623
623
  if (a.go) { window.location.href = a.go; return; }
624
- if (a.ask) { desk.ask = desk.ask === r.key ? null : r.key; desk.open = null; draw(); focusRow(r.key, 'chip'); return; }
624
+ if (a.ask) { var at = r.key + '|' + a.key; desk.ask = desk.ask === at ? null : at; desk.open = null; draw(); focusRow(r.key, 'chip'); return; }
625
625
  deskAct(r, a.key, {});
626
626
  }));
627
627
  });
@@ -632,10 +632,23 @@
632
632
  r.facts.forEach(function (f) { facts.appendChild(node('span', 'desk-fact', f)); });
633
633
  row.appendChild(facts);
634
634
  }
635
- if (desk.ask === r.key && r.chips) {
635
+ var asked = desk.ask && desk.ask.indexOf(r.key + '|') === 0 ? (r.choices || {})[desk.ask.slice(r.key.length + 1)] : null;
636
+ if (asked) {
636
637
  var chips = node('div', 'desk-choices');
637
- chips.appendChild(node('span', 'desk-choices__ask', r.chips.ask));
638
- r.chips.options.forEach(function (c) { chips.appendChild(chipBtn('desk-chip--choice', c.label, function () { deskAct(r, r.chips.for, c.input); })); });
638
+ chips.appendChild(node('span', 'desk-choices__ask', asked.ask));
639
+ asked.options.forEach(function (c) { chips.appendChild(chipBtn('desk-chip--choice', c.label, function () { deskAct(r, asked.for, c.input); })); });
640
+ if (asked.field) {
641
+ // the one typed value a desk takes: a name to invite (never a reason)
642
+ var input = node('input', 'desk-field');
643
+ input.setAttribute('type', 'text');
644
+ input.setAttribute('aria-label', asked.ask);
645
+ input.setAttribute('autocomplete', 'off');
646
+ input.setAttribute('maxlength', '60');
647
+ var send = function () { var v = input.value.trim(); if (v) deskAct(r, asked.for, { option: v, label: v }); };
648
+ input.addEventListener('keydown', function (e) { if (e.key === 'Enter') send(); });
649
+ chips.appendChild(input);
650
+ chips.appendChild(chipBtn('desk-chip--primary', 'send', send));
651
+ }
639
652
  row.appendChild(chips);
640
653
  }
641
654
  return row;
@@ -666,7 +679,13 @@
666
679
  if (typeof setTimeout !== 'function') return then();
667
680
  setTimeout(then, 320);
668
681
  }
669
- var DONE_WORDS = { for: 'voted yes', object: 'voted no', fine: 'let in', refuse: 'turned away', release: 'released', hold: 'held' };
682
+ var DONE_WORDS = {
683
+ for: 'voted yes', object: 'voted no', fine: 'let in', refuse: 'turned away', release: 'released', hold: 'held',
684
+ confirm: 'confirmed', dispute: 'disputed', fix: 'fixed', remove: 'removed', keep: 'kept', signoff: 'signed off', force: 'forced',
685
+ switch_on: 'switched on', switch_off: 'switched off', allow: 'allowed', disallow: 'disallowed', first: 'put first',
686
+ pause: 'paused', resume: 'resumed', tier: 'new tier', deactivate: 'deactivated', give: 'gave', take: 'took back',
687
+ check: 'checked', rot: 'set', module_on: 'turned on', module_off: 'turned off', backup: 'backed up', invite: 'invited',
688
+ };
670
689
  // One act from a row: checked, sent through the card's own request (the
671
690
  // server's permission checks decide), then the row clears, the floor counts
672
691
  // it, the meter takes a pip and the toast says what happened. An act that
@@ -55,14 +55,37 @@
55
55
  "auth": true,
56
56
  "_": "In the hall since task 1004544: the shell's full-screen switch pressed, the sidebar and the top bar gone, the street filling the window and the one exit in the corner (the sky's mechanism, task 1004327).",
57
57
  "actions": [
58
- ["wait", 900],
59
- ["click", "#fullscreen-toggle"],
60
- ["wait", 500]
58
+ [
59
+ "wait",
60
+ 900
61
+ ],
62
+ [
63
+ "click",
64
+ "#fullscreen-toggle"
65
+ ],
66
+ [
67
+ "wait",
68
+ 500
69
+ ]
61
70
  ],
62
71
  "expect": {
63
- "visible": ["#streetBoard", "#streetSigns", "#streetMode", "#fullscreen-toggle"],
64
- "hidden": ["#app-sidebar", "#topbar-title"],
65
- "attr": [["#fullscreen-toggle", "aria-pressed", "true"]]
72
+ "visible": [
73
+ "#streetBoard",
74
+ "#streetSigns",
75
+ "#streetMode",
76
+ "#fullscreen-toggle"
77
+ ],
78
+ "hidden": [
79
+ "#app-sidebar",
80
+ "#topbar-title"
81
+ ],
82
+ "attr": [
83
+ [
84
+ "#fullscreen-toggle",
85
+ "aria-pressed",
86
+ "true"
87
+ ]
88
+ ]
66
89
  }
67
90
  },
68
91
  "card": {
@@ -456,6 +479,208 @@
456
479
  ".desk-quiet"
457
480
  ]
458
481
  }
482
+ },
483
+ "desk-people-builders": {
484
+ "auth": true,
485
+ "_": "Task 1004559: People, Builders floor. Each builder is a row with tier and deactivate, the acts the old builders page held.",
486
+ "url": "/builders/street#building=people",
487
+ "actions": [
488
+ [
489
+ "wait",
490
+ 900
491
+ ],
492
+ [
493
+ "clickIfVisible",
494
+ "[data-craft-close]"
495
+ ],
496
+ [
497
+ "wait",
498
+ 300
499
+ ],
500
+ [
501
+ "click",
502
+ "[data-floor=\"2\"]"
503
+ ],
504
+ [
505
+ "wait",
506
+ 600
507
+ ]
508
+ ],
509
+ "expect": {
510
+ "visible": [
511
+ ".desk-rows",
512
+ ".desk-chip--primary"
513
+ ]
514
+ }
515
+ },
516
+ "desk-people-tier": {
517
+ "auth": true,
518
+ "_": "Task 1004559: the tier button opens its tiers as one-tap chips in place (one rung up at most).",
519
+ "url": "/builders/street#building=people",
520
+ "actions": [
521
+ [
522
+ "wait",
523
+ 900
524
+ ],
525
+ [
526
+ "clickIfVisible",
527
+ "[data-craft-close]"
528
+ ],
529
+ [
530
+ "wait",
531
+ 300
532
+ ],
533
+ [
534
+ "click",
535
+ "[data-floor=\"2\"]"
536
+ ],
537
+ [
538
+ "wait",
539
+ 600
540
+ ],
541
+ [
542
+ "click",
543
+ ".desk-row .desk-chip--primary"
544
+ ],
545
+ [
546
+ "wait",
547
+ 400
548
+ ]
549
+ ],
550
+ "expect": {
551
+ "visible": [
552
+ ".desk-choices"
553
+ ]
554
+ }
555
+ },
556
+ "desk-disputes": {
557
+ "auth": true,
558
+ "_": "Task 1004559: Disputes, Security reports. Confirm or dispute an open report; mark a confirmed one fixed.",
559
+ "url": "/builders/street#building=disputes",
560
+ "actions": [
561
+ [
562
+ "wait",
563
+ 900
564
+ ],
565
+ [
566
+ "clickIfVisible",
567
+ "[data-craft-close]"
568
+ ],
569
+ [
570
+ "wait",
571
+ 300
572
+ ],
573
+ [
574
+ "click",
575
+ "[data-floor=\"0\"]"
576
+ ],
577
+ [
578
+ "wait",
579
+ 600
580
+ ]
581
+ ],
582
+ "expect": {
583
+ "visible": [
584
+ ".desk-rows",
585
+ ".desk-status--hot"
586
+ ]
587
+ }
588
+ },
589
+ "desk-automation-goals": {
590
+ "auth": true,
591
+ "_": "Task 1004559: Automation, Allowed goals. Disallow, put first, or allow an open goal from chips.",
592
+ "url": "/builders/street#building=automation",
593
+ "actions": [
594
+ [
595
+ "wait",
596
+ 900
597
+ ],
598
+ [
599
+ "clickIfVisible",
600
+ "[data-craft-close]"
601
+ ],
602
+ [
603
+ "wait",
604
+ 300
605
+ ],
606
+ [
607
+ "click",
608
+ "[data-floor=\"1\"]"
609
+ ],
610
+ [
611
+ "wait",
612
+ 600
613
+ ]
614
+ ],
615
+ "expect": {
616
+ "visible": [
617
+ ".desk-rows"
618
+ ]
619
+ }
620
+ },
621
+ "desk-release-core": {
622
+ "auth": true,
623
+ "_": "Task 1004559: Release, Core update. Check first (a dry run); take it is offered only after the check passed for that version.",
624
+ "url": "/builders/street#building=release",
625
+ "actions": [
626
+ [
627
+ "wait",
628
+ 900
629
+ ],
630
+ [
631
+ "clickIfVisible",
632
+ "[data-craft-close]"
633
+ ],
634
+ [
635
+ "wait",
636
+ 300
637
+ ],
638
+ [
639
+ "click",
640
+ "[data-floor=\"3\"]"
641
+ ],
642
+ [
643
+ "wait",
644
+ 600
645
+ ]
646
+ ],
647
+ "expect": {
648
+ "visible": [
649
+ ".desk-rows"
650
+ ]
651
+ }
652
+ },
653
+ "desk-rules": {
654
+ "auth": true,
655
+ "_": "Task 1004559: Rules in force. The rot timer as day chips, work categories as a link, each module on or off.",
656
+ "url": "/builders/street#building=rules",
657
+ "actions": [
658
+ [
659
+ "wait",
660
+ 900
661
+ ],
662
+ [
663
+ "clickIfVisible",
664
+ "[data-craft-close]"
665
+ ],
666
+ [
667
+ "wait",
668
+ 300
669
+ ],
670
+ [
671
+ "click",
672
+ "[data-floor=\"0\"]"
673
+ ],
674
+ [
675
+ "wait",
676
+ 600
677
+ ]
678
+ ],
679
+ "expect": {
680
+ "visible": [
681
+ ".desk-rows"
682
+ ]
683
+ }
459
684
  }
460
685
  },
461
686
  "reducedMotion": {
@@ -58,10 +58,29 @@ function removalMatter(r) {
58
58
  tally: null,
59
59
  href: `/task/${encodeURIComponent(r.proposal_task_id)}`,
60
60
  ref: { type: 'task', id: r.proposal_task_id },
61
+ // POST /removal-proposals/:id/decide, the docket source's own atom (task 1004559)
62
+ acts: [{ key: 'remove' }, { key: 'keep' }],
61
63
  };
62
64
  }
63
65
 
64
- function uatMatter(c) {
66
+ // The one-tap reasons a force sign-off may give (POST /done-when/:id/satisfy
67
+ // requires one); the desk sends the label as the override reason.
68
+ const FORCE_REASONS = Object.freeze([
69
+ { key: 'tested_by_hand', label: 'tested by hand' },
70
+ { key: 'checked_review', label: 'seen in review' },
71
+ { key: 'no_longer', label: 'no longer applies' },
72
+ ]);
73
+ // What the viewer may do on a criterion waiting for a tester (task 1004559). A
74
+ // backend-only criterion signs off in one tap (with the live attestation as its
75
+ // chip); a user-facing one needs a recording, so its act goes to the goal's page.
76
+ // Force sign-off only for a holder of criterion.satisfy, that route's own atom.
77
+ function uatActs(c, { force = false } = {}) {
78
+ const acts = [c.backend_only === true ? { key: 'signoff', options: [{ key: 'live', label: 'tested it live' }] } : { key: 'record' }];
79
+ if (force) acts.push({ key: 'force', options: FORCE_REASONS });
80
+ return acts;
81
+ }
82
+
83
+ function uatMatter(c, opts) {
65
84
  return {
66
85
  id: c.id,
67
86
  rule: 'signoff',
@@ -75,6 +94,7 @@ function uatMatter(c) {
75
94
  tally: { for: c.shipped_total, against: 0, open: 0, needs: 'one sign-off by someone who did not build it' },
76
95
  href: c.goal_id ? `/goals#/goal/${encodeURIComponent(c.goal_id)}` : '/goals',
77
96
  ref: { type: 'criterion', id: c.id },
97
+ acts: uatActs(c, opts),
78
98
  };
79
99
  }
80
100
 
@@ -326,7 +346,8 @@ function contributeLifecycleToDocket() {
326
346
  source: 'criteria_uat',
327
347
  building: 'review',
328
348
  gate: { permission: 'criterion.review' },
329
- read: async () => (await doneWhen.listPendingReviewCriteria({})).filter((c) => c.awaiting_uat === true).map(uatMatter),
349
+ read: async (ctx) => (await doneWhen.listPendingReviewCriteria({})).filter((c) => c.awaiting_uat === true)
350
+ .map((c) => uatMatter(c, { force: ctx.has('criterion.satisfy') })),
330
351
  });
331
352
  // Own-scoped by its read, like GET /goals/inbox: a goal's lead sees the requests
332
353
  // to join their goals, and everyone sees their own pending ones. An Archon is
@@ -371,6 +392,7 @@ module.exports = {
371
392
  overrideMatter,
372
393
  removalMatter,
373
394
  uatMatter,
395
+ uatActs,
374
396
  joinRequestMatter,
375
397
  flowReading,
376
398
  promisesReading,
@@ -134,7 +134,29 @@ function upmarketRow() {
134
134
  };
135
135
  }
136
136
 
137
+ // The Rules desk's settings rows (task 1004559), under this page's own atom: the
138
+ // rot timer as one-tap day counts (PATCH /project-settings/rot-days), and the
139
+ // work categories as a link to this page, since a category is named in words.
140
+ // Pure; exported for tests.
141
+ const ROT_CHOICES = Object.freeze([7, 14, 30, 60, 90]);
142
+ function settingsDeskRows({ days, categories }) {
143
+ const live = (categories || []).filter((c) => c && c.active !== false).length;
144
+ return [
145
+ { id: 'rot', name: 'Rot timer', fact: `stale after ${days} ${days === 1 ? 'day' : 'days'}`, facts: ['raising it only asks a person'], tool: true, ref: { type: 'project_setting', id: 'rot_days' },
146
+ acts: [{ key: 'rot', options: ROT_CHOICES.filter((d) => d !== Number(days)).slice(0, 4).map((d) => ({ key: String(d), label: `${d} days` })) }] },
147
+ { id: 'categories', name: 'Work categories', fact: `${live} in use`, tool: true, href: '/project-settings', ref: { type: 'project_setting', id: 'work_categories' }, acts: [{ key: 'open' }] },
148
+ ];
149
+ }
150
+ function contributeSettingsDesk() {
151
+ api.contribute('governor.docket', {
152
+ desk: 'rules.settings',
153
+ gate: { permission: 'project.settings.manage' },
154
+ read: async () => settingsDeskRows({ days: (await api.resolveRotDays()).days, categories: await goalCategory.listCategories({ includeInactive: true }) }),
155
+ });
156
+ }
157
+
137
158
  module.exports = function buildProjectSettingsRouter() {
159
+ contributeSettingsDesk();
138
160
  const router = express.Router();
139
161
 
140
162
  // GET /project-settings — all five rows in ONE read.
@@ -261,3 +283,5 @@ module.exports = function buildProjectSettingsRouter() {
261
283
 
262
284
  return router;
263
285
  };
286
+
287
+ module.exports.settingsDeskRows = settingsDeskRows;