@bongos/core 1.24.80 → 1.24.82

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 (123) hide show
  1. package/.bongos-core.json +154 -124
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +5 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0402-helm-is-the-halls-look-and-the-hall-is-dark-only.md +142 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +86 -2
  10. package/docs/api-reference.md +4 -2
  11. package/docs/copy-inventory.md +51 -50
  12. package/docs/copy-registry.json +121 -123
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +2 -3
  15. package/docs/page-readings.json +226 -220
  16. package/modules/builder-settings/hall-view-prefs.js +50 -0
  17. package/modules/builder-settings/routes/settings.js +32 -0
  18. package/modules/hall-ui/public/ai-studio.html +5 -3
  19. package/modules/hall-ui/public/ai-studio.states.json +1 -0
  20. package/modules/hall-ui/public/atlas.html +3 -3
  21. package/modules/hall-ui/public/atlas.states.json +1 -0
  22. package/modules/hall-ui/public/blockers.html +3 -3
  23. package/modules/hall-ui/public/board-room.html +3 -3
  24. package/modules/hall-ui/public/board-room.states.json +1 -0
  25. package/modules/hall-ui/public/city.css +1 -3
  26. package/modules/hall-ui/public/city.html +6 -9
  27. package/modules/hall-ui/public/city.js +10 -55
  28. package/modules/hall-ui/public/city.states.json +7 -32
  29. package/modules/hall-ui/public/collab.html +3 -3
  30. package/modules/hall-ui/public/collab.states.json +1 -0
  31. package/modules/hall-ui/public/commands.html +3 -3
  32. package/modules/hall-ui/public/commands.states.json +1 -0
  33. package/modules/hall-ui/public/controls.css +63 -11
  34. package/modules/hall-ui/public/copy-desk.html +3 -3
  35. package/modules/hall-ui/public/copy-desk.states.json +1 -0
  36. package/modules/hall-ui/public/deploy.html +3 -3
  37. package/modules/hall-ui/public/deploy.states.json +1 -0
  38. package/modules/hall-ui/public/goal-map.html +4 -4
  39. package/modules/hall-ui/public/goals.html +3 -3
  40. package/modules/hall-ui/public/goals.states.json +1 -0
  41. package/modules/hall-ui/public/government.html +3 -3
  42. package/modules/hall-ui/public/government.states.json +1 -0
  43. package/modules/hall-ui/public/idea.html +3 -3
  44. package/modules/hall-ui/public/idea.states.json +1 -0
  45. package/modules/hall-ui/public/ideas.html +3 -3
  46. package/modules/hall-ui/public/ideas.states.json +1 -0
  47. package/modules/hall-ui/public/index-founding.states.json +1 -0
  48. package/modules/hall-ui/public/index-genesis-decide.states.json +1 -0
  49. package/modules/hall-ui/public/index-genesis-demo.states.json +1 -0
  50. package/modules/hall-ui/public/index-genesis.states.json +1 -0
  51. package/modules/hall-ui/public/index.html +5 -5
  52. package/modules/hall-ui/public/index.states.json +1 -0
  53. package/modules/hall-ui/public/market-watch-item.html +3 -3
  54. package/modules/hall-ui/public/market-watch-item.states.json +1 -0
  55. package/modules/hall-ui/public/market-watch-rules.html +3 -3
  56. package/modules/hall-ui/public/market-watch-rules.states.json +1 -0
  57. package/modules/hall-ui/public/market-watch.html +3 -3
  58. package/modules/hall-ui/public/market-watch.states.json +1 -0
  59. package/modules/hall-ui/public/modules.html +3 -3
  60. package/modules/hall-ui/public/modules.states.json +1 -0
  61. package/modules/hall-ui/public/money.html +3 -3
  62. package/modules/hall-ui/public/money.states.json +1 -0
  63. package/modules/hall-ui/public/my-agents.html +3 -3
  64. package/modules/hall-ui/public/my-agents.states.json +1 -0
  65. package/modules/hall-ui/public/options-menu.js +2 -1
  66. package/modules/hall-ui/public/primer.html +3 -3
  67. package/modules/hall-ui/public/primer.states.json +1 -0
  68. package/modules/hall-ui/public/profile.html +3 -3
  69. package/modules/hall-ui/public/profile.states.json +1 -0
  70. package/modules/hall-ui/public/project-settings.html +3 -3
  71. package/modules/hall-ui/public/roster.html +3 -3
  72. package/modules/hall-ui/public/roster.states.json +1 -0
  73. package/modules/hall-ui/public/settings-view.js +75 -0
  74. package/modules/hall-ui/public/settings.html +17 -4
  75. package/modules/hall-ui/public/settings.states.json +1 -0
  76. package/modules/hall-ui/public/shell-game.css +97 -42
  77. package/modules/hall-ui/public/shell-game.js +138 -40
  78. package/modules/hall-ui/public/shell.js +12 -43
  79. package/modules/hall-ui/public/street-card.states.json +1 -0
  80. package/modules/hall-ui/public/street.css +1 -5
  81. package/modules/hall-ui/public/street.html +5 -9
  82. package/modules/hall-ui/public/street.js +8 -59
  83. package/modules/hall-ui/public/street.states.json +7 -32
  84. package/modules/hall-ui/public/studio.html +3 -3
  85. package/modules/hall-ui/public/studio.states.json +1 -0
  86. package/modules/hall-ui/public/style.css +126 -65
  87. package/modules/hall-ui/public/task.html +3 -3
  88. package/modules/hall-ui/public/task.states.json +1 -0
  89. package/modules/hall-ui/public/thinking.html +4 -4
  90. package/modules/hall-ui/public/thinking.states.json +1 -0
  91. package/modules/hall-ui/public/tweak-editor.html +3 -3
  92. package/modules/hall-ui/public/tweak-editor.states.json +1 -0
  93. package/modules/hall-ui/public/visor.js +7 -1
  94. package/modules/hall-ui/public/watch.html +3 -3
  95. package/modules/hall-ui/public/watch.states.json +1 -0
  96. package/modules/hall-ui/public/work-founding.states.json +1 -0
  97. package/modules/hall-ui/public/work.html +3 -3
  98. package/modules/hall-ui/public/work.states.json +1 -0
  99. package/modules/hall-ui/records/game-mode.md +5 -4
  100. package/modules/hall-ui/records/start-screen.md +10 -2
  101. package/modules/hall-ui/specimens/components.states.json +2 -1
  102. package/modules/ui-design/public/brand.html +2 -2
  103. package/package-lock.json +2 -2
  104. package/package.json +1 -1
  105. package/release-notes.json +12 -0
  106. package/scripts/hall-preview/README.md +2 -3
  107. package/scripts/hall-preview/pref-writes.js +8 -1
  108. package/src/bongos/serve-internal.js +7 -2
  109. package/src/hall-helm.js +79 -0
  110. package/src/module-api.js +1 -1
  111. package/tests/design_reference.mjs +1 -1
  112. package/tests/hall_builders_page_pass.mjs +2 -1
  113. package/tests/hall_city.mjs +15 -29
  114. package/tests/hall_controls.mjs +52 -4
  115. package/tests/hall_fullscreen.mjs +42 -21
  116. package/tests/hall_helm.mjs +187 -0
  117. package/tests/hall_record_world.mjs +4 -2
  118. package/tests/hall_start_screen.mjs +103 -5
  119. package/tests/hall_street.mjs +13 -26
  120. package/tests/hall_topbar_320.mjs +4 -2
  121. package/tests/hall_view_prefs.mjs +42 -0
  122. package/tests/hall_visor.mjs +3 -2
  123. package/tests/nav_permission_atoms.mjs +1 -1
@@ -94,7 +94,8 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
94
94
  get cookie() { return ''; }, set cookie(_v) {},
95
95
  };
96
96
  const assigned = []; // task 1005144: where the page sent the browser (game mode's Escape opens the menu)
97
- const location = { hostname: 'h', origin: 'https://h', href: 'https://h/' + page + search, pathname: path || '/' + page, hash, search, assign(u) { assigned.push(u); }, replace() {} };
97
+ const replaced = []; // task 1005200: a classic page in game mode sends the browser to the city
98
+ const location = { hostname: 'h', origin: 'https://h', href: 'https://h/' + page + search, pathname: path || '/' + page, hash, search, assign(u) { assigned.push(u); }, replace(u) { replaced.push(u); } };
98
99
  // `session` is the TAB's sessionStorage: pass one object to several boots and
99
100
  // they are page loads in one tab (the trail, task 1004476).
100
101
  const sessionStorage = {
@@ -135,6 +136,7 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
135
136
  return {
136
137
  store,
137
138
  assigned,
139
+ replaced,
138
140
  game: () => windowObj.OTBShellGame,
139
141
  gameStamp: () => root.dataset.game === 'on',
140
142
  // The "how to leave" line: showing = the shell created it and it is not hidden.
@@ -517,17 +519,36 @@ test('game mode: every hall page carries the switch — none opts out, and each
517
519
  }
518
520
  });
519
521
 
520
- test('game mode: leaving brings the bars back for THIS page only, and never rewrites a page\'s own memory', () => {
522
+ // task 1005200 (owner, 2026-11-11): game OR classic, never both. Inside game mode there is
523
+ // no way back to the bars — the corner button is hidden (style.css) and, pressed
524
+ // anyway, does nothing; Escape is the options menu's, where the view is switched.
525
+ test('game mode: there is no way back to the bars inside it, and a page\'s own memory is never rewritten', () => {
521
526
  const store = { 'hall-game': 'on', 'hall-fullscreen:thinking': 'on' };
522
- const goals = boot({ page: 'goals', optIn: false, store });
523
- goals.button().click();
524
- assert.equal(goals.on(), false, 'the corner button still leaves (Escape opens the options menu, below)');
525
- assert.equal(store['hall-fullscreen:goals'], undefined, 'nothing remembered for the page');
526
- assert.equal(boot({ page: 'goals', optIn: false, store }).on(), true, 'the next load opens without bars again');
527
+ const profile = boot({ page: 'profile', optIn: false, store });
528
+ profile.button().click();
529
+ assert.equal(profile.on(), true, 'the corner button does not leave game mode');
530
+ assert.equal(store['hall-fullscreen:profile'], undefined, 'nothing remembered for the page');
527
531
  const sky = boot({ page: 'thinking', store });
528
532
  sky.button().click();
529
- assert.equal(sky.on(), false, 'the corner button leaves too');
533
+ sky.press('Escape');
534
+ assert.equal(sky.on(), true, 'nor does Escape');
530
535
  assert.equal(store['hall-fullscreen:thinking'], 'on', 'the sky\'s own choice survives game mode');
536
+ assert.match(read('style.css'), /html\[data-game="on"\] \.fullscreen-toggle \{ display: none; \}/, 'and the button is not drawn');
537
+ });
538
+
539
+ test('game mode: only the game\'s places are there — a classic page sends the browser to the city', () => {
540
+ const store = { 'hall-game': 'on' };
541
+ assert.deepEqual(boot({ page: 'goals', optIn: false, store }).replaced, ['/'], 'Goals is a classic page');
542
+ assert.deepEqual(boot({ page: 'work', optIn: false, store, path: '/builders/work' }).replaced, ['/builders/'], 'on the apex too');
543
+ for (const page of ['home', 'city', 'goal-map', 'thinking', 'atlas', 'settings', 'profile']) {
544
+ assert.deepEqual(boot({ page, optIn: false, store }).replaced, [], `${page} is a game place`);
545
+ }
546
+ assert.deepEqual(boot({ page: 'goals', optIn: false, store: {} }).replaced, [], 'classic reaches every page');
547
+ const G = boot({ page: 'city', store }).game();
548
+ assert.equal(G.isPlace('/city'), true);
549
+ assert.equal(G.isPlace('/builders/'), true);
550
+ assert.equal(G.isPlace('/work'), false);
551
+ assert.equal(G.isPlace('/blockers?x=1'), false);
531
552
  });
532
553
 
533
554
  // task 1005144 (BONGOS-V3 R04, ADR 0399 §2.4 row 6): in game mode the last Escape is the
@@ -562,35 +583,35 @@ test('game mode: the Escape hand-off is unchanged — a press the page used, or
562
583
 
563
584
  test('game mode: set() switches the page live, and turning it off hands each page back its own memory', () => {
564
585
  const store = { 'hall-fullscreen:thinking': 'on' };
565
- const goals = boot({ page: 'goals', optIn: false, store });
586
+ const goals = boot({ page: 'profile', optIn: false, store });
566
587
  goals.game().set(true);
567
588
  assert.equal(store['hall-game'], 'on');
568
589
  assert.equal(goals.on(), true, 'on at once, no reload');
569
590
  assert.ok(goals.resizes() >= 1, 'a page that sizes to its box is told to re-measure');
570
591
  goals.game().set(false);
571
- assert.equal(goals.on(), false, 'goals remembered nothing, so its bars return');
592
+ assert.equal(goals.on(), false, 'the profile remembered nothing, so its bars return');
572
593
  assert.equal(goals.gameStamp(), false);
573
- assert.equal(store['hall-fullscreen:goals'], undefined, 'and switching the mode wrote nothing for the page');
594
+ assert.equal(store['hall-fullscreen:profile'], undefined, 'and switching the mode wrote nothing for the page');
595
+ const work = boot({ page: 'work', optIn: false, store: {} });
596
+ work.game().set(true);
597
+ assert.deepEqual(work.replaced, ['/'], 'switched to game on a classic page: to the city');
574
598
  const sky = boot({ page: 'thinking', store: Object.assign(store, { 'hall-game': 'on' }) });
575
599
  sky.game().set(false);
576
600
  assert.equal(sky.on(), true, 'the sky had chosen full screen itself, and keeps it');
577
601
  });
578
602
 
579
- test('game mode: ?game=on / ?game=off set it from the URL; anything else is ignored', () => {
603
+ test('game mode: the address does not switch it — Options and the sign-in chooser do (task 1005200)', () => {
580
604
  const store = {};
581
- assert.equal(boot({ page: 'work', optIn: false, store, search: '?game=on' }).on(), true);
582
- assert.equal(store['hall-game'], 'on');
583
- assert.equal(boot({ page: 'goals', optIn: false, store }).on(), true, 'and it holds on the next page');
584
- assert.equal(boot({ page: 'work', optIn: false, store, search: '?game=off' }).on(), false);
585
- boot({ page: 'work', optIn: false, store, search: '?game=%22%3E%3Cimg%3E' });
586
- assert.equal(store['hall-game'], 'off', 'a value that is not on/off is not stored');
605
+ assert.equal(boot({ page: 'profile', optIn: false, store, search: '?game=on' }).on(), false);
606
+ assert.equal(store['hall-game'], undefined, 'nothing stored from the URL');
607
+ assert.doesNotMatch(read('shell-game.js'), /get\('game'\)/, 'the ?game= reader is gone');
587
608
  });
588
609
 
589
- test('game mode: how to leave is said once per tab, not on every page; outside it the shell\'s rule stands', () => {
610
+ test('game mode never says how to leave full screen (there is no leaving); outside it the shell\'s rule stands', () => {
590
611
  const store = { 'hall-game': 'on' };
591
612
  const session = {};
592
- assert.equal(boot({ page: 'goals', optIn: false, store, session }).hintShowing(), true, 'the first page says it');
593
- assert.equal(boot({ page: 'work', optIn: false, store, session }).hintShowing(), false, 'the next does not repeat it');
613
+ assert.equal(boot({ page: 'profile', optIn: false, store, session }).hintShowing(), false, 'no line in game mode');
614
+ assert.equal(boot({ page: 'city', store, session }).hintShowing(), false);
594
615
  const own = { 'hall-fullscreen:thinking': 'on' };
595
616
  assert.equal(boot({ page: 'thinking', store: own, session: {} }).hintShowing(), true);
596
617
  assert.equal(boot({ page: 'thinking', store: own, session: {} }).hintShowing(), true, 'a remembered page says it on every open, as before');
@@ -0,0 +1,187 @@
1
+ // tests/hall_helm.mjs — Helm, the look the builders' hall wears (task 1005192, ADR 0402).
2
+ //
3
+ // WHY. Two owner decisions are easy to break without anyone seeing it on their own
4
+ // machine: Helm is the hall's alone (the landing, the hub and the status page keep
5
+ // their look), and the hall is dark only (no page offers light, and a light OS never
6
+ // flashes it). Both are decided in one serve-time seam, src/hall-helm.js, which
7
+ // recognises a hall page by the sheet it links. This file runs that seam over the
8
+ // real pages, and holds the pieces of the kit a browser would hide:
9
+ // * every hall page is served with html[data-theme="dark"], and nothing else is;
10
+ // * the Helm faces reach a hall whose pack names the default faces, and never a
11
+ // hall that chose its own, and never the landing or the status page;
12
+ // * the font stylesheet loads the way tests/google_fonts_nonblocking.mjs asks of
13
+ // a typed-in link (both preconnects on the page, display=swap), which that sweep
14
+ // cannot see because the link is built at serve time;
15
+ // * no hall script writes a theme choice, and the shell offers no switch;
16
+ // * the Helm neutrals: the two dark twins carry the night, the ice and no black;
17
+ // * the kit's recipes exist once, as hall-tier tokens and shared rules.
18
+ //
19
+ // Run: node --test tests/hall_helm.mjs
20
+ import { test } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { createRequire } from 'node:module';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
30
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
31
+ const decomment = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
32
+
33
+ const { applyHelm, isHallPage, wearsHelmFaces, stampHtml, HELM_FONTS_HREF } = require('../src/hall-helm.js');
34
+ const { injectBrandingGlobal } = require('../src/bongos/serve-internal.js');
35
+ const { clientBranding, resolveBranding } = require('../src/branding.js');
36
+
37
+ const NEUTRAL = JSON.parse(read('config', 'branding.neutral.json'));
38
+ const DEFAULTS = NEUTRAL.theme.ui.fonts;
39
+ const hallPages = fs.readdirSync(HALL).filter((f) => f.endsWith('.html'));
40
+ const htmlTag = (html) => (html.match(/<html\b[^>]*>/i) || [''])[0];
41
+
42
+ // ── the seam ────────────────────────────────────────────────────────────────
43
+
44
+ test('a hall page is the one that links the hall sheet; the landing, the hub and the status page are not', () => {
45
+ for (const f of hallPages) assert.ok(isHallPage(read('modules', 'hall-ui', 'public', f)), `${f} links the hall's sheet`);
46
+ for (const p of [['modules', 'public-landing', 'public', 'index.html'], ['modules', 'public-landing', 'public', 'projects.html'], ['modules', 'status-ui', 'public', 'index.html'], ['modules', 'ui-design', 'public', 'brand.html']]) {
47
+ assert.equal(isHallPage(read(...p)), false, `${p.join('/')} is not a hall page`);
48
+ }
49
+ });
50
+
51
+ test('every hall page is served pinned dark, in the Helm faces, and no other surface is touched', () => {
52
+ const brand = clientBranding(resolveBranding({ neutral: NEUTRAL }));
53
+ for (const f of hallPages) {
54
+ const out = injectBrandingGlobal(read('modules', 'hall-ui', 'public', f), brand, {});
55
+ const tag = htmlTag(out);
56
+ assert.match(tag, / data-theme="dark"/, `${f}: dark before first paint`);
57
+ assert.match(tag, / data-faces="helm"/, `${f}: the Helm faces`);
58
+ assert.equal((tag.match(/data-theme=/g) || []).length, 1, `${f}: stamped once`);
59
+ assert.ok(out.includes(`href="${HELM_FONTS_HREF.replace(/&/g, '&amp;')}"`), `${f}: the two families are linked`);
60
+ // the link follows the pack's own splice, so it is the last word in <head>
61
+ assert.ok(out.indexOf('Barlow') > out.indexOf('--font-display:'), `${f}: after the pack's :root`);
62
+ }
63
+ for (const p of [['modules', 'public-landing', 'public', 'index.html'], ['modules', 'status-ui', 'public', 'index.html']]) {
64
+ const src = read(...p);
65
+ const out = injectBrandingGlobal(src, brand, {});
66
+ assert.equal(htmlTag(out), htmlTag(src), `${p.join('/')}: its <html> tag is as authored`);
67
+ assert.doesNotMatch(out, /Barlow|data-faces/, `${p.join('/')}: no Helm face`);
68
+ }
69
+ });
70
+
71
+ test('a hall that wears faces of its own keeps them; it is still dark only', () => {
72
+ const page = '<!doctype html><html lang="en"><head><link rel="stylesheet" href="/builders/style.css?v=1"></head><body></body></html>';
73
+ const own = { fonts: { display: '"Archivo", sans-serif', body: DEFAULTS.body, links: [] } };
74
+ assert.equal(wearsHelmFaces(own, DEFAULTS), false, 'one face of its own is a pack that chose');
75
+ const kept = applyHelm(page, own, DEFAULTS);
76
+ assert.match(htmlTag(kept.html), /data-theme="dark"/);
77
+ assert.doesNotMatch(kept.html, /data-faces/);
78
+ assert.equal(kept.tail, '', 'and no Helm stylesheet is linked');
79
+ // the default faces, and a pack that names none, take Helm
80
+ assert.equal(wearsHelmFaces({ fonts: { display: DEFAULTS.display, body: DEFAULTS.body } }, DEFAULTS), true);
81
+ assert.equal(wearsHelmFaces({}, DEFAULTS), true);
82
+ // a look from the style library with its own faces (ADR 0358) keeps them through the real resolver
83
+ const looked = clientBranding(resolveBranding({ neutral: NEUTRAL, env: { BONGOS_LOOK: 'expedition' } }));
84
+ if (looked.theme.look === 'expedition') {
85
+ const out = injectBrandingGlobal(page, looked, {});
86
+ assert.match(htmlTag(out), /data-theme="dark"/);
87
+ assert.doesNotMatch(out, /data-faces|Barlow/, 'the expedition look keeps Archivo');
88
+ }
89
+ });
90
+
91
+ test('the stamp replaces an attribute already there and leaves the others alone', () => {
92
+ assert.equal(stampHtml('<html lang="en" data-artist="on">', 'data-theme', 'dark'), '<html lang="en" data-artist="on" data-theme="dark">');
93
+ assert.equal(stampHtml('<html data-theme="light" lang="en">', 'data-theme', 'dark'), '<html lang="en" data-theme="dark">');
94
+ assert.equal(applyHelm('<html><head></head></html>', {}, DEFAULTS).html, '<html><head></head></html>', 'a page with no hall sheet is untouched');
95
+ });
96
+
97
+ test('the Helm stylesheet loads without blocking: swap, and both preconnects on every hall page', () => {
98
+ assert.match(HELM_FONTS_HREF, /^https:\/\/fonts\.googleapis\.com\/css2\?/);
99
+ assert.match(HELM_FONTS_HREF, /[?&]display=swap$/);
100
+ assert.match(HELM_FONTS_HREF, /family=Barlow\+Condensed:wght@400;500;600/);
101
+ assert.match(HELM_FONTS_HREF, /family=Barlow:wght@400;500/);
102
+ const missing = [];
103
+ for (const f of hallPages) {
104
+ const html = read('modules', 'hall-ui', 'public', f);
105
+ if (!/<link rel="preconnect" href="https:\/\/fonts\.googleapis\.com">/.test(html)) missing.push(`${f}: fonts.googleapis.com`);
106
+ if (!/<link rel="preconnect" href="https:\/\/fonts\.gstatic\.com" crossorigin>/.test(html)) missing.push(`${f}: fonts.gstatic.com (crossorigin)`);
107
+ }
108
+ assert.deepEqual(missing, [], 'a hall page that links a web font warms both font origins first');
109
+ });
110
+
111
+ // ── dark only ───────────────────────────────────────────────────────────────
112
+
113
+ test('no hall script stores or offers a theme, and the shell pins dark', () => {
114
+ const shell = read('modules', 'hall-ui', 'public', 'shell.js');
115
+ assert.match(shell, /function applyTheme\(\) \{\s*document\.documentElement\.dataset\.theme = 'dark';\s*\}/, 'the shell sets dark, whatever was stored');
116
+ assert.doesNotMatch(shell, /theme-toggle|toggleTheme/, 'no switch on the top bar');
117
+ const writers = [];
118
+ for (const f of fs.readdirSync(HALL).filter((x) => x.endsWith('.js'))) {
119
+ const src = read('modules', 'hall-ui', 'public', f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');
120
+ if (/['"]hall-theme['"]/.test(src)) writers.push(`${f}: names the retired 'hall-theme' preference`);
121
+ if (/dataset\.theme\s*=\s*(?![\s=]|'dark')|setAttribute\(\s*['"]data-theme['"]\s*,\s*(?!\s|'dark')/.test(src)) writers.push(`${f}: writes a theme other than dark`);
122
+ }
123
+ assert.deepEqual(writers, []);
124
+ for (const f of hallPages) assert.doesNotMatch(read('modules', 'hall-ui', 'public', f), /id="(theme-toggle|cityMode|streetMode)"/, `${f}: no light/dark switch`);
125
+ assert.doesNotMatch(decomment(read('modules', 'hall-ui', 'public', 'style.css')), /\.theme-toggle/, 'and no styles for one');
126
+ });
127
+
128
+ // ── the Helm tier ───────────────────────────────────────────────────────────
129
+
130
+ const STYLE = read('modules', 'hall-ui', 'public', 'style.css');
131
+ const blockAt = (marker) => {
132
+ const at = STYLE.indexOf(marker);
133
+ assert.notEqual(at, -1, `style.css holds ${marker}`);
134
+ const open = STYLE.indexOf('{', at);
135
+ let depth = 0;
136
+ for (let i = open; i < STYLE.length; i++) {
137
+ if (STYLE[i] === '{') depth++;
138
+ else if (STYLE[i] === '}' && --depth === 0) return STYLE.slice(open + 1, i);
139
+ }
140
+ throw new Error('unbalanced');
141
+ };
142
+ const decls = (body) => Object.fromEntries([...decomment(body).matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].replace(/\s+/g, ' ').trim()]));
143
+ const root = decls(blockAt('\n:root {'));
144
+ const dark = decls(blockAt('html[data-theme="dark"] {'));
145
+
146
+ test('the dark tier is the Helm neutrals: night grounds, white and ice inks, ice hairlines', () => {
147
+ assert.deepEqual(
148
+ Object.fromEntries(['--bg', '--bg-card', '--bg-deep', '--bg-frame', '--ink', '--ink-soft', '--rule', '--rule-soft', '--void'].map((k) => [k, dark[k]])),
149
+ { '--bg': '#050d16', '--bg-card': '#0a1622', '--bg-deep': '#050d16', '--bg-frame': '#07111b', '--ink': '#ffffff', '--ink-soft': '#c8f1f7', '--rule': 'rgba(200, 241, 247, .4)', '--rule-soft': 'rgba(200, 241, 247, .2)', '--void': '#050d16' },
150
+ );
151
+ // the accent is still the pack's: the moon is never pinned (the Unpinned Accent Rule)
152
+ for (const name of ['--accent', '--accent-deep', '--sea', '--terracotta', '--font-display', '--font-body']) assert.equal(dark[name], undefined, `${name} is not set in the dark tier`);
153
+ // in dark a label is the name voice
154
+ assert.equal(dark['--label-face'], 'var(--font-display)');
155
+ });
156
+
157
+ test('the Helm faces are hall-tier tokens, handed to the two font tokens only under html[data-faces="helm"]', () => {
158
+ assert.match(root['--helm-display'], /^"Barlow Condensed", /);
159
+ assert.match(root['--helm-body'], /^"Barlow", /);
160
+ // the contract tier still equals the pack (tests/hall_tokens.mjs holds that); Helm does not overwrite it there
161
+ assert.equal(root['--font-display'], DEFAULTS.display);
162
+ const faces = decls(blockAt('\nhtml[data-faces="helm"] {'));
163
+ assert.deepEqual(faces, { '--font-display': 'var(--helm-display)', '--font-body': 'var(--helm-body)' });
164
+ assert.equal((decomment(STYLE).match(/Barlow/g) || []).length, 2, 'the family names live once each, in the token block');
165
+ for (const f of fs.readdirSync(HALL).filter((x) => x.endsWith('.css') && x !== 'style.css')) {
166
+ assert.doesNotMatch(read('modules', 'hall-ui', 'public', f), /Barlow/, `${f} takes the face from the tokens`);
167
+ }
168
+ });
169
+
170
+ test('the kit\'s recipes are named once: the name voice, the cut, the segment, the panel over art', () => {
171
+ assert.equal(root['--name-face'], 'var(--font-display)');
172
+ assert.equal(root['--name-case'], 'uppercase');
173
+ for (const k of ['--name-space', '--name-space-wide', '--name-space-tight']) {
174
+ const em = Number((root[k].match(/^(\.\d+)em$/) || [])[1]);
175
+ assert.ok(em >= 0.16 && em <= 0.3, `${k} is wide tracking (${root[k]})`);
176
+ }
177
+ assert.equal(root['--cut'], '12px');
178
+ assert.equal(root['--seg-skew'], '-28deg');
179
+ assert.equal(root['--night-glass'], 'color-mix(in srgb, var(--bg) 72%, transparent)');
180
+ const controls = decomment(read('modules', 'hall-ui', 'public', 'controls.css'));
181
+ const seg = controls.match(/\n\.seg \{([^}]*)\}/);
182
+ assert.ok(seg, 'the segment is a shared rule');
183
+ assert.match(seg[1], /transform: skewX\(var\(--seg-skew\)\);/);
184
+ assert.match(seg[1], /height: var\(--seg-h\);/);
185
+ assert.match(controls, /\n\.seg--done \{ background: var\(--ink-soft\);/, 'a finished step is information, so it is ice, not the accent');
186
+ assert.match(controls, /\n\.seg--now \{ background: var\(--accent\);/, 'the accent is the step you can act on');
187
+ });
@@ -152,14 +152,16 @@ test('.btn and .btn--ghost are defined, and say what .btn-accent / .btn-ghost sa
152
152
  assert.match(btn, /background:\s*var\(--accent\)/, '.btn is the accent door');
153
153
  assert.match(btn, /color:\s*var\(--accent-on\)/, '--accent-on is the dark ink for the salmon');
154
154
  const shape = ruleHolding(controlsCss, '.btn', 'border-radius');
155
- assert.match(shape, /border-radius:\s*var\(--radius-pill\)/, 'pill or nothing (DESIGN.md, Shapes)');
155
+ // Helm (task 1005192, ADR 0402): the hall's corner is the cut; the pill is the fallback where the hall's tier is not loaded
156
+ assert.match(shape, /border-radius:\s*var\(--press-radius, var\(--radius-pill\)\)/, 'the cut corner in the hall, the pill elsewhere (DESIGN.md, The hall)');
157
+ assert.match(shape, /clip-path:\s*var\(--cut-path, none\)/, 'anything you can press takes the cut corner');
156
158
  // collab.css's .btn--quiet sets only a border-COLOUR; a modifier cannot paint
157
159
  // an edge that was never reserved, so the base has to carry a transparent one.
158
160
  assert.match(shape, /border:\s*var\(--line\)\s+solid\s+transparent/,
159
161
  'the edge is reserved so a modifier that sets only border-color still draws');
160
162
  const ghost = ruleHolding(controlsCss, '.btn--ghost', 'background');
161
163
  assert.ok(ghost, '.btn--ghost has a rule');
162
- assert.match(ghost, /background:\s*transparent/, 'the quiet sibling is a hairline pill, not a fill');
164
+ assert.match(ghost, /background:\s*var\(--press-fill, transparent\)/, 'the quiet sibling is the press fill inside a hairline, never the accent');
163
165
  assert.equal(ghost, ruleHolding(controlsCss, '.btn-ghost', 'background'), 'and .btn-ghost says the same, in the same rule');
164
166
  assert.match(ghost, /border-color:\s*var\(--rule\)/, 'and it takes the reserved edge');
165
167
  });
@@ -82,6 +82,16 @@ test('the tips rotate, and every one is a sentence', () => {
82
82
  for (const t of G.TIPS) assert.match(t, /^[A-Z].*\.$/);
83
83
  });
84
84
 
85
+ test('the load screen fills one segment per week, up to the week it is showing (task 1005192)', () => {
86
+ const { window } = sandbox();
87
+ const G = window.OTBShellGame;
88
+ assert.equal(G.segsDone(10, 0), 1, 'the oldest week is the first segment');
89
+ assert.equal(G.segsDone(10, 4.5), 5);
90
+ assert.equal(G.segsDone(10, 9), 10, 'now fills the row');
91
+ assert.equal(G.segsDone(10, 99), 10, 'never more than there are weeks');
92
+ assert.equal(G.segsDone(0, 0), 0);
93
+ });
94
+
85
95
  test('a fresh sign-in is handed to the start screen with the still, and the craft picker waits for the world', () => {
86
96
  const s = sandbox({ store: { 'hall-welcome-signin': '1' } });
87
97
  const access = { authed: true }, builder = { id: 7, display_name: 'Ada' };
@@ -113,6 +123,13 @@ test('Home loads the sheet and the script, and the sheet types in no size, corne
113
123
  assert.doesNotMatch(css, /border-radius:\s*[\d.]/);
114
124
  assert.doesNotMatch(css, /@media/);
115
125
  assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/, 'tokens, or black and white: no private palette');
126
+ // Helm (task 1005192, ADR 0402): the literal floor is zero, the voice and the panel are the hall tier's tokens
127
+ assert.doesNotMatch(css, /rgba?\(|hsla?\(|box-shadow:\s*[\d-]|text-shadow:\s*[\d-]|font-family:(?!\s*var\()/, 'no typed colour, shadow or face');
128
+ assert.match(css, /\.gs__title \{[^}]*font-family: var\(--name-face\);[^}]*letter-spacing: var\(--name-space-wide\); text-transform: var\(--name-case\);/, 'the title is the name voice');
129
+ assert.match(css, /\.gs__item \{[^}]*font-family: var\(--name-face\);[^}]*text-transform: var\(--name-case\);/, 'so is every choice');
130
+ assert.match(css, /\.gs__menu, \.gs__load \{[^}]*background: var\(--night-glass\);/, 'both panels are the night over the art');
131
+ assert.match(css, /\.gs__item\[data-gs="start"\] \{\s*background: var\(--accent\); color: var\(--accent-on\);/, 'Start is the one thing lit, under the dark ink');
132
+ assert.match(css, /\.gs__item:focus-visible \{[^}]*box-shadow: var\(--press-focus\);/, 'a row rings inside itself, in two tones');
116
133
  });
117
134
 
118
135
  // ---------- in a browser: the two screens over Home's own markup ----------
@@ -130,8 +147,8 @@ const SCRIPTS = ['dom-utils.js', 'welcome-boot.js', 'shell-game.js', 'shell-trai
130
147
  .map((f) => `<script src="/builders/${f}"></script>`).join('');
131
148
  const PAGE = INDEX.replace('</body>', STUB + SCRIPTS + '</body>');
132
149
 
133
- async function hall({ city = true, reducedMotion = 'no-preference' } = {}) {
134
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, reducedMotion });
150
+ async function hall({ city = true, reducedMotion = 'no-preference', viewport = { width: 1280, height: 800 } } = {}) {
151
+ const ctx = await browser.newContext({ viewport, reducedMotion });
135
152
  await ctx.route(ORIGIN + '/**', (route) => {
136
153
  const p = new URL(route.request().url()).pathname;
137
154
  if (p === '/' || p === '/index.html') return route.fulfill({ contentType: 'text/html', body: PAGE });
@@ -171,15 +188,45 @@ test('a sign-in passes through the start screen and the load screen before the w
171
188
  const { page, errors } = await signedIn(ctx);
172
189
  await page.waitForSelector('#game-start');
173
190
  assert.deepEqual(await state(page), { start: true, welcome: false, picker: false, game: null, full: null },
174
- 'the start screen, not the welcome and not the picker; game mode is not on until Start');
191
+ 'the start screen, not the welcome and not the picker; game mode is not on until it is chosen');
192
+ // task 1005200: first the chooser — game or classic — then, for game, the start screen.
193
+ const views = await page.evaluate(() => ({
194
+ items: Array.from(document.querySelectorAll('.gs__items:not([hidden]) .gs__item')).map((e) => e.textContent),
195
+ focus: document.activeElement.textContent,
196
+ }));
197
+ assert.deepEqual(views, { items: ['GameThe city, full screen', 'ClassicThe hall with its sidebar'], focus: 'GameThe city, full screen' });
198
+ await page.keyboard.press('Enter');
199
+ await page.waitForSelector('#gs-items:not([hidden])');
200
+ assert.equal((await state(page)).game, 'on', 'choosing Game turns game mode on');
175
201
  const menu = await page.evaluate(() => ({
176
202
  title: document.getElementById('gs-title').textContent,
177
- items: Array.from(document.querySelectorAll('.gs__items:not(.gs__items--extras) .gs__item')).map((e) => e.textContent),
203
+ items: Array.from(document.querySelectorAll('.gs__items:not(.gs__items--extras):not([hidden]) .gs__item')).map((e) => e.textContent),
178
204
  focus: document.activeElement.textContent,
179
- small: Array.from(document.querySelectorAll('.gs__items:not(.gs__items--extras) .gs__item')).filter((e) => e.getBoundingClientRect().height < 46).length,
205
+ small: Array.from(document.querySelectorAll('.gs__items:not(.gs__items--extras):not([hidden]) .gs__item')).filter((e) => e.getBoundingClientRect().height < 46).length,
180
206
  }));
181
207
  assert.deepEqual(menu, { title: 'Harbor', items: ['Start', 'Options', 'Achievements', 'Extras', 'Quit'], focus: 'Start', small: 0 });
182
208
 
209
+ // Helm (task 1005192): one full-height night panel on the left, the mark over the name, a line at the foot.
210
+ const panel = await page.evaluate(() => {
211
+ const box = (sel) => { const r = document.querySelector(sel).getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }; };
212
+ const start = document.querySelector('[data-gs="start"]'), title = document.getElementById('gs-title');
213
+ return {
214
+ menu: box('.gs__menu'), start: box('[data-gs="start"]'), view: { w: window.innerWidth, h: window.innerHeight },
215
+ mark: !!document.querySelector('.gs__head .gs__mark'), line: document.getElementById('gs-line').textContent,
216
+ caps: getComputedStyle(title).textTransform, startCaps: getComputedStyle(start).textTransform,
217
+ key: getComputedStyle(start, '::after').content, sideways: document.getElementById('game-start').scrollWidth - document.getElementById('game-start').clientWidth,
218
+ };
219
+ });
220
+ assert.equal(panel.menu.w, 400, 'the panel is 400 wide on a desk');
221
+ assert.ok(panel.menu.x > 0 && panel.menu.x < panel.view.w / 4, 'and stands on the left, off the edge');
222
+ assert.deepEqual([panel.menu.y, panel.menu.h], [0, panel.view.h], 'the full height of the screen');
223
+ assert.ok(panel.start.w >= panel.menu.w - 4, 'Start is a bar the width of the panel');
224
+ assert.equal(panel.mark, true, 'the mark stands over the name');
225
+ assert.match(panel.line, /^[A-Z].*\.$/, 'the foot carries one sentence');
226
+ assert.deepEqual([panel.caps, panel.startCaps], ['uppercase', 'uppercase'], 'names are capitals');
227
+ assert.equal(panel.key, '"Enter"', 'Start names the key that takes it');
228
+ assert.equal(panel.sideways, 0);
229
+
183
230
  // The arrows walk the menu; Escape stays with the screen and does not flip the shell underneath.
184
231
  await page.keyboard.press('ArrowDown');
185
232
  assert.equal(await page.evaluate(() => document.activeElement.textContent), 'Options');
@@ -201,7 +248,11 @@ test('a sign-in passes through the start screen and the load screen before the w
201
248
  assert.match(await page.textContent('#gs-tip'), /\.$/);
202
249
  await page.waitForFunction(() => /weeks? ago$/.test(document.getElementById('gs-when').textContent));
203
250
  assert.ok(await page.evaluate(() => document.querySelectorAll('#gs-city *').length > 20), 'the city is drawn, not a placeholder');
251
+ const weeks = JSON.parse(CITY).weeks.length;
252
+ assert.equal(await page.evaluate(() => document.querySelectorAll('#gs-segs:not([hidden]) > .seg').length), weeks, 'one segment per week of the history');
204
253
  await page.waitForFunction(() => { const w = document.getElementById('gs-when'); return !w || w.textContent === 'The city, now'; });
254
+ const segs = await page.evaluate(() => { const b = document.getElementById('gs-segs'); return b ? [b.querySelectorAll('.seg--done').length, b.getAttribute('aria-hidden')] : null; });
255
+ if (segs) assert.deepEqual(segs, [weeks, 'true'], 'at now every segment is filled; the words beside them say it, so the row is hidden from a screen reader');
205
256
  await page.waitForSelector('#game-start', { state: 'detached' });
206
257
  await page.waitForSelector('#craft-picker');
207
258
  assert.deepEqual(await state(page), { start: false, welcome: false, picker: true, game: 'on', full: 'on' },
@@ -211,11 +262,57 @@ test('a sign-in passes through the start screen and the load screen before the w
211
262
  await ctx.close();
212
263
  });
213
264
 
265
+ // task 1005200: Classic at the chooser — the screen goes, game mode is off, and the account
266
+ // is told. The view chosen last time is the one marked and focused.
267
+ test('choosing Classic at sign-in closes the screen, leaves game mode off, and saves it to the account', { skip }, async () => {
268
+ const ctx = await hall();
269
+ const saved = [];
270
+ await ctx.route(ORIGIN + '/api/bongos/me/hall-view', (route) => {
271
+ if (route.request().method() === 'PATCH') saved.push(JSON.parse(route.request().postData()));
272
+ return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ view: null }) });
273
+ });
274
+ const page = await ctx.newPage();
275
+ await page.addInitScript(() => { try { localStorage.setItem('hall-welcome-signin', '1'); localStorage.setItem('hall-game', 'off'); } catch (_) { /* no storage */ } });
276
+ await page.goto(ORIGIN + '/');
277
+ await page.waitForFunction(() => !!window.OTBShell && !!window.OTBWelcome && !!window.OTBShellGame);
278
+ await page.evaluate(() => { const me = { authed: true, rank: 'metic', builder: { id: 7, display_name: 'Ada', github_login: 'ada' } }; window.OTBShell.applyAccess(me); window.OTBShell.applyAccess(me); });
279
+ await page.waitForSelector('#game-start');
280
+ assert.equal(await page.evaluate(() => document.activeElement.textContent), 'ClassicLast timeThe hall with its sidebar', 'the view chosen last time is focused and marked');
281
+ await page.keyboard.press('Enter');
282
+ await page.waitForSelector('#game-start', { state: 'detached' });
283
+ assert.equal((await state(page)).game, null);
284
+ assert.deepEqual(saved, [{ view: 'classic' }]);
285
+ await ctx.close();
286
+ });
287
+
288
+ test('on a phone the panel is the whole width, nothing scrolls sideways and every choice clears the floor (task 1005192)', { skip }, async () => {
289
+ for (const [w, h] of [[390, 844], [320, 700]]) {
290
+ const ctx = await hall({ viewport: { width: w, height: h } });
291
+ const { page, errors } = await signedIn(ctx);
292
+ await page.waitForSelector('#game-start');
293
+ const m = await page.evaluate(() => {
294
+ const gs = document.getElementById('game-start'), r = document.querySelector('.gs__menu').getBoundingClientRect();
295
+ return {
296
+ x: Math.round(r.left), w: Math.round(r.width), sideways: gs.scrollWidth - gs.clientWidth,
297
+ small: Array.from(document.querySelectorAll('.gs__item')).filter((e) => !e.closest('[hidden]')).filter((e) => { const b = e.getBoundingClientRect(); return b.height < 24 || b.width < 24 || b.right > window.innerWidth + 1; }).length,
298
+ };
299
+ });
300
+ assert.deepEqual(m, { x: 0, w, sideways: 0, small: 0 }, `at ${w}px`);
301
+ await page.click('[data-gs="view-game"]');
302
+ await page.click('[data-gs="start"]');
303
+ await page.waitForSelector('#gs-load:not([hidden])');
304
+ assert.equal(await page.evaluate(() => { const gs = document.getElementById('game-start'); return gs.scrollWidth - gs.clientWidth; }), 0, `the load screen fits ${w}px`);
305
+ assert.deepEqual(errors, []);
306
+ await ctx.close();
307
+ }
308
+ });
309
+
214
310
  test('Quit closes the tab, and says so when the browser keeps it open', { skip }, async () => {
215
311
  const ctx = await hall();
216
312
  const { page } = await signedIn(ctx);
217
313
  await page.waitForSelector('#game-start');
218
314
  await page.evaluate(() => { window.__closed = 0; window.close = () => { window.__closed += 1; }; });
315
+ await page.click('[data-gs="view-game"]');
219
316
  await page.click('[data-gs="quit"]');
220
317
  await page.waitForSelector('#gs-note:not([hidden])');
221
318
  assert.equal(await page.evaluate(() => window.__closed), 1);
@@ -228,6 +325,7 @@ test('with no city to draw, or with reduced motion, the still and the tip stand
228
325
  const ctx = await hall(opts);
229
326
  const { page, errors } = await signedIn(ctx);
230
327
  await page.waitForSelector('#game-start');
328
+ await page.click('[data-gs="view-game"]');
231
329
  await page.click('[data-gs="start"]');
232
330
  await page.waitForSelector('#gs-load:not([hidden])');
233
331
  assert.match(await page.textContent('#gs-tip'), /\.$/);
@@ -488,7 +488,8 @@ test('the page holds the prototype\'s header, the street, the readings and the c
488
488
  assert.equal((html.match(/<main\b/g) || []).length, 1);
489
489
  assert.equal((html.match(/<h1\b/g) || []).length, 1);
490
490
  assert.match(html, /<a class="street-mark" id="streetHome" href="\/builders\/city"[^>]*>\{\{productName\}\}</);
491
- for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
491
+ assert.doesNotMatch(html, /streetMode/, 'no light/dark switch: the hall is dark only (task 1005192, ADR 0402)');
492
+ for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
492
493
  const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
493
494
  assert.deepEqual(scripts.filter((s) => !/^street-(deck|office)$/.test(s)), ['dom-utils', 'shell-game', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-depth', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the living model and its one reader (task 1004685: city-draw.js counts with it, so it loads first), the shared kit, then the card (task 1004528), the interiors (task 1004526), the living street\'s exterior (street-depth.js), the floor desk (task 1004558) and the drafted amendment (task 1004530); the deck rides beside them');
494
495
  assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
@@ -552,7 +553,7 @@ function fakeDom() {
552
553
  };
553
554
  const focused = { el: null };
554
555
  const html = mk('html');
555
- const ids = ['streetMode', 'streetModeIcon', 'streetModeWord', 'streetWindow', 'streetSizer', 'streetBoard', 'streetSvg', 'streetFlat', 'streetBlds', 'streetCrates', 'streetCols', 'streetGroups', 'streetDoors', 'streetPins', 'streetSigns', 'streetEvening', 'streetEveningLine', 'hudNeeds', 'hudMoving', 'hudShare', 'hudDot', 'streetHome', 'streetCard', 'streetCardWhere', 'streetCardWhen', 'streetCardTitle', 'streetCardBody', 'streetCardOpen', 'streetCardClose', 'streetToast'];
556
+ const ids = ['streetWindow', 'streetSizer', 'streetBoard', 'streetSvg', 'streetFlat', 'streetBlds', 'streetCrates', 'streetCols', 'streetGroups', 'streetDoors', 'streetPins', 'streetSigns', 'streetEvening', 'streetEveningLine', 'hudNeeds', 'hudMoving', 'hudShare', 'hudDot', 'streetHome', 'streetCard', 'streetCardWhere', 'streetCardWhen', 'streetCardTitle', 'streetCardBody', 'streetCardOpen', 'streetCardClose', 'streetToast'];
556
557
  for (const id of ids) mk('x').setAttribute('id', id);
557
558
  const document = { documentElement: html, getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_ns, t) => mk(t) };
558
559
  return { document, byId, html, focused };
@@ -701,32 +702,18 @@ test('the header counts wear the pins\' colours, so they double as the legend',
701
702
  assert.ok(size('urgent') > size('needs') && size('needs') > size('moving') && size('moving') > size('settled'), 'urgent > needs > moving > settled');
702
703
  });
703
704
 
704
- test('the switch reads and writes the hall\'s own theme preference and redraws the street', async () => {
705
- const p = await bootPage({ feeds: FEEDS([]), stored: 'light', osDark: true });
706
- assert.equal(p.html.attrs['data-theme'], 'light', 'the stored hall choice beats the OS');
707
- assert.equal(p.byId.get('streetMode').attrs['aria-label'], 'Switch to dark mode');
708
- assert.equal(p.byId.get('streetModeWord').textContent, 'dark');
709
- const before = JSON.stringify(p.byId.get('streetBlds').children[0].children[0].attrs);
710
- p.byId.get('streetMode').fire('click');
711
- assert.equal(p.store.get('hall-theme'), 'dark');
712
- assert.equal(p.html.attrs['data-theme'], 'dark');
713
- assert.notEqual(JSON.stringify(p.byId.get('streetBlds').children[0].children[0].attrs), before, 'the street redraws in the new mode');
714
- });
715
-
716
- test('in the hall, the street and the top bar\'s theme switch never disagree (task 1004544)', async () => {
717
- let shellPaints = 0;
718
- const p = await bootPage({ feeds: FEEDS([]), stored: 'light', osDark: true, shell: { applyTheme() { shellPaints++; } } });
719
- assert.equal(p.observers.length, 1, 'the page watches html[data-theme]');
720
- p.byId.get('streetMode').fire('click');
721
- assert.equal(shellPaints, 1, 'this switch repaints the shell\'s');
722
- const before = JSON.stringify(p.byId.get('streetBlds').children[0].children[0].attrs);
723
- p.store.set('hall-theme', 'light'); p.html.setAttribute('data-theme', 'light'); // the shell's switch
724
- p.observers[0].fn();
725
- assert.equal(p.byId.get('streetModeWord').textContent, 'dark', 'this switch follows the shell\'s');
726
- assert.notEqual(JSON.stringify(p.byId.get('streetBlds').children[0].children[0].attrs), before, 'and the street redraws in that mode');
727
- assert.equal(shellPaints, 1, 'following the shell does not echo back to it');
705
+ test('the street is dark only: no stored choice and no OS setting reaches it (task 1005192, ADR 0402)', async () => {
706
+ const p = await bootPage({ feeds: FEEDS([]), stored: 'light', osDark: false, shell: { applyTheme() { throw new Error('the street no longer drives the shell'); } } });
707
+ assert.equal(p.html.attrs['data-theme'], 'dark', 'a stored light choice and a light OS are both ignored');
708
+ assert.equal(p.store.get('hall-theme'), 'light', 'and the old preference is never written');
709
+ assert.equal(p.observers.length, 0, 'nothing flips the theme, so nothing watches it');
710
+ const q = await bootPage({ feeds: FEEDS([]), stored: null, osDark: true });
711
+ assert.equal(JSON.stringify(q.byId.get('streetBlds').children[0].children[0].attrs), JSON.stringify(p.byId.get('streetBlds').children[0].children[0].attrs), 'one drawing whatever was stored');
712
+ assert.doesNotMatch(read('street.js'), /hall-theme|prefers-color-scheme/, 'street.js reads no theme preference');
728
713
  });
729
714
 
715
+
716
+
730
717
  test('arriving through a door marks that building, and a failed docket says so', async () => {
731
718
  const p = await bootPage({ feeds: FEEDS([]), hash: '#building=treasury' });
732
719
  const here = p.byId.get('streetDoors').children.filter((a) => a.attrs['aria-current'] === 'location');
@@ -63,7 +63,7 @@ async function measureBar(width, scheme) {
63
63
  const ctx = await context(width, scheme);
64
64
  const page = await ctx.newPage();
65
65
  await page.goto(ORIGIN + '/work');
66
- await page.waitForSelector('#app-topbar .topbar-side #theme-toggle');
66
+ await page.waitForSelector('#app-topbar .topbar-side #zoom-toggle');
67
67
  return page.evaluate(() => {
68
68
  const side = document.querySelector('#app-topbar .topbar-side');
69
69
  if (!document.getElementById('upd-pill')) {
@@ -86,9 +86,11 @@ for (const scheme of ['light', 'dark']) {
86
86
  test(`at 320px the top bar fits, every button inside it and at least 24px (${scheme})`, { skip }, async () => {
87
87
  const m = await measureBar(320, scheme);
88
88
  const ids = m.buttons.map((b) => b.id);
89
- for (const want of ['upd-pill', 'topbar-jump', 'zoom-toggle', 'theme-toggle', 'fullscreen-toggle']) {
89
+ for (const want of ['upd-pill', 'topbar-jump', 'zoom-toggle', 'fullscreen-toggle']) {
90
90
  assert.ok(ids.includes(want), `${want} is on the bar (got ${ids.join(', ')})`);
91
91
  }
92
+ // the hall is dark only (task 1005192, ADR 0402): the bar offers no light/dark switch
93
+ assert.ok(!ids.includes('theme-toggle'), 'no theme switch on the bar');
92
94
  assert.ok(m.scrollWidth <= 320, `the page is ${m.scrollWidth}px wide at 320 (the top bar spills)`);
93
95
  for (const b of m.buttons) {
94
96
  assert.ok(b.left >= 0 && b.right <= 320, `${b.id} sits at ${Math.round(b.left)}..${Math.round(b.right)}, outside 0..320`);