@bongos/core 1.21.54 → 1.21.56

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.
@@ -212,9 +212,9 @@ const MAX_ACTS = 2;
212
212
  const MAX_OPTIONS = 5;
213
213
  const ACT_KEY_RE = /^[a-z][a-z_]{1,23}$/;
214
214
  const PICKS = Object.freeze(['open_goals']);
215
- // The one thing a desk may type: a GitHub name to invite (it is a name, not a
216
- // reason; reasons stay chips).
217
- const FIELDS = Object.freeze(['github_login']);
215
+ // The things a desk may type: a GitHub name to invite, and a work category's
216
+ // name (task 1004580). Each is a name, not a reason; reasons stay chips.
217
+ const FIELDS = Object.freeze(['github_login', 'name']);
218
218
  function normalizeActs(raw) {
219
219
  if (!Array.isArray(raw)) return [];
220
220
  return raw.filter((a) => a && typeof a === 'object' && ACT_KEY_RE.test(String(a.key))).slice(0, MAX_ACTS).map((a) => {
@@ -267,14 +267,19 @@ function normalizeDeskRow(raw, { desk, building }) {
267
267
  ...deskExtras(raw),
268
268
  };
269
269
  }
270
- // Two optional shapes a desk row may carry (task 1004560): `pips`, promises kept
270
+ // Optional shapes a desk row may carry (task 1004560): `pips`, promises kept
271
271
  // of total drawn as squares (at most 12), and `set`, the ordered set the row sits
272
272
  // in (a version id) so the desk can reorder its rows and save the order whole.
273
+ // Task 1004580: `seq`, a whole-number place the desk moves the row by in place
274
+ // (a work category's sort_order), and `stay`, a row that stays after an act
275
+ // because it is a thing being edited, not a matter decided.
273
276
  function deskExtras(raw) {
274
277
  const out = {};
275
278
  const p = raw.pips;
276
279
  if (p && Number.isInteger(p.of) && p.of > 0 && p.of <= 12 && Number.isInteger(p.on) && p.on >= 0 && p.on <= p.of) out.pips = { on: p.on, of: p.of };
277
280
  if (typeof raw.set === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(raw.set)) out.set = raw.set;
281
+ if (Number.isSafeInteger(raw.seq)) out.seq = raw.seq;
282
+ if (raw.stay === true) out.stay = true;
278
283
  return out;
279
284
  }
280
285
 
@@ -661,6 +661,19 @@
661
661
  },
662
662
  };
663
663
  ACT_ROUTES.invite = { invite: function (id, inp) { return str(inp.option).trim() ? post('/access-requests/invite', { github_login: str(inp.option).trim() }) : null; } };
664
+ // The work categories, edited in place (task 1004580): the Project settings
665
+ // page's own two routes. A typed name renames (PATCH .../:id { label }) or
666
+ // adds (POST /work-categories { label }); retire and bring back flip whether
667
+ // new goals are offered it ({ active }); a move sets its place ({ sort_order },
668
+ // a whole number the desk works out from its neighbours). Nothing typed, or a
669
+ // place that is not a whole number, sends nothing.
670
+ ACT_ROUTES.work_category = {
671
+ rename: function (id, inp) { return str(inp.option).trim() ? call('PATCH', '/work-categories/' + id, { label: str(inp.option).trim() }) : null; },
672
+ retire: function (id) { return call('PATCH', '/work-categories/' + id, { active: false }); },
673
+ restore: function (id) { return call('PATCH', '/work-categories/' + id, { active: true }); },
674
+ move: function (id, inp) { return Number.isInteger(inp.sort_order) ? call('PATCH', '/work-categories/' + id, { sort_order: inp.sort_order }) : null; },
675
+ add: function (id, inp) { return str(inp.option).trim() ? post('/work-categories', { label: str(inp.option).trim() }) : null; },
676
+ };
664
677
  ACT_ROUTES.autonomy_fence = ACT_ROUTES.autobongos_fence;
665
678
  // The Planning desks (task 1004560). A blocker is decided by one of its chips,
666
679
  // whose words are the resolution note; a criterion proposal ratifies in one
@@ -793,6 +806,11 @@
793
806
  }
794
807
  if (key === 'take' && cv && cv.subject && cv.subject.kind === 'core_pin') return { toast: ACT_TOASTS.take_core, building: b, kind: 'rise', close: false };
795
808
  if (key === 'invite') return { toast: ACT_TOASTS.invite.replace('{who}', r.github_login || 'They'), building: b, kind: 'light', close: false };
809
+ if (key === 'add' && cv && cv.subject && cv.subject.kind === 'work_category') {
810
+ var made = r.category && r.category.label ? str(r.category.label) : 'It';
811
+ return { toast: r.created === false ? made + ' was already a category.' : made + ' is a category now. New goals can pick it.', building: b, kind: 'light', close: false };
812
+ }
813
+ if (key === 'rename' && r.category && r.category.label) return { toast: 'Renamed. Goals read ' + str(r.category.label) + ' now.', building: b, kind: 'light', close: false };
796
814
  if (ACT_TOASTS[key]) return { toast: ACT_TOASTS[key].replace('{who}', who), building: b, kind: ACT_KINDS[key] || 'light', close: false };
797
815
  return { toast: '', building: null, kind: null, close: false };
798
816
  }
@@ -811,8 +829,11 @@
811
829
  decide: 'Decided. The work it held moves again.', ratify: 'Ratified. The promise is on its version.', ratify_goal: 'Ratified. It is a goal now.',
812
830
  budget: '{who} has a new monthly limit.', unsign: 'Undone. The promise is open again.',
813
831
  recheck: 'Checking it again. The answer lands at Release when it is done.',
832
+ // task 1004580: the work categories
833
+ rename: 'Renamed.', retire: 'Retired. New goals are not offered it; goals that carry it keep it.',
834
+ restore: 'Brought back. New goals can pick it again.', move: 'Moved. New goals are offered the categories in this order.',
814
835
  };
815
- var ACT_KINDS = { recheck: 'light', budget: 'light', unsign: 'settle', decide: 'rise', ratify: 'rise', ratify_goal: 'rise', check: 'light', rot: 'light', module_on: 'rise', module_off: 'settle', backup: 'light', confirm: 'settle', dispute: 'settle', fix: 'rise', remove: 'settle', keep: 'settle', signoff: 'rise', force: 'rise', switch_on: 'rise', switch_off: 'settle', allow: 'rise', disallow: 'settle', first: 'light', pause: 'settle', resume: 'rise', tier: 'light', deactivate: 'settle', give: 'light', take: 'settle' };
836
+ var ACT_KINDS = { rename: 'light', retire: 'settle', restore: 'rise', move: 'light', recheck: 'light', budget: 'light', unsign: 'settle', decide: 'rise', ratify: 'rise', ratify_goal: 'rise', check: 'light', rot: 'light', module_on: 'rise', module_off: 'settle', backup: 'light', confirm: 'settle', dispute: 'settle', fix: 'rise', remove: 'settle', keep: 'settle', signoff: 'rise', force: 'rise', switch_on: 'rise', switch_off: 'settle', allow: 'rise', disallow: 'settle', first: 'light', pause: 'settle', resume: 'rise', tier: 'light', deactivate: 'settle', give: 'light', take: 'settle' };
816
837
 
817
838
  // A refused act, in words. The server's own message wins when it has one.
818
839
  var REFUSALS = {
@@ -838,6 +859,10 @@
838
859
  not_proposer: 'Only the person who put it up may withdraw it.',
839
860
  votes_cast: 'Someone has voted on it, so it is the sitting\'s to decide now.',
840
861
  item_changed: 'Someone voted just now, so it stays with the sitting.',
862
+ // task 1004580: the work categories
863
+ category_not_found: 'That category is gone. The floor reads it again.',
864
+ label_required: 'Type a name first.',
865
+ bad_slug: 'Use letters or digits in the name.',
841
866
  };
842
867
  function refusal(status, body) {
843
868
  var e = body && body.error ? body.error : {};
@@ -171,9 +171,11 @@
171
171
  withdraw: 'withdraw',
172
172
  // task 1004566: a module version flagged by its security check, checked again
173
173
  recheck: 'check again',
174
+ // task 1004580: a work category renamed, retired, brought back, or a new one added
175
+ rename: 'rename', retire: 'retire', restore: 'bring back', add: 'add',
174
176
  });
175
177
  // What a row's chips ask, per act (a reason or an option is one tap, never text).
176
- const ASKS = Object.freeze({ recheck: 'why', budget: 'monthly limit', unsign: 'why', decide: 'pick one', ratify_goal: 'which version', 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', record: 'the recording', allow: 'which goal', tier: 'which tier', give: 'to whom', take: 'from whom' });
178
+ const ASKS = Object.freeze({ recheck: 'why', budget: 'monthly limit', unsign: 'why', decide: 'pick one', ratify_goal: 'which version', 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', record: 'the recording', allow: 'which goal', tier: 'which tier', give: 'to whom', take: 'from whom', rename: 'new name', add: 'its name' });
177
179
  // How a floor counts rows nobody is waiting on (desk rows), by its desk.
178
180
  const DESK_NOUNS = Object.freeze({
179
181
  'people.builders': 'builders', 'people.ranks': 'ranks', 'disputes.confirmed': 'to fix',
@@ -330,6 +332,9 @@
330
332
 
331
333
  // A desk row (task 1004559): something on this floor the viewer may act on that
332
334
  // no one is waiting on, read by the module that owns its route.
335
+ // Task 1004580: a row that `stay`s is edited in place, not decided, so an act
336
+ // leaves it on the desk; a rename's field starts from its whole name; a row
337
+ // with a `seq` (a place in an order) is moved from its open detail.
333
338
  function deskRowOf(d, ctx) {
334
339
  const titles = new Map((ctx.goals || []).map((g) => [str(g.id), str(g.title || '')]));
335
340
  const hit = /^goal (\d+)$/.exec(str(d.name));
@@ -343,13 +348,45 @@
343
348
  if (age) add('since ' + age);
344
349
  const ref = d.ref || {};
345
350
  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;
346
- return Object.assign(actedRow(d.acts || [], card || { href: null }, ctx), {
351
+ const acted = actedRow(d.acts || [], card || { href: null }, ctx);
352
+ if (acted.choices.rename && acted.choices.rename.field) acted.choices.rename.value = full;
353
+ return Object.assign(acted, {
347
354
  key: str(d.id), matter: str(d.id), ini: initials(name), name: name, fact: fit(d.fact, LIMITS.fact), facts: facts,
348
355
  needs: d.needs === true, source: d.desk, opened: d.opened_at || null, desk: true, tool: d.tool === true, over: d.over || null,
349
356
  order: d.set || null, goalId: d.set && ref.id != null ? str(ref.id) : null, full: full,
350
357
  pips: d.pips ? Array.from({ length: Math.min(d.pips.of, LIMITS.pips) }, (_, i) => ({ key: 'p' + i, on: i < d.pips.on })) : null,
358
+ stay: d.stay === true, seq: Number.isInteger(d.seq) ? d.seq : null,
351
359
  });
352
360
  }
361
+ // The rows of one floor that hold a place (`seq`), in their order: each learns
362
+ // whether it can move up or down (task 1004580).
363
+ function placed(rows) {
364
+ const set = rows.filter((r) => r.seq != null);
365
+ set.forEach((r, i) => { r.moves = { up: i > 0, down: i < set.length - 1 }; });
366
+ return rows;
367
+ }
368
+ // The writes that move one placed row a place up (-1) or down (+1): one, when a
369
+ // whole number sits between its new neighbours' places; else every place
370
+ // numbered again in tens, writing only those that change. [{ id, sort_order }],
371
+ // [] at an end. Pure (task 1004580).
372
+ function placeMoves(rows, key, by) {
373
+ const set = (rows || []).filter((r) => r && r.seq != null && r.card && r.card.subject);
374
+ const i = set.findIndex((r) => r.key === key);
375
+ const j = i + by;
376
+ if (i < 0 || (by !== -1 && by !== 1) || j < 0 || j >= set.length) return [];
377
+ const order = set.slice();
378
+ order.splice(i, 1);
379
+ order.splice(j, 0, set[i]);
380
+ const lo = j > 0 ? order[j - 1].seq : null;
381
+ const hi = j < order.length - 1 ? order[j + 1].seq : null;
382
+ let v = null;
383
+ if (lo == null) v = hi - 10;
384
+ else if (hi == null) v = lo + 10;
385
+ else if (hi - lo >= 2) v = Math.floor((lo + hi) / 2);
386
+ if (v != null) return [{ id: str(set[i].card.subject.id), sort_order: v }];
387
+ return order.map((r, k) => ({ id: str(r.card.subject.id), sort_order: (k + 1) * 10, was: r.seq }))
388
+ .filter((p) => p.sort_order !== p.was).map((p) => ({ id: p.id, sort_order: p.sort_order }));
389
+ }
353
390
  // The desk rows of one floor: those of the desk its PLAN entry names.
354
391
  function deskRows(key, floor, docket, ctx) {
355
392
  const plan = I && I.PLAN[key];
@@ -364,7 +401,7 @@
364
401
  function rowsFor(key, floor, docket, ctx) {
365
402
  const c = Object.assign({ docket: docket }, ctx || {});
366
403
  const done = c.done || {};
367
- const desked = deskRows(key, floor, docket).filter((d) => !done[str(d.id)]).map((d) => deskRowOf(d, c)).filter((r) => r.actions.length);
404
+ const desked = placed(deskRows(key, floor, docket).filter((d) => !done[str(d.id)]).map((d) => deskRowOf(d, c)).filter((r) => r.actions.length));
368
405
  // a desk row that stands in for a source (the core update's check-then-take)
369
406
  // replaces that source's matter rows on this floor
370
407
  const over = desked.map((r) => r.over).filter(Boolean);
@@ -592,7 +629,7 @@
592
629
 
593
630
  const api = {
594
631
  LIMITS: LIMITS, ACT_WORDS: ACT_WORDS, ASKS: ASKS, DESK_NOUNS: DESK_NOUNS, CHIPS: CHIPS, NOUNS: NOUNS, CLEAR: CLEAR,
595
- fit: fit, ageWord: ageWord, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, cardFor: cardFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips, moved: moved,
632
+ fit: fit, ageWord: ageWord, placeMoves: placeMoves, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, cardFor: cardFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips, moved: moved,
596
633
  };
597
634
  root.GovDesk = api;
598
635
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
@@ -289,7 +289,6 @@
289
289
  // the tiers whose label shows without a hover, most important first
290
290
  const LABEL_TIERS = Object.freeze(['urgent', 'needs', 'queue']);
291
291
  const MAX_CRATES = 3;
292
- const HINT = 'work moves left to right · orange waits on you · white is moving without you';
293
292
  const READING_KEYS = Object.freeze(['flow', 'promises', 'money', 'people', 'fairness', 'rules']);
294
293
  const READING_LABELS = Object.freeze({ flow: 'flow', promises: 'promises', money: 'money', people: 'people', fairness: 'fairness', rules: 'rules at work' });
295
294
  const READING_BUILDING = Object.freeze({ flow: 'review', promises: 'planning', money: 'treasury', people: 'people', fairness: 'disputes', rules: 'rules' });
@@ -626,7 +625,6 @@
626
625
  flat: flat, blds: blds, crates: crates, cols: cols, groups: groups, pins: pins, doors: doors, signs: signs,
627
626
  hud: { needs: docket ? String(needs) : '—', moving: docket ? String(moving) : '—', share: money(share), hot: needs > 0 },
628
627
  line: docket ? String(docket.line || '') : FALLBACK_LINE,
629
- hint: docket && !evening ? HINT : '',
630
628
  eveningLine: 'this month · ' + moving + ' moving · ' + settled + ' settled · ' + money(share) + ' to your share',
631
629
  label: 'The street: the nine civic buildings in the order work moves, and '
632
630
  + (goals.length === 1 ? 'one district' : goals.length + ' districts') + ', one for each open goal'
@@ -661,7 +659,7 @@
661
659
  whenWord: whenWord, reviewHeldOf: reviewHeldOf, monthShare: monthShare, money: money, parseHash: parseHash, buildingHref: buildingHref, districtHref: districtHref,
662
660
  streetModel: streetModel, cardFor: cardFor, placeLabels: placeLabels, labelBox: labelBox, shownLabels: shownLabels, pinLabel: pinLabel,
663
661
  G: G, SEGS: SEGS, GROUP_NAMES: GROUP_NAMES, CIVIC_KEYS: CIVIC_KEYS, READING_KEYS: READING_KEYS, ARTBOARD_W: ARTBOARD_W,
664
- PIN_W: PIN_W, PIN_LABEL: PIN_LABEL, LABEL_MAX_W: LABEL_MAX_W, PIN_STACK: PIN_STACK, MAX_CRATES: MAX_CRATES, MAX_DISTRICTS: MAX_DISTRICTS, GOALS_HREF: GOALS_HREF, HINT: HINT, FALLBACK_LINE: FALLBACK_LINE, HOME_HREF: '/builders/city',
662
+ PIN_W: PIN_W, PIN_LABEL: PIN_LABEL, LABEL_MAX_W: LABEL_MAX_W, PIN_STACK: PIN_STACK, MAX_CRATES: MAX_CRATES, MAX_DISTRICTS: MAX_DISTRICTS, GOALS_HREF: GOALS_HREF, FALLBACK_LINE: FALLBACK_LINE, HOME_HREF: '/builders/city',
665
663
  };
666
664
  root.GovStreet = api;
667
665
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
@@ -93,7 +93,6 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
93
93
  .street-layer { position: absolute; left: 0; top: 0; width: 100%; height: 900px; pointer-events: none; }
94
94
  .street-layer > * { pointer-events: auto; }
95
95
 
96
- .street-hint { position: absolute; left: 0; right: 0; top: 84px; margin: 0; text-align: center; font-size: 11.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-faint); pointer-events: none; z-index: 2; }
97
96
  .street-evening { position: absolute; left: 0; right: 0; top: 104px; display: flex; flex-direction: column; align-items: center; gap: 10px; pointer-events: none; z-index: 2; }
98
97
  .street-evening[hidden] { display: none; }
99
98
  .street-evening__h { margin: 0; font-size: 46px; font-weight: 300; letter-spacing: -0.042em; line-height: 1; }
@@ -343,6 +342,8 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
343
342
  .desk-save__line { font-size: 13.5px; color: var(--ink-soft); margin-right: auto; }
344
343
  .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; }
345
344
  .desk-fact { font-size: 13px; color: var(--ink-soft); background: var(--street-wash); border-radius: 6px; padding: 6px 10px; white-space: nowrap; }
345
+ /* a place in an order moves from the open detail: its two chips wrap together (task 1004580) */
346
+ .desk-moves { display: inline-flex; gap: 8px; flex-wrap: wrap; }
346
347
  .desk-choices__ask { font-size: 12.5px; color: var(--ink-faint); margin-right: 4px; }
347
348
  .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; }
348
349
  .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; }
@@ -96,7 +96,6 @@
96
96
  <div class="street-window" id="streetWindow">
97
97
  <div class="street-sizer" id="streetSizer">
98
98
  <div class="street-board" id="streetBoard" role="group" aria-label="The street">
99
- <p class="street-hint" id="streetHint"></p>
100
99
  <div class="street-evening" id="streetEvening" hidden>
101
100
  <p class="street-evening__h">nothing waits on you.</p>
102
101
  <p class="street-evening__line" id="streetEveningLine"></p>
@@ -377,8 +377,6 @@
377
377
  var signs = $('streetSigns'); clear(signs);
378
378
  m.signs.forEach(function (sg) { signs.appendChild(drawSign(sg)); });
379
379
 
380
- var hint = $('streetHint');
381
- if (hint) { hint.textContent = m.hint; hint.hidden = !m.hint || !!view.open || showInside; }
382
380
  var eve = $('streetEvening');
383
381
  if (eve) eve.hidden = !(m.evening && !view.open) || showInside;
384
382
  var eveLine = $('streetEveningLine');
@@ -691,6 +689,13 @@
691
689
  // a recorded sign-off's detail is the goal page's three checks, once read
692
690
  var checks = recorded(r) && rec.checks[criterionOf(r.card)];
693
691
  (checks ? window.GovMatterCard.uatChecks(checks).facts : r.facts).forEach(function (f) { facts.appendChild(node('span', 'desk-fact', f)); });
692
+ // a row with a place in an order moves from here, one place a tap (task 1004580)
693
+ if (r.moves) {
694
+ var moves = node('span', 'desk-moves');
695
+ if (r.moves.up) moves.appendChild(moveChip(r, -1));
696
+ if (r.moves.down) moves.appendChild(moveChip(r, 1));
697
+ facts.appendChild(moves);
698
+ }
694
699
  row.appendChild(facts);
695
700
  }
696
701
  var asked = desk.ask && desk.ask.indexOf(r.key + '|') === 0 ? (r.choices || {})[desk.ask.slice(r.key.length + 1)] : null;
@@ -709,12 +714,14 @@
709
714
  }
710
715
  asked.options.forEach(function (c) { chips.appendChild(chipBtn('desk-chip--choice', c.label, function () { deskAct(r, asked.for, c.input); })); });
711
716
  if (asked.field) {
712
- // the one typed value a desk takes: a name to invite (never a reason)
717
+ // the one typed value a desk takes: a name, to invite or to call a
718
+ // category (never a reason); a rename starts from the name it has
713
719
  var input = node('input', 'desk-field');
714
720
  input.setAttribute('type', 'text');
715
721
  input.setAttribute('aria-label', asked.ask);
716
722
  input.setAttribute('autocomplete', 'off');
717
723
  input.setAttribute('maxlength', '60');
724
+ if (asked.value) { input.setAttribute('value', asked.value); input.value = asked.value; }
718
725
  var send = function () { var v = input.value.trim(); if (v) deskAct(r, asked.for, { option: v, label: v }); };
719
726
  input.addEventListener('keydown', function (e) { if (e.key === 'Enter') send(); });
720
727
  chips.appendChild(input);
@@ -724,6 +731,40 @@
724
731
  }
725
732
  return row;
726
733
  }
734
+ // A place in an order, moved one step (task 1004580): the chip in a row's open
735
+ // detail, and the writes it sends — GovDesk.placeMoves works out the places,
736
+ // each written through the card's own request; the first refusal stops the run.
737
+ function moveChip(r, by) {
738
+ var b = chipBtn('desk-chip--choice', by < 0 ? 'move up' : 'move down', function () { deskMove(r, by); });
739
+ b.setAttribute('data-act', by < 0 ? 'move-up' : 'move-down');
740
+ return b;
741
+ }
742
+ function deskMove(r, by) {
743
+ var D = window.GovDesk, C = window.GovMatterCard;
744
+ var writes = D && desk.model ? D.placeMoves(desk.model.rows, r.key, by) : [];
745
+ if (!C || !writes.length) return Promise.resolve(false);
746
+ var go = function () {
747
+ var i = 0;
748
+ var next = function (last) {
749
+ if (i >= writes.length) return Promise.resolve(last);
750
+ var w = writes[i++];
751
+ var req = C.request({ subject: { kind: 'work_category', id: w.id } }, 'move', { sort_order: w.sort_order });
752
+ return send(req).then(function (out) {
753
+ if (!out.ok) { say(C.refusal(out.status, out.body), true); return out; }
754
+ return next(out);
755
+ });
756
+ };
757
+ return next({ ok: true, status: 200, body: {} });
758
+ };
759
+ return deskAct(r, 'move', { by: by }, go).then(function (done) {
760
+ desk.open = r.key;
761
+ draw();
762
+ var at = rowEl(r.key);
763
+ var again = findAct(at, by < 0 ? 'move-up' : 'move-down') || findAct(at, by < 0 ? 'move-down' : 'move-up');
764
+ if (again && again.focus) again.focus();
765
+ return done;
766
+ });
767
+ }
727
768
  // The Priorities desk (task 1004560): a move is kept in this visit until saved.
728
769
  function moveRow(r, by) {
729
770
  var D = window.GovDesk, st = view.inside;
@@ -788,6 +829,8 @@
788
829
  check: 'checked', rot: 'set', module_on: 'turned on', module_off: 'turned off', backup: 'backed up', invite: 'invited',
789
830
  decide: 'decided', ratify: 'ratified', ratify_goal: 'made a goal of',
790
831
  budget: 'set the limit of', unsign: 'undid', withdraw: 'withdrew', record: 'signed off',
832
+ // task 1004580: the work categories
833
+ rename: 'renamed', retire: 'retired', restore: 'brought back', add: 'added', move: 'moved',
791
834
  };
792
835
  // ---------- the recorded sign-off (task 1004579) ----------
793
836
  function recState(key) { return rec.state[key] || (rec.state[key] = { file: null, phase: 'empty', pct: 0 }); }
@@ -868,15 +911,20 @@
868
911
  var a = C.answer(r.card, key, body);
869
912
  // an act at a building a sitting decides went to it as a matter (task 1004561)
870
913
  var sent = r.card.subject && r.card.subject.council && req && /\/government\/board\/matters$/.test(req.url);
871
- desk.mine[k] = [(sent ? 'sent to council:' : DONE_WORDS[key]) + ' ' + r.name].concat(desk.mine[k] || []);
872
- desk.done[r.matter] = true;
914
+ // what was added is named by what was typed, not by the row it was added from
915
+ var what = key === 'add' && input && input.label ? String(input.label) : r.name;
916
+ desk.mine[k] = [(sent ? 'sent to council:' : DONE_WORDS[key]) + ' ' + what].concat(desk.mine[k] || []);
917
+ // a row that stays (a category being edited) is not cleared away (task 1004580)
918
+ if (!r.stay) desk.done[r.matter] = true;
873
919
  desk.open = null; desk.ask = null;
874
- if (desk.model && !desk.model.rows.some(function (x) { return x.matter !== r.matter; })) desk.cleared[k] = true;
920
+ if (!r.stay && desk.model && !desk.model.rows.some(function (x) { return x.matter !== r.matter; })) desk.cleared[k] = true;
875
921
  if (st.floor == null && view.insideModel) st.floor = view.insideModel.selFloor;
876
922
  bumpToday();
877
923
  if (a.building && a.kind) card.answer = { building: a.building, matter: r.matter, kind: a.kind, at: Date.now() };
878
924
  say(a.toast || 'Done.', true);
879
925
  };
926
+ // a row being edited with nothing to send (an empty name) does nothing
927
+ if (!req && !go && r.stay) return Promise.resolve(false);
880
928
  if (!req && !go) return new Promise(function (res) { clearRow(r.key, function () { settle({}); draw(); res(true); }); });
881
929
  desk.busy = true;
882
930
  draw();
@@ -884,6 +932,11 @@
884
932
  desk.busy = false;
885
933
  if (!out.ok) { if (!go) say(C.refusal(out.status, out.body), true); draw(); return false; }
886
934
  if (go) delete rec.state[r.matter];
935
+ if (r.stay) {
936
+ settle(out.body);
937
+ draw();
938
+ return load().then(function () { return true; });
939
+ }
887
940
  return new Promise(function (res) {
888
941
  clearRow(r.key, function () {
889
942
  settle(out.body);
@@ -652,7 +652,7 @@
652
652
  },
653
653
  "desk-rules": {
654
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.",
655
+ "_": "Task 1004559: Rules in force. The rot timer as day chips, each module on or off. Task 1004580: each work category is its own row, edited in place (no link out).",
656
656
  "url": "/builders/street#building=rules",
657
657
  "actions": [
658
658
  [
@@ -682,6 +682,46 @@
682
682
  ]
683
683
  }
684
684
  },
685
+ "desk-rules-category": {
686
+ "auth": true,
687
+ "_": "Task 1004580: a work category opened in place: its key, its place in the order and whether it is offered, with move up and move down.",
688
+ "url": "/builders/street#building=rules",
689
+ "actions": [
690
+ [
691
+ "wait",
692
+ 900
693
+ ],
694
+ [
695
+ "clickIfVisible",
696
+ "[data-craft-close]"
697
+ ],
698
+ [
699
+ "wait",
700
+ 300
701
+ ],
702
+ [
703
+ "click",
704
+ "[data-floor=\"0\"]"
705
+ ],
706
+ [
707
+ "wait",
708
+ 600
709
+ ],
710
+ [
711
+ "click",
712
+ "[data-matter=\"rules.settings:cat-3\"]"
713
+ ],
714
+ [
715
+ "wait",
716
+ 400
717
+ ]
718
+ ],
719
+ "expect": {
720
+ "visible": [
721
+ ".desk-facts"
722
+ ]
723
+ }
724
+ },
685
725
  "desk-planning-priorities": {
686
726
  "auth": true,
687
727
  "_": "Task 1004560: Planning, Priorities. The open goals in order with promise pips and two arrows; one goal moved down, so the save bar says the new order goes to the council and how many yes it needs, with undo.",
@@ -83,3 +83,26 @@ Owner decision 2026-10-04: "all these processes should be integrated". The recor
83
83
  Walked in the hall preview harness at 1440 and 375, light and dark, desk and card: the empty line, a picked file, progress mid-upload, a refused file, a broken upload and its retry, the checks under "more", and the cleared row with pip, toast and record line. The harness docket holds no recorded criterion and refuses writes, so the walk added one recorded matter and answered the upload and the sign-off inside the page; nothing was signed off on the live site.
84
84
 
85
85
  Tests: `tests/hall_street_desk.mjs` (the `1004579` cases) hold the line in every phase, the routes against the goal page's and the server's own, the short refusals, the run's two steps and its retry rules, the upload with its progress, and on the page the desk row and the card end to end: the cleared row, the pip, the toast and the record line, a refused upload and a refused sign-off that keep the row and count nothing, the drop, and the checks.
86
+
87
+ ## Work categories, edited in place (task 1004580)
88
+
89
+ Owner decision 2026-10-04: Work categories was the last floor row that still left the building (the link-out both walkthroughs above left as found). The Rules in force desk now edits the category set in place, through the same two routes the Project settings page uses: `PATCH /work-categories/:id` (project.settings.manage) and `POST /work-categories` (goal.create). No new route and no database change; the docket gained one read (`countGoalsByCategory`, how many goals carry each category) and two optional desk-row fields (`seq`, `stay`), and a desk may now type a `name` as well as a GitHub name.
90
+
91
+ - **One row per category.** Its name and one fact: how many goals carry it (a retired one says "retired · N goals"). Offered ones come first, in their order, then the retired ones. Two buttons: rename and retire, or bring back and rename for a retired one. A "New category" row at the end adds one; it is offered only to a viewer holding goal.create, the add route's own atom.
92
+ - **Rename and add type a name, nothing else.** The field opens on the row's choice line, the rot timer's way; a rename starts from the name it has. Retire and bring back are one tap (both are undone by the other).
93
+ - **"more" is the page's detail.** Tapping a name opens its key, its place ("3 of 6 in order") and whether new goals are offered it, with "move up" and "move down" chips that wrap together. A move is one write when a whole number sits between its new neighbours' places, else the offered set is numbered again in tens, writing only the ones that change (`GovDesk.placeMoves`); the first refusal stops the run.
94
+ - **A category row stays.** It is a thing being edited, not a matter decided, so an act does not clear it away (`stay`); the meter still takes a pip, the desk's toast speaks and the record gains "renamed …", "retired …", "moved …" or "added <the typed name>". A refused act says the server's reason, keeps the row and counts nothing.
95
+
96
+ Walked in the hall preview harness at 1440 and 375, light and dark: the rows, an open detail with its move chips, the rename and add fields, and a rename sent (the write answered inside the page, since the harness refuses writes) with its pip, toast and record line. Nothing was changed on the live site. The old Project settings page keeps its own category list.
97
+
98
+ Tests: `tests/hall_street_desk.mjs` (the `1004580` cases) hold the server rows and their gates, every act's route, the move arithmetic, and on the page rename, retire and bring back, a move from the open detail, an add, and a refused act and a refused move that count nothing. `tests/goal_category.mjs` holds the grouped count.
99
+
100
+ ## No legend hint (task 1004581)
101
+
102
+ Owner decision 2026-10-04: the street should read at a glance without words, so the small grey line over the sky ("work moves left to right · orange waits on you · white is moving without you") is gone, in both themes and at every width. This is a deliberate departure from the prototype, whose `Street.dc.html` still draws the line; everything else on the reference board stands. The line was also wrong in light mode, where moving pins are black, not white.
103
+
104
+ - **Gone with it:** the `streetHint` element, the `.street-hint` style, the `HINT` constant and the model's `hint` field in `street-draw.js`, the code in `street.js` that showed and hid it, and its entry in `docs/page-readings.json` (regenerated).
105
+ - **No gap where it stood.** The line was laid over the sky, not in the flow, so the header and the reading band keep their places; walked in the hall preview harness at 1440 and 375, light and dark.
106
+ - **The evening view is unchanged** ("nothing waits on you." and its line still show when nothing needs you).
107
+
108
+ Tests: `tests/hall_street.mjs` asserts the model has no hint field, the module exports no `HINT`, and none of the street's four files carries the line; putting the markup or the constant back reds it.
@@ -81,6 +81,20 @@ async function listCategories({ includeInactive = false } = {}, deps = {}) {
81
81
  return rows;
82
82
  }
83
83
 
84
+ // How many goals carry each category, as a Map of id → count (task 1004580: the
85
+ // Rules desk's one fact per category row). One grouped read for the whole set; a
86
+ // category no goal carries is simply absent (read it as 0).
87
+ async function countGoalsByCategory(deps = {}) {
88
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
89
+ const { rows } = await activePool.query(
90
+ `SELECT category_id, COUNT(*)::int AS n
91
+ FROM goals
92
+ WHERE category_id IS NOT NULL
93
+ GROUP BY category_id`
94
+ );
95
+ return new Map(rows.map((r) => [String(r.category_id), Number(r.n) || 0]));
96
+ }
97
+
84
98
  async function getCategory(id, deps = {}) {
85
99
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
86
100
  const { rows } = await activePool.query(
@@ -369,6 +383,7 @@ module.exports = {
369
383
  slugify,
370
384
  isValidSlug,
371
385
  listCategories,
386
+ countGoalsByCategory,
372
387
  getCategory,
373
388
  adviseCategory,
374
389
  createCategory,
@@ -135,23 +135,58 @@ function upmarketRow() {
135
135
  }
136
136
 
137
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.
138
+ // rot timer as one-tap day counts (PATCH /project-settings/rot-days), then the
139
+ // work categories edited in place (task 1004580), so the desk no longer links
140
+ // out to this page. Each category is a row: its name, how many goals carry it,
141
+ // and the acts this page's category list has, each sent to the SAME route the
142
+ // page uses — rename (a typed name) and retire / bring back through
143
+ // PATCH /work-categories/:id (project.settings.manage), its place in the order
144
+ // (`seq`, the sort_order the desk moves it by, given only to an offered one),
145
+ // and a "New category" row whose name goes to POST /work-categories, offered only
146
+ // to a viewer holding that route's own atom (goal.create). Offered categories
147
+ // first, in their order, then the retired ones. `stay`: a category row stays on
148
+ // the desk after an act (it is a thing being edited, not a matter decided).
140
149
  // Pure; exported for tests.
141
150
  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;
151
+ function categoryRows(categories, { goals = new Map(), mayAdd = false } = {}) {
152
+ const all = (categories || []).filter((c) => c && c.id != null && c.label);
153
+ const live = all.filter((c) => c.active !== false);
154
+ const off = all.filter((c) => c.active === false);
155
+ const count = (c) => Number((goals instanceof Map ? goals.get(String(c.id)) : 0) || 0);
156
+ const goalWords = (n) => `${n} ${n === 1 ? 'goal' : 'goals'}`;
157
+ const rows = live.map((c, i) => ({
158
+ id: `cat-${c.id}`, name: c.label, fact: goalWords(count(c)),
159
+ facts: [`key ${c.slug}`, `${i + 1} of ${live.length} in order`, 'offered to new goals'],
160
+ tool: true, stay: true, seq: Number(c.sort_order), ref: { type: 'work_category', id: c.id },
161
+ acts: [{ key: 'rename', field: 'name' }, { key: 'retire' }],
162
+ })).concat(off.map((c) => ({
163
+ id: `cat-${c.id}`, name: c.label, fact: `retired · ${goalWords(count(c))}`,
164
+ facts: [`key ${c.slug}`, 'not offered to new goals', 'goals keep it'],
165
+ tool: true, stay: true, ref: { type: 'work_category', id: c.id },
166
+ acts: [{ key: 'restore' }, { key: 'rename', field: 'name' }],
167
+ })));
168
+ if (mayAdd) {
169
+ rows.push({ id: 'cat-new', name: 'New category', fact: `${live.length} in use`, facts: ['it gates nothing'], tool: true, stay: true, ref: { type: 'work_category', id: 'new' }, acts: [{ key: 'add', field: 'name' }] });
170
+ }
171
+ return rows;
172
+ }
173
+ function settingsDeskRows({ days, categories, goals, mayAdd = false }) {
144
174
  return [
145
175
  { 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
176
  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
- ];
177
+ ].concat(categoryRows(categories, { goals, mayAdd }));
149
178
  }
150
179
  function contributeSettingsDesk() {
151
180
  api.contribute('governor.docket', {
152
181
  desk: 'rules.settings',
153
182
  gate: { permission: 'project.settings.manage' },
154
- read: async () => settingsDeskRows({ days: (await api.resolveRotDays()).days, categories: await goalCategory.listCategories({ includeInactive: true }) }),
183
+ read: async (ctx) => settingsDeskRows({
184
+ days: (await api.resolveRotDays()).days,
185
+ categories: await goalCategory.listCategories({ includeInactive: true }),
186
+ goals: await goalCategory.countGoalsByCategory(),
187
+ // the add row only for a viewer POST /work-categories would let in
188
+ mayAdd: !!(ctx && typeof ctx.has === 'function' && ctx.has('goal.create')),
189
+ }),
155
190
  });
156
191
  }
157
192
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.54",
3
+ "version": "1.21.56",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.54",
9
+ "version": "1.21.56",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.54",
3
+ "version": "1.21.56",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9096,5 +9096,17 @@
9096
9096
  "id": "1002973",
9097
9097
  "text": "A single test now checks every privacy rule at once — who can see a private, hidden or not-yet-active account on every page that shows people — so a change that breaks one rule fails straight away."
9098
9098
  }
9099
+ ],
9100
+ "1.21.55": [
9101
+ {
9102
+ "id": "1004580",
9103
+ "text": "Work categories can now be managed right on the Rules floor of the Governor City, with no trip to the old settings page. Each category is its own line showing how many goals use it. You can rename it, retire it or bring it bac"
9104
+ }
9105
+ ],
9106
+ "1.21.56": [
9107
+ {
9108
+ "id": "1004581",
9109
+ "text": "The street no longer shows the small grey line of words above the buildings. The owner wants the city to read at a glance from its look alone, and the line was also wrong in light mode. It is gone in light and dark and on phon"
9110
+ }
9099
9111
  ]
9100
9112
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.54'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.56'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -225,3 +225,19 @@ test('POST /goals accepts a category but never requires one', () => {
225
225
  assert.match(routes, /category_id: \{ type: 'integer', min: 1 \}/, 'accepted on create');
226
226
  assert.equal(/category_id: \{ required: true/.test(routes), false, 'never required — that would make the prompt a refusal');
227
227
  });
228
+
229
+ test('countGoalsByCategory: one grouped read, id to count, for the Rules desk fact (task 1004580)', async () => {
230
+ const before = process.env.NODE_ENV;
231
+ process.env.NODE_ENV = 'test';
232
+ const seen = [];
233
+ const pool = { query: async (sql) => { seen.push(sql); return { rows: [{ category_id: 3, n: 4 }, { category_id: '7', n: '1' }] }; } };
234
+ try {
235
+ const m = await gc.countGoalsByCategory({ pool });
236
+ assert.deepEqual([...m.entries()], [['3', 4], ['7', 1]]);
237
+ assert.equal(seen.length, 1);
238
+ assert.match(seen[0], /GROUP BY category_id/);
239
+ assert.match(seen[0], /WHERE category_id IS NOT NULL/);
240
+ } finally {
241
+ if (before === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = before;
242
+ }
243
+ });