@bongos/core 1.21.36 → 1.21.37

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 (49) hide show
  1. package/.bongos-core.json +59 -54
  2. package/docs/copy-inventory.md +8 -8
  3. package/docs/copy-registry.json +8 -8
  4. package/docs/module-api-changelog.md +2 -0
  5. package/docs/page-readings.json +234 -234
  6. package/modules/hall-ui/public/atlas.html +3 -3
  7. package/modules/hall-ui/public/blockers.html +3 -3
  8. package/modules/hall-ui/public/board-room.html +3 -3
  9. package/modules/hall-ui/public/city.html +3 -3
  10. package/modules/hall-ui/public/collab.html +3 -3
  11. package/modules/hall-ui/public/commands.html +3 -3
  12. package/modules/hall-ui/public/copy-desk.html +3 -3
  13. package/modules/hall-ui/public/craft-mode.js +44 -6
  14. package/modules/hall-ui/public/deploy.html +3 -3
  15. package/modules/hall-ui/public/diagrams.html +3 -3
  16. package/modules/hall-ui/public/drachmae.html +3 -3
  17. package/modules/hall-ui/public/fleet.html +3 -3
  18. package/modules/hall-ui/public/gate.html +3 -3
  19. package/modules/hall-ui/public/goal-map.html +3 -3
  20. package/modules/hall-ui/public/goals.html +3 -3
  21. package/modules/hall-ui/public/government.html +3 -3
  22. package/modules/hall-ui/public/idea.html +3 -3
  23. package/modules/hall-ui/public/ideas.html +3 -3
  24. package/modules/hall-ui/public/index.html +3 -3
  25. package/modules/hall-ui/public/modules.html +3 -3
  26. package/modules/hall-ui/public/primer.html +3 -3
  27. package/modules/hall-ui/public/profile.html +3 -3
  28. package/modules/hall-ui/public/project-settings.html +3 -3
  29. package/modules/hall-ui/public/ranks.html +3 -3
  30. package/modules/hall-ui/public/roadmap.html +3 -3
  31. package/modules/hall-ui/public/roster.html +3 -3
  32. package/modules/hall-ui/public/sessions.html +3 -3
  33. package/modules/hall-ui/public/settings.html +3 -3
  34. package/modules/hall-ui/public/street.html +3 -3
  35. package/modules/hall-ui/public/studio.html +3 -3
  36. package/modules/hall-ui/public/style.css +121 -2
  37. package/modules/hall-ui/public/task.html +3 -3
  38. package/modules/hall-ui/public/thinking.html +3 -3
  39. package/modules/hall-ui/public/tweak-editor.html +3 -3
  40. package/modules/hall-ui/public/watch.html +3 -3
  41. package/modules/hall-ui/public/work.html +3 -3
  42. package/modules/hall-ui/records/craft-mode.md +8 -0
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +6 -0
  46. package/src/module-api.js +1 -1
  47. package/tests/craft_mode_looks.mjs +170 -0
  48. package/tests/craft_mode_nav.mjs +7 -5
  49. package/tests/hall_tweak_editor.mjs +1 -1
@@ -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.37",
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.37",
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.37",
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,11 @@
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
+ }
8983
8989
  ]
8984
8990
  }
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.37'; // 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
+ });
@@ -4,7 +4,7 @@
4
4
  // Each craft mode shows only the tabs its craft needs; Legacy mode and no mode
5
5
  // show every tab, exactly as before. The owner's rule (2026-10-01): some tabs
6
6
  // stay in EVERY mode, Builders first among them, written down as ONE shared list
7
- // that each mode's list adds to. These cases boot the REAL shell-trail.js +
7
+ // that each mode's list adds to; on 2026-10-03 Collab and Profile joined it. These cases boot the REAL shell-trail.js +
8
8
  // craft-mode.js + shell.js and read the sidebar the shell actually rendered, so a
9
9
  // tab id that drifts from NAV, or a gate pass that runs after the mode and
10
10
  // re-shows a tab, reds here.
@@ -126,13 +126,15 @@ function boot({ session = {}, perms = { 'board.vote.cast': true } } = {}) {
126
126
 
127
127
  const CRAFTS = ['building', 'artistry', 'ideating', 'governing'];
128
128
 
129
- test('THE ALWAYS-PRESENT LIST: one shared list, Builders first, inside every mode', () => {
129
+ test('THE ALWAYS-PRESENT LIST: one shared list, Builders first, then Collab and Profile, inside every mode', () => {
130
130
  const { craft } = boot();
131
- assert.equal(craft.ALWAYS_TABS[0], 'roster', 'Builders (NAV id roster) heads the list (owner, 2026-10-01)');
131
+ assert.deepEqual([...craft.ALWAYS_TABS], ['roster', 'collab', 'profile'],
132
+ 'Builders (NAV id roster) heads the list (owner, 2026-10-01); Collab and Profile join it (owner, 2026-10-03)');
132
133
  for (const m of CRAFTS) {
133
134
  const tabs = craft.tabsFor(m);
134
135
  assert.ok(Array.isArray(tabs), `${m} has a named tab list`);
135
136
  for (const id of craft.ALWAYS_TABS) assert.ok(tabs.includes(id), `${m} keeps ${id}`);
137
+ assert.equal(new Set(tabs).size, tabs.length, `${m} names no tab twice`);
136
138
  }
137
139
  assert.match(CRAFT, /return own \? ALWAYS_TABS\.concat\(own\) : null;/, 'each list is ALWAYS_TABS plus its own, not a copy of it');
138
140
  });
@@ -173,7 +175,7 @@ test('each mode shows its own tabs, and switching updates the nav live', () => {
173
175
  p.craft.choose(m);
174
176
  assert.equal(p.root.dataset.craft, m);
175
177
  assert.deepEqual(new Set(p.showing()), new Set(p.craft.tabsFor(m)), `${m} shows exactly its list`);
176
- assert.ok(p.showing().includes('roster'), `Builders stays in ${m}`);
178
+ for (const id of ['roster', 'collab', 'profile']) assert.ok(p.showing().includes(id), `${id} stays in ${m}`);
177
179
  assert.equal(p.headShowing('government'), m === 'governing', `the Government head shows only where its tabs do (${m})`);
178
180
  }
179
181
  p.craft.choose('legacy');
@@ -185,7 +187,7 @@ test('a mode never shows a tab the gate hid, and a later gate pass keeps the mod
185
187
  p.signIn('thetes');
186
188
  assert.ok(!p.showing().includes('watch'), 'a thetes in Governing does not get the metic-floored Watch tab');
187
189
  assert.ok(!p.headShowing('government'), 'nor its group head');
188
- assert.deepEqual(p.showing(), ['blockers', 'board-room', 'roster'], 'what a thetes board member in Governing sees');
190
+ assert.deepEqual(p.showing(), ['blockers', 'board-room', 'profile', 'collab', 'roster'], 'what a thetes board member in Governing sees');
189
191
  p.signIn('archon');
190
192
  assert.ok(p.showing().includes('watch'), 'promoted, the gate lets it through and the mode keeps it');
191
193
  assert.ok(p.headShowing('government'), 'and its group head comes back with it, which only a mode pass after the gate can know');
@@ -635,7 +635,7 @@ try {
635
635
  assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
636
636
  assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
637
637
  assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
638
- assert.match(HTML, /shell\.js\?v=2026-10-03-nav/);
638
+ assert.match(HTML, /shell\.js\?v=2026-10-03-looks/);
639
639
  assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
640
640
  for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
641
641
  assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);