@bongos/core 1.21.36 → 1.21.38

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 (68) hide show
  1. package/.bongos-core.json +90 -70
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +77 -3
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +8 -8
  10. package/docs/copy-registry.json +8 -8
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-readings.json +234 -234
  13. package/modules/government/board-withdraw.js +38 -0
  14. package/modules/government/board.js +1 -0
  15. package/modules/government/db.js +20 -0
  16. package/modules/government/migrations/government_026_board_outcome_withdrawn.sql +29 -0
  17. package/modules/government/routes/government.js +26 -0
  18. package/modules/hall-ui/public/atlas.html +3 -3
  19. package/modules/hall-ui/public/blockers.html +3 -3
  20. package/modules/hall-ui/public/board-room.html +3 -3
  21. package/modules/hall-ui/public/city.html +3 -3
  22. package/modules/hall-ui/public/collab.html +3 -3
  23. package/modules/hall-ui/public/commands.html +3 -3
  24. package/modules/hall-ui/public/copy-desk.html +3 -3
  25. package/modules/hall-ui/public/craft-mode.js +44 -6
  26. package/modules/hall-ui/public/deploy.html +3 -3
  27. package/modules/hall-ui/public/diagrams.html +3 -3
  28. package/modules/hall-ui/public/drachmae.html +3 -3
  29. package/modules/hall-ui/public/fleet.html +3 -3
  30. package/modules/hall-ui/public/gate.html +3 -3
  31. package/modules/hall-ui/public/goal-map.html +3 -3
  32. package/modules/hall-ui/public/goals.html +3 -3
  33. package/modules/hall-ui/public/government.html +3 -3
  34. package/modules/hall-ui/public/idea.html +3 -3
  35. package/modules/hall-ui/public/ideas.html +3 -3
  36. package/modules/hall-ui/public/index.html +3 -3
  37. package/modules/hall-ui/public/modules.html +3 -3
  38. package/modules/hall-ui/public/primer.html +3 -3
  39. package/modules/hall-ui/public/profile.html +3 -3
  40. package/modules/hall-ui/public/project-settings.html +3 -3
  41. package/modules/hall-ui/public/ranks.html +3 -3
  42. package/modules/hall-ui/public/roadmap.html +3 -3
  43. package/modules/hall-ui/public/roster.html +3 -3
  44. package/modules/hall-ui/public/sessions.html +3 -3
  45. package/modules/hall-ui/public/settings.html +3 -3
  46. package/modules/hall-ui/public/street-card.js +16 -0
  47. package/modules/hall-ui/public/street-desk.js +2 -0
  48. package/modules/hall-ui/public/street.html +3 -3
  49. package/modules/hall-ui/public/street.js +1 -1
  50. package/modules/hall-ui/public/studio.html +3 -3
  51. package/modules/hall-ui/public/style.css +121 -2
  52. package/modules/hall-ui/public/task.html +3 -3
  53. package/modules/hall-ui/public/thinking.html +3 -3
  54. package/modules/hall-ui/public/tweak-editor.html +3 -3
  55. package/modules/hall-ui/public/watch.html +3 -3
  56. package/modules/hall-ui/public/work.html +3 -3
  57. package/modules/hall-ui/records/craft-mode.md +8 -0
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/release-notes.json +12 -0
  61. package/src/bongos/route-rank-check.js +4 -0
  62. package/src/module-api.js +1 -1
  63. package/tests/craft_mode_looks.mjs +170 -0
  64. package/tests/craft_mode_nav.mjs +7 -5
  65. package/tests/government_board_withdraw.mjs +144 -0
  66. package/tests/government_seed.mjs +1 -0
  67. package/tests/hall_street_desk.mjs +37 -0
  68. package/tests/hall_tweak_editor.mjs +1 -1
@@ -111,9 +111,9 @@
111
111
 
112
112
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
113
113
  chrome exists before the page script runs. -->
114
- <script src="/builders/shell-trail.js?v=2026-10-03-nav"></script>
115
- <script src="/builders/craft-mode.js?v=2026-10-03-nav"></script>
116
- <script src="/builders/shell.js?v=2026-10-03-nav"></script>
114
+ <script src="/builders/shell-trail.js?v=2026-10-03-looks"></script>
115
+ <script src="/builders/craft-mode.js?v=2026-10-03-looks"></script>
116
+ <script src="/builders/shell.js?v=2026-10-03-looks"></script>
117
117
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
118
118
  <script src="/builders/palette.js" defer></script>
119
119
  <!-- shared frontend toolkit (window.OTB) — loaded BEFORE sessions.js so it can pull from it. -->
@@ -435,9 +435,9 @@
435
435
 
436
436
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
437
437
  chrome exists before the page scripts run. -->
438
- <script src="/builders/shell-trail.js?v=2026-10-03-nav"></script>
439
- <script src="/builders/craft-mode.js?v=2026-10-03-nav"></script>
440
- <script src="/builders/shell.js?v=2026-10-03-nav"></script>
438
+ <script src="/builders/shell-trail.js?v=2026-10-03-looks"></script>
439
+ <script src="/builders/craft-mode.js?v=2026-10-03-looks"></script>
440
+ <script src="/builders/shell.js?v=2026-10-03-looks"></script>
441
441
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
442
442
  <script src="/builders/palette.js" defer></script>
443
443
  <!-- shared frontend toolkit (window.OTB) — loaded before consuming IIFEs. -->
@@ -325,6 +325,7 @@
325
325
  if (item.closed_at) {
326
326
  cv.readOnly = true;
327
327
  if (item.outcome === 'passed') cv.result = 'Passed ' + b.forN + '–' + b.againstN + '. In force from ' + str(item.closed_at).slice(0, 10) + '.';
328
+ else if (item.outcome === 'withdrawn') cv.result = b.isAuthor ? 'You withdrew it before anyone voted.' : 'Withdrawn by ' + (proposer || 'its author') + ' before anyone voted.';
328
329
  else if (b.isAuthor && cv.counsel && cv.counsel.revise) { cv.result = 'It came back to you.'; cv.readOnly = false; cv.reviseForm = { text: words, label: kind === 'matter' ? 'the matter, in your words' : 'why, in your words' }; }
329
330
  else cv.result = 'It went back to ' + (proposer || 'its author') + ' to revise.';
330
331
  return cv;
@@ -346,6 +347,16 @@
346
347
  cv.result = m.state === 'needs' ? 'Read the idea in the Board Room and vote there, with its sections in front of you.' : 'Not yours to vote on.';
347
348
  return cv;
348
349
  }
350
+ // task 1004564: the proposer may withdraw their own sitting until anyone has
351
+ // voted (POST .../withdraw; the server holds the same two walls). If they may
352
+ // also vote on it, withdraw takes the second place: an objection to your own
353
+ // proposal is a withdraw said the long way.
354
+ var withdrawable = b.isAuthor && !((item.votes || []).length);
355
+ if (withdrawable) {
356
+ cv.actions = b.youOpen ? [act('for', 'vote for', 'primary'), act('withdraw', 'withdraw', 'quiet')] : [act('withdraw', 'withdraw', 'quiet')];
357
+ cv.result = 'Yours. No one has voted yet, so you can still withdraw it.';
358
+ return cv;
359
+ }
349
360
  if (b.youOpen) {
350
361
  cv.actions = [act('for', 'vote for', 'primary'), act('object', 'object', 'quiet')];
351
362
  cv.textActions = [act('abstain', 'abstain', 'text')];
@@ -575,6 +586,7 @@
575
586
  if (s.itemId != null) {
576
587
  if (key === 'for') return post('/government/board/items/' + enc(s.itemId) + '/votes', { direction: 'yes' });
577
588
  if (key === 'object') return post('/government/board/items/' + enc(s.itemId) + '/votes', { direction: 'no', reason_md: str(inp.reason).trim(), section_key: str(inp.section) });
589
+ if (key === 'withdraw') return post('/government/board/items/' + enc(s.itemId) + '/withdraw', {});
578
590
  if (key === 'revise') {
579
591
  var text = str(inp.text).trim();
580
592
  if (s.kind === 'amendment') return post('/government/board/items', { subject_type: 'constitutional_amendment', proposal: s.proposed, rationale_md: text });
@@ -629,6 +641,7 @@
629
641
  return { toast: r.already_voted ? 'Your vote was already on the record.' : 'Your yes is on the record.', building: b, kind: 'light', close: false };
630
642
  }
631
643
  if (key === 'object') return { toast: r.item_closed ? 'Your objection is on the record. It goes back to its author to revise.' : 'Your objection is on the record.', building: b, kind: 'settle', close: false };
644
+ if (key === 'withdraw') return { toast: 'Withdrawn. The sitting is closed and nothing was decided.', building: b, kind: 'settle', close: true };
632
645
  if (key === 'abstain') return { toast: 'Noted. Nothing goes on the record, and it still waits for your vote until it closes.', building: null, kind: null, close: true };
633
646
  if (key === 'revise') return { toast: 'Revised. It sits again on the new wording.', building: b, kind: 'light', close: true };
634
647
  if (key === 'hold') return { toast: 'Held. It waits for you.', building: null, kind: null, close: true };
@@ -678,6 +691,9 @@
678
691
  backup_already_running: 'A backup is already running.',
679
692
  intent_conflict: 'Another core run is going. Try again when it settles.',
680
693
  criterion_needs_recording: 'This one needs a recording. Sign it off on its goal page.',
694
+ not_proposer: 'Only the person who put it up may withdraw it.',
695
+ votes_cast: 'Someone has voted on it, so it is the sitting\'s to decide now.',
696
+ item_changed: 'Someone voted just now, so it stays with the sitting.',
681
697
  };
682
698
  function refusal(status, body) {
683
699
  var e = body && body.error ? body.error : {};
@@ -119,6 +119,8 @@
119
119
  decide: 'decide', ratify: 'ratify', ratify_goal: 'make goal', up: 'move up', down: 'move down',
120
120
  // task 1004561: a builder's monthly limit, and a sign-off undone
121
121
  budget: 'set limit', unsign: 'undo',
122
+ // task 1004564: the proposer takes back their own sitting before any vote
123
+ withdraw: 'withdraw',
122
124
  });
123
125
  // What a row's chips ask, per act (a reason or an option is one tap, never text).
124
126
  const ASKS = Object.freeze({ 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', allow: 'which goal', tier: 'which tier', give: 'to whom', take: 'from whom' });
@@ -208,9 +208,9 @@
208
208
  </div>
209
209
  </div>
210
210
 
211
- <script src="/builders/shell-trail.js?v=2026-10-03-nav"></script>
212
- <script src="/builders/craft-mode.js?v=2026-10-03-nav"></script>
213
- <script src="/builders/shell.js?v=2026-10-03-nav"></script>
211
+ <script src="/builders/shell-trail.js?v=2026-10-03-looks"></script>
212
+ <script src="/builders/craft-mode.js?v=2026-10-03-looks"></script>
213
+ <script src="/builders/shell.js?v=2026-10-03-looks"></script>
214
214
  <script src="/builders/palette.js" defer></script>
215
215
  <script src="/builders/city-draw.js?v=2026-10-02-street1"></script>
216
216
  <script src="/builders/street-draw.js?v=2026-10-02-street1"></script>
@@ -751,7 +751,7 @@
751
751
  pause: 'paused', resume: 'resumed', tier: 'new tier', deactivate: 'deactivated', give: 'gave', take: 'took back',
752
752
  check: 'checked', rot: 'set', module_on: 'turned on', module_off: 'turned off', backup: 'backed up', invite: 'invited',
753
753
  decide: 'decided', ratify: 'ratified', ratify_goal: 'made a goal of',
754
- budget: 'set the limit of', unsign: 'undid',
754
+ budget: 'set the limit of', unsign: 'undid', withdraw: 'withdrew',
755
755
  };
756
756
  // One act from a row: checked, sent through the card's own request (the
757
757
  // server's permission checks decide), then the row clears, the floor counts
@@ -176,9 +176,9 @@
176
176
 
177
177
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
178
178
 
179
- <script src="/builders/shell-trail.js?v=2026-10-03-nav"></script>
180
- <script src="/builders/craft-mode.js?v=2026-10-03-nav"></script>
181
- <script src="/builders/shell.js?v=2026-10-03-nav"></script>
179
+ <script src="/builders/shell-trail.js?v=2026-10-03-looks"></script>
180
+ <script src="/builders/craft-mode.js?v=2026-10-03-looks"></script>
181
+ <script src="/builders/shell.js?v=2026-10-03-looks"></script>
182
182
  <script src="/builders/palette.js" defer></script>
183
183
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
184
184
  <!-- The kit, for its ONE renderer: the full-size ship-time visual figure
@@ -1206,8 +1206,8 @@ html[data-fullscreen="on"][data-fullscreen-plain="on"] .app-content { padding-to
1206
1206
  /* THE CRAFT MODE (task 1004492, craft-mode.js). The top bar's switch: one pill
1207
1207
  holding the four crafts and Legacy, the current one lit. Narrower windows
1208
1208
  drop the words, and a phone shows only the current mode (or "Mode"), which
1209
- opens the picker. The picker below asks once per session. Neither paints a
1210
- mode's colour — the looks are their own task (1004495). */
1209
+ opens the picker. The picker below asks once per session. Each mode's look
1210
+ (its colour on the current pill) is THE CRAFT MODE LOOKS, further down. */
1211
1211
  .craft-switch {
1212
1212
  display: inline-flex;
1213
1213
  align-items: center;
@@ -1431,6 +1431,125 @@ html.craft-picker-open { overflow: hidden; }
1431
1431
  .craft-picker__legacy { height: auto; min-height: 40px; white-space: normal; text-align: left; }
1432
1432
  }
1433
1433
 
1434
+ /* THE CRAFT MODE LOOKS (task 1004495; direction docs/design/craft-mode-direction.md).
1435
+ Three modes wear a look: Artistry, Ideating, Governing. Building, Legacy and no
1436
+ mode wear none — nothing below names them, so they render as the hall always
1437
+ did (tests/craft_mode_looks.mjs pins it). --craft marks WHERE you are, never
1438
+ what to press: the accent stays the action colour in every mode. Light values
1439
+ clear 5.4:1 on the hall's paper; the dark values are the prototype's. Light
1440
+ keeps the paper ground; only dark changes the ground and the glass. */
1441
+ html[data-craft="artistry"] { --craft: #2e3c6e; --craft-on: #f1e6d2; }
1442
+ html[data-craft="ideating"] { --craft: #7a5a0c; --craft-on: #fdf6dd; }
1443
+ html[data-craft="governing"] { --craft: #33479e; --craft-on: #eef1ff; }
1444
+ html[data-theme="dark"][data-craft="artistry"] {
1445
+ --craft: #f1e6d2; --craft-on: #0c1122;
1446
+ --bg: #07080d; --bg-frame: #0a0b12;
1447
+ --glass: rgba(150, 170, 230, .11); --glass-strong: rgba(170, 190, 240, .17);
1448
+ --glass-edge: rgba(241, 230, 210, .16);
1449
+ }
1450
+ html[data-theme="dark"][data-craft="ideating"] {
1451
+ --craft: #f2d27a; --craft-on: #1d1604;
1452
+ --bg: #05060d;
1453
+ --glass: rgba(140, 150, 225, .10); --glass-strong: rgba(160, 170, 240, .16);
1454
+ --glass-edge: rgba(242, 210, 122, .16);
1455
+ }
1456
+ html[data-theme="dark"][data-craft="governing"] {
1457
+ --craft: #aabeff; --craft-on: #0a1030;
1458
+ --glass-edge: rgba(170, 190, 255, .16);
1459
+ }
1460
+ /* The shared treatment: the switch's current pill, the current tab and the top
1461
+ bar's hairline take the mode colour. */
1462
+ html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing"]) .craft-switch__opt[aria-pressed="true"] {
1463
+ background: var(--craft); color: var(--craft-on);
1464
+ }
1465
+ html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing"]) .nav-item--active {
1466
+ background: color-mix(in srgb, var(--craft) 14%, transparent); color: var(--craft);
1467
+ }
1468
+ html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing"]) .nav-item--active .nav-icon,
1469
+ html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing"]) .nav-item--active .nav-item__label { color: var(--craft); }
1470
+ html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing"]) .nav-item--active::before { background: var(--craft); }
1471
+ html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing"]) .app-topbar {
1472
+ border-bottom-color: color-mix(in srgb, var(--craft) 30%, transparent);
1473
+ }
1474
+ /* The header mark: the mode's own words beside the page title (craft-mode.js
1475
+ applyLook). Hidden on a phone — the switch's one pill already says the mode,
1476
+ and the bar has no room for both. */
1477
+ .craft-eyebrow {
1478
+ font-family: var(--label-face);
1479
+ font-size: 11px;
1480
+ font-weight: 500;
1481
+ letter-spacing: .14em;
1482
+ text-transform: uppercase;
1483
+ color: var(--craft);
1484
+ white-space: nowrap;
1485
+ flex-shrink: 0;
1486
+ }
1487
+ @media (max-width: 640px) { .craft-eyebrow { display: none; } }
1488
+ /* The signature: the mode's door scene (craft-mode.js artHtml, the same art the
1489
+ picker draws) framed under the brand, 40px in the closed rail and 64px open.
1490
+ The 110px-tall scene is scaled to the plate's height and widened to fill it,
1491
+ never redrawn: each scene's pieces are placed from its edges or its centre. */
1492
+ .craft-plate {
1493
+ position: relative;
1494
+ height: 40px;
1495
+ margin: 10px 8px 0;
1496
+ border-radius: var(--radius-sm);
1497
+ overflow: hidden;
1498
+ flex-shrink: 0;
1499
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--craft) 30%, transparent);
1500
+ }
1501
+ .craft-plate .craft-art {
1502
+ position: absolute;
1503
+ left: 0;
1504
+ top: 0;
1505
+ width: calc(100% / .364);
1506
+ height: 110px;
1507
+ transform: scale(.364);
1508
+ transform-origin: 0 0;
1509
+ }
1510
+ .craft-plate .craft-art__city { top: 0; }
1511
+ .app-sidebar:hover .craft-plate,
1512
+ .app-sidebar:focus-within .craft-plate { height: 64px; margin: 10px 16px 0; }
1513
+ .app-sidebar:hover .craft-plate .craft-art,
1514
+ .app-sidebar:focus-within .craft-plate .craft-art { width: calc(100% / .582); transform: scale(.582); }
1515
+ @media (max-width: 768px) {
1516
+ .craft-plate { height: 64px; margin: 10px 16px 0; }
1517
+ .craft-plate .craft-art { width: calc(100% / .582); transform: scale(.582); }
1518
+ }
1519
+ /* The plate follows the theme (owner, 2026-10-03): light shows each scene's day. */
1520
+ html[data-theme="light"] .craft-plate .craft-art--artistry { background: #dfe3ef; }
1521
+ html[data-theme="light"] .craft-plate .craft-art__dot--star { background: #2e3c6e; }
1522
+ html[data-theme="light"] .craft-plate .craft-art__moon { background: #f3c98b; box-shadow: 0 0 0 9px rgba(243, 201, 139, .28); }
1523
+ html[data-theme="light"] .craft-plate .craft-art__ground { background: #c3c9dc; }
1524
+ html[data-theme="light"] .craft-plate .craft-art__house { background: #8d97b8; }
1525
+ html[data-theme="light"] .craft-plate .craft-art--ideating { background: #f6f0dc; }
1526
+ html[data-theme="light"] .craft-plate .craft-art__dot--gold { background: #c9a23a; }
1527
+ html[data-theme="light"] .craft-plate .craft-art__dot--glow { background: #b8891c; box-shadow: 0 0 10px rgba(184, 137, 28, .45); }
1528
+ html[data-theme="light"] .craft-plate .craft-art__dot--proto { box-shadow: inset 0 0 0 1px rgba(122, 90, 12, .6); }
1529
+ html[data-theme="light"] .craft-plate .craft-art__ring { box-shadow: inset 0 0 0 1px rgba(122, 90, 12, .35); }
1530
+ html[data-theme="light"] .craft-plate .craft-art--governing { background: #e9ecf6; }
1531
+ html[data-theme="light"] .craft-plate .craft-art__city { filter: invert(1) hue-rotate(180deg); }
1532
+ /* Ideating's star dust: a few faint fixed specks across the rail itself. */
1533
+ html[data-craft="ideating"] .app-sidebar {
1534
+ background-image:
1535
+ radial-gradient(1px 1px at 14px 40px, rgba(242, 210, 122, .5), transparent),
1536
+ radial-gradient(1px 1px at 46px 130px, rgba(242, 210, 122, .35), transparent),
1537
+ radial-gradient(1.5px 1.5px at 120px 90px, rgba(242, 210, 122, .4), transparent),
1538
+ radial-gradient(1px 1px at 200px 190px, rgba(242, 210, 122, .35), transparent),
1539
+ radial-gradient(1px 1px at 30px 230px, rgba(242, 210, 122, .3), transparent),
1540
+ radial-gradient(1.5px 1.5px at 170px 260px, rgba(242, 210, 122, .3), transparent);
1541
+ background-size: 246px 280px;
1542
+ }
1543
+ html[data-theme="light"][data-craft="ideating"] .app-sidebar {
1544
+ background-image:
1545
+ radial-gradient(1px 1px at 14px 40px, rgba(184, 137, 28, .45), transparent),
1546
+ radial-gradient(1px 1px at 46px 130px, rgba(184, 137, 28, .3), transparent),
1547
+ radial-gradient(1.5px 1.5px at 120px 90px, rgba(184, 137, 28, .35), transparent),
1548
+ radial-gradient(1px 1px at 200px 190px, rgba(184, 137, 28, .3), transparent),
1549
+ radial-gradient(1px 1px at 30px 230px, rgba(184, 137, 28, .25), transparent),
1550
+ radial-gradient(1.5px 1.5px at 170px 260px, rgba(184, 137, 28, .25), transparent);
1551
+ }
1552
+
1434
1553
  /* THE BACK LINK (task 1004430): at the head of the bar when the page was
1435
1554
  reached from inside another hall page. 24px floor on both axes. */
1436
1555
  .topbar-back {
@@ -54,9 +54,9 @@
54
54
 
55
55
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
56
56
  time so the chrome exists before the deferred page scripts execute. -->
57
- <script src="/builders/shell-trail.js?v=2026-10-03-nav"></script>
58
- <script src="/builders/craft-mode.js?v=2026-10-03-nav"></script>
59
- <script src="/builders/shell.js?v=2026-10-03-nav"></script>
57
+ <script src="/builders/shell-trail.js?v=2026-10-03-looks"></script>
58
+ <script src="/builders/craft-mode.js?v=2026-10-03-looks"></script>
59
+ <script src="/builders/shell.js?v=2026-10-03-looks"></script>
60
60
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
61
61
  <script src="/builders/palette.js" defer></script>
62
62
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so task.js can pull from it. -->
@@ -138,9 +138,9 @@
138
138
  </div>
139
139
  </div>
140
140
 
141
- <script src="/builders/shell-trail.js?v=2026-10-03-nav"></script>
142
- <script src="/builders/craft-mode.js?v=2026-10-03-nav"></script>
143
- <script src="/builders/shell.js?v=2026-10-03-nav"></script>
141
+ <script src="/builders/shell-trail.js?v=2026-10-03-looks"></script>
142
+ <script src="/builders/craft-mode.js?v=2026-10-03-looks"></script>
143
+ <script src="/builders/shell.js?v=2026-10-03-looks"></script>
144
144
  <script src="/builders/palette.js" defer></script>
145
145
 
146
146
  <script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
@@ -142,9 +142,9 @@
142
142
 
143
143
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
144
144
 
145
- <script src="/builders/shell-trail.js?v=2026-10-03-nav"></script>
146
- <script src="/builders/craft-mode.js?v=2026-10-03-nav"></script>
147
- <script src="/builders/shell.js?v=2026-10-03-nav"></script>
145
+ <script src="/builders/shell-trail.js?v=2026-10-03-looks"></script>
146
+ <script src="/builders/craft-mode.js?v=2026-10-03-looks"></script>
147
+ <script src="/builders/shell.js?v=2026-10-03-looks"></script>
148
148
  <script src="/builders/palette.js" defer></script>
149
149
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
150
150
  <script src="/builders/brand-holes.js?v=2026-09-29-aqfix"></script>
@@ -162,9 +162,9 @@
162
162
 
163
163
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
164
164
  chrome exists before the page script runs. -->
165
- <script src="/builders/shell-trail.js?v=2026-10-03-nav"></script>
166
- <script src="/builders/craft-mode.js?v=2026-10-03-nav"></script>
167
- <script src="/builders/shell.js?v=2026-10-03-nav"></script>
165
+ <script src="/builders/shell-trail.js?v=2026-10-03-looks"></script>
166
+ <script src="/builders/craft-mode.js?v=2026-10-03-looks"></script>
167
+ <script src="/builders/shell.js?v=2026-10-03-looks"></script>
168
168
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
169
169
  <script src="/builders/palette.js" defer></script>
170
170
  <!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
@@ -171,9 +171,9 @@
171
171
 
172
172
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
- <script src="/builders/shell-trail.js?v=2026-10-03-nav"></script>
175
- <script src="/builders/craft-mode.js?v=2026-10-03-nav"></script>
176
- <script src="/builders/shell.js?v=2026-10-03-nav"></script>
174
+ <script src="/builders/shell-trail.js?v=2026-10-03-looks"></script>
175
+ <script src="/builders/craft-mode.js?v=2026-10-03-looks"></script>
176
+ <script src="/builders/shell.js?v=2026-10-03-looks"></script>
177
177
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
178
178
  <script src="/builders/palette.js" defer></script>
179
179
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
@@ -27,3 +27,11 @@ Covers: every shelled hall page (`craft-mode.js`, `shell.js`, `style.css`), and
27
27
  - **Government shows only in Governing**, and outside that group Governing is the shortest list (Blockers, Board Room). Building is every tab except Government and the Board Room; Artistry is Studio, Goals, Your thinking; Ideating is Sky, Your thinking, Goals. The prototype names rooms that do not exist yet (Store, Page reviews, Treasury); a list names only real NAV items, and `tests/craft_mode_nav.mjs` requires every NAV item to sit in some mode.
28
28
  - **The mode only ever takes away, and never through `hidden`.** The access gate owns `hidden`; `applyNav()` marks what the mode leaves out with `[data-craft-off]`, which `style.css` hides. So a mode can never show a tab the gate hid, and switching mode just moves the attribute. A group head goes too once nothing under it shows.
29
29
  - **Two callers.** `apply()` runs it on every mode change (live, no reload), and `shell.js` `applyAccess` runs it right after its gate loop, so a later `/me` answer (a promotion, a permission) brings a head back with its tabs. Hidden tabs still open by their URL; nothing is enforced (ADR 0016).
30
+
31
+ ## Each mode's look (task 1004495)
32
+
33
+ - **Three modes wear a look; Building, Legacy and no mode wear none.** Artistry, Ideating and Governing each set `--craft` (where you are) and `--craft-on` (text on it) on `html[data-craft="<mode>"]`, with a dark twin on `html[data-theme="dark"][data-craft="<mode>"]` that also darkens the ground and tints the glass (Artistry, Ideating). Values and contrast are the direction's, `docs/design/craft-mode-direction.md`. No rule in `style.css` names `building` or `legacy`, and none matches any mode at all; `tests/craft_mode_looks.mjs` pins both, and re-measures every colour pair.
34
+ - **The accent stays the action colour.** `--craft` paints only the switch's current pill, the current tab (tint, icon, label, marker) and the top bar's hairline.
35
+ - **Two pieces are drawn by `craft-mode.js` `applyLook()`, on every mode change:** the eyebrow beside `#topbar-title` (`LOOKS`: "the studio · night", "the sky", "the city"; hidden at <=640px, where it overflowed phones) and the scene plate under `.app-sidebar__brand` (40px closed rail, 64px open). The plate's markup IS `artHtml(mode)`, the picker's door art, exported rather than copied; light mode recolours it to each scene's day (the city is the dark one through `invert(1) hue-rotate(180deg)`). It does not animate.
36
+ - **Always-present tabs widened** (owner, 2026-10-03): `ALWAYS_TABS` is Builders, Collab and Profile, so every mode keeps the You and Community groups.
37
+ - **Governing's header tallies** ("needs you · moving") are task 1004563, not this look.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.36",
3
+ "version": "1.21.38",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.36",
9
+ "version": "1.21.38",
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.36",
3
+ "version": "1.21.38",
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",
@@ -8980,5 +8980,17 @@
8980
8980
  "id": "1004560",
8981
8981
  "text": "The Planning building now has three working desks on its first floors. Priorities lists the open goals in order with a square for each promise; arrows move a goal up or down, and a save bar (with undo) sets the new order, or,"
8982
8982
  }
8983
+ ],
8984
+ "1.21.37": [
8985
+ {
8986
+ "id": "1004495",
8987
+ "text": "The hall now looks different in each craft mode. In Artistry, Ideating and Governing the mode button, the current sidebar tab and the top edge of the page take the mode's own colour (moonlight or night ink, gold, city blue), t"
8988
+ }
8989
+ ],
8990
+ "1.21.38": [
8991
+ {
8992
+ "id": "1004564",
8993
+ "text": "If you put something up for a vote and nobody has voted yet, you can now withdraw it with one tap on your desk. Once anyone votes, it stays with the vote. A small database change makes \"withdrawn\" a valid way for a sitting to"
8994
+ }
8983
8995
  ]
8984
8996
  }
@@ -292,6 +292,10 @@ const GOVERNMENT_PINS = {
292
292
  // writes the table; neither route does.
293
293
  'POST /government/board/decision-rules': 'perm:board.item.open',
294
294
  'POST /government/board/matters': 'perm:board.item.open',
295
+ // task 1004564: a proposer withdraws their own open sitting before any vote —
296
+ // the proposer's half of putting one up, so the same atom. The proposer-only
297
+ // and no-votes walls are in withdrawItem (board-withdraw.js) and its write.
298
+ 'POST /government/board/items/:itemId/withdraw': 'perm:board.item.open',
295
299
  };
296
300
 
297
301
  // Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
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.36'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.38'; // 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');
@@ -0,0 +1,170 @@
1
+ // tests/craft_mode_looks.mjs — each craft mode's look (task 1004495, goal 1000095
2
+ // WA6; direction docs/design/craft-mode-direction.md).
3
+ //
4
+ // Artistry, Ideating and Governing each wear a look: --craft / --craft-on tokens
5
+ // (light + dark twins) in style.css, the header eyebrow beside the page title and
6
+ // the door's scene framed under the brand. Building, Legacy and no mode wear
7
+ // NOTHING — the hall as it always was. These cases run the REAL craft-mode.js over
8
+ // a small DOM and read the REAL style.css, so a look that leaks into Building, a
9
+ // plate that is a second copy of the art, or a colour that loses its contrast reds.
10
+ import assert from 'node:assert/strict';
11
+ import { test } from 'node:test';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import vm from 'node:vm';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
19
+ const CRAFT = fs.readFileSync(path.join(HALL, 'craft-mode.js'), 'utf8');
20
+ const CSS = fs.readFileSync(path.join(HALL, 'style.css'), 'utf8');
21
+
22
+ const LOOKED = ['artistry', 'ideating', 'governing'];
23
+ const WORDS = { artistry: 'the studio · night', ideating: 'the sky', governing: 'the city' };
24
+
25
+ // A small DOM: enough tree for insertBefore / removeChild / nextSibling to be real.
26
+ function node(tag, id, cls) {
27
+ const attrs = {};
28
+ const el = {
29
+ tagName: tag, id: id || '', className: cls || '', dataset: {}, children: [], parentNode: null,
30
+ textContent: '', innerHTML: '',
31
+ setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
32
+ hasAttribute(k) { return k in attrs; }, removeAttribute(k) { delete attrs[k]; },
33
+ get nextSibling() { const sib = this.parentNode && this.parentNode.children; return sib ? sib[sib.indexOf(this) + 1] || null : null; },
34
+ appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
35
+ insertBefore(c, ref) {
36
+ c.parentNode = this;
37
+ const at = ref ? this.children.indexOf(ref) : -1;
38
+ if (at === -1) this.children.push(c); else this.children.splice(at, 0, c);
39
+ return c;
40
+ },
41
+ removeChild(c) { this.children.splice(this.children.indexOf(c), 1); c.parentNode = null; return c; },
42
+ querySelector(sel) { return all(this).find((n) => sel === '.' + n.className) || null; },
43
+ querySelectorAll() { return []; },
44
+ addEventListener() {}, classList: { toggle() {} },
45
+ };
46
+ return el;
47
+ }
48
+ function all(root) { return root.children.flatMap((c) => [c, ...all(c)]); }
49
+
50
+ function boot(session = {}) {
51
+ const sidebar = node('NAV', 'app-sidebar');
52
+ const brand = sidebar.appendChild(node('DIV', '', 'app-sidebar__brand'));
53
+ sidebar.appendChild(node('NAV', '', 'app-sidebar__nav'));
54
+ const topbar = node('DIV', 'app-topbar');
55
+ topbar.appendChild(node('BUTTON', 'topbar-menu'));
56
+ const title = topbar.appendChild(node('H2', 'topbar-title'));
57
+ topbar.appendChild(node('DIV', '', 'topbar-side'));
58
+ const root = node('HTML');
59
+ const body = node('BODY');
60
+ const document = {
61
+ documentElement: root, body, activeElement: null,
62
+ getElementById: (id) => [sidebar, topbar, ...all(sidebar), ...all(topbar)].find((n) => n.id === id) || null,
63
+ querySelectorAll: () => [],
64
+ createElement: (t) => node(t.toUpperCase()),
65
+ addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; },
66
+ };
67
+ const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
68
+ const window = { localStorage: mk({}), sessionStorage: mk(session), matchMedia: () => ({ matches: false }) };
69
+ const sandbox = { window, document, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
70
+ vm.createContext(sandbox);
71
+ vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
72
+ const craft = window.OTBCraftMode;
73
+ craft.mount();
74
+ return {
75
+ craft, root, sidebar, brand, topbar, title,
76
+ plates: () => all(sidebar).filter((n) => n.id === 'craft-plate'),
77
+ marks: () => all(topbar).filter((n) => n.id === 'craft-eyebrow'),
78
+ };
79
+ }
80
+
81
+ test('each looked mode frames its own door scene under the brand, and names itself beside the title', () => {
82
+ const p = boot();
83
+ for (const m of LOOKED) {
84
+ p.craft.choose(m);
85
+ assert.equal(p.plates().length, 1, `${m}: exactly one plate`);
86
+ const plate = p.plates()[0];
87
+ assert.equal(p.brand.nextSibling, plate, `${m}: the plate sits right under the brand`);
88
+ assert.equal(plate.getAttribute('data-plate'), m);
89
+ assert.equal(plate.getAttribute('aria-hidden'), 'true', 'the plate is decoration');
90
+ assert.equal(plate.innerHTML, p.craft.artHtml(m), `${m}: the plate IS the picker's door art, not a second copy`);
91
+ assert.equal(p.marks().length, 1, `${m}: exactly one eyebrow`);
92
+ assert.equal(p.title.nextSibling, p.marks()[0], `${m}: the eyebrow sits right after the page title`);
93
+ assert.equal(p.marks()[0].textContent, WORDS[m], `${m}: in the prototype's own words`);
94
+ }
95
+ });
96
+
97
+ test('BUILDING, LEGACY AND NO MODE WEAR NO LOOK: no plate, no eyebrow, and switching to them takes the look away', () => {
98
+ const none = boot();
99
+ assert.equal(none.root.dataset.craft, undefined);
100
+ assert.equal(none.plates().length + none.marks().length, 0, 'no mode draws nothing');
101
+ for (const m of ['building', 'legacy']) {
102
+ const p = boot({ 'hall-craft': m });
103
+ assert.equal(p.plates().length + p.marks().length, 0, `${m} at boot draws nothing`);
104
+ p.craft.choose('governing');
105
+ assert.equal(p.plates().length, 1);
106
+ p.craft.choose(m);
107
+ assert.equal(p.plates().length + p.marks().length, 0, `switching to ${m} removes the plate and the eyebrow`);
108
+ }
109
+ assert.deepEqual(Object.keys(none.craft.LOOKS).sort(), [...LOOKED].sort(), 'only the three crafts have a look');
110
+ });
111
+
112
+ test('the art is drawn once: craft-mode.js has one artHtml and exports it', () => {
113
+ assert.equal(CRAFT.match(/function artHtml\(/g).length, 1);
114
+ assert.match(CRAFT, /artHtml: artHtml,/);
115
+ assert.match(CRAFT, /plate\.innerHTML = artHtml\(mode\);/);
116
+ });
117
+
118
+ // Every selector in style.css that names a craft mode.
119
+ const CRAFT_SELECTORS = [...CSS.matchAll(/data-craft="([a-z]+)"/g)].map((m) => m[1]);
120
+
121
+ test('style.css names only the three looked modes — Building and Legacy have no rule at all', () => {
122
+ assert.ok(CRAFT_SELECTORS.length >= 12, `found ${CRAFT_SELECTORS.length} craft selectors`);
123
+ for (const m of CRAFT_SELECTORS) assert.ok(LOOKED.includes(m), `a rule names data-craft="${m}"`);
124
+ assert.doesNotMatch(CSS, /data-craft="(building|legacy)"/);
125
+ assert.doesNotMatch(CSS, /data-craft\]/, 'no rule matches ANY mode, which would dress Building too');
126
+ });
127
+
128
+ // The token values, light and dark, read from the stylesheet itself.
129
+ function tokens(selector) {
130
+ const at = CSS.indexOf(selector + ' {');
131
+ assert.ok(at !== -1, `style.css has a ${selector} block`);
132
+ const block = CSS.slice(at, CSS.indexOf('}', at));
133
+ const get = (name) => (block.match(new RegExp(name + ':\\s*(#[0-9a-f]{6})')) || [])[1];
134
+ return { craft: get('--craft'), on: get('--craft-on'), bg: get('--bg') };
135
+ }
136
+ function lum(hex) {
137
+ const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
138
+ .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
139
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
140
+ }
141
+ function contrast(a, b) { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }
142
+
143
+ test('every mode colour has a light and a dark twin that keep their contrast', () => {
144
+ const LIGHT_BG = '#edecee', DARK_BG = '#000000';
145
+ for (const m of LOOKED) {
146
+ const light = tokens(`html[data-craft="${m}"]`);
147
+ const dark = tokens(`html[data-theme="dark"][data-craft="${m}"]`);
148
+ for (const [k, t] of [['light', light], ['dark', dark]]) {
149
+ assert.ok(t.craft && t.on, `${m} ${k} sets --craft and --craft-on`);
150
+ }
151
+ assert.ok(contrast(light.craft, LIGHT_BG) >= 5.4, `${m} light --craft on paper: ${contrast(light.craft, LIGHT_BG).toFixed(2)}`);
152
+ assert.ok(contrast(dark.craft, dark.bg || DARK_BG) >= 5.4, `${m} dark --craft on its ground`);
153
+ assert.ok(contrast(light.on, light.craft) >= 5.8, `${m} light pill text: ${contrast(light.on, light.craft).toFixed(2)}`);
154
+ assert.ok(contrast(dark.on, dark.craft) >= 5.8, `${m} dark pill text`);
155
+ assert.equal(light.bg, undefined, `${m}: light keeps the hall's paper ground`);
156
+ }
157
+ });
158
+
159
+ test('the shared treatment, the eyebrow and the plate are written as the direction says', () => {
160
+ const looked = 'html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing"])';
161
+ for (const target of ['.craft-switch__opt[aria-pressed="true"]', '.nav-item--active', '.nav-item--active::before', '.app-topbar']) {
162
+ assert.ok(CSS.includes(`${looked} ${target}`), `the looked modes restyle ${target}`);
163
+ }
164
+ assert.match(CSS, /@media \(max-width: 640px\) \{ \.craft-eyebrow \{ display: none; \} \}/, 'the eyebrow is hidden on a phone');
165
+ assert.match(CSS, /\.craft-plate \{[^}]*height: 40px;/, '40px in the closed rail');
166
+ assert.match(CSS, /\.app-sidebar:focus-within \.craft-plate \{ height: 64px;/, '64px when the rail opens');
167
+ assert.match(CSS, /html\[data-theme="light"\] \.craft-plate \.craft-art__city \{ filter: invert\(1\) hue-rotate\(180deg\); \}/,
168
+ 'the light city is the dark one inverted, not a second drawing');
169
+ assert.equal(CSS.match(/prefers-reduced-motion/g).length, 1, 'no second reduced-motion block');
170
+ });