@bongos/core 1.24.81 → 1.24.83

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 (77) hide show
  1. package/.bongos-core.json +95 -80
  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 +4 -0
  8. package/docs/api/openapi.json +86 -2
  9. package/docs/api-reference.md +4 -2
  10. package/docs/copy-inventory.md +39 -33
  11. package/docs/copy-registry.json +107 -52
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +1 -0
  14. package/docs/page-readings.json +207 -201
  15. package/modules/builder-settings/hall-view-prefs.js +50 -0
  16. package/modules/builder-settings/routes/settings.js +32 -0
  17. package/modules/hall-ui/public/ai-studio.html +3 -3
  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 +5 -5
  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/controls.css +10 -2
  25. package/modules/hall-ui/public/copy-desk.html +3 -3
  26. package/modules/hall-ui/public/deploy.html +3 -3
  27. package/modules/hall-ui/public/goal-map.html +5 -5
  28. package/modules/hall-ui/public/goals.html +3 -3
  29. package/modules/hall-ui/public/government.html +3 -3
  30. package/modules/hall-ui/public/idea.html +3 -3
  31. package/modules/hall-ui/public/ideas.html +3 -3
  32. package/modules/hall-ui/public/index.html +6 -6
  33. package/modules/hall-ui/public/market-watch-item.html +3 -3
  34. package/modules/hall-ui/public/market-watch-rules.html +3 -3
  35. package/modules/hall-ui/public/market-watch.html +3 -3
  36. package/modules/hall-ui/public/modules.html +3 -3
  37. package/modules/hall-ui/public/money.html +3 -3
  38. package/modules/hall-ui/public/my-agents.html +3 -3
  39. package/modules/hall-ui/public/options-menu.js +22 -1
  40. package/modules/hall-ui/public/primer.html +3 -3
  41. package/modules/hall-ui/public/profile.html +3 -3
  42. package/modules/hall-ui/public/project-settings.html +3 -3
  43. package/modules/hall-ui/public/roster.html +3 -3
  44. package/modules/hall-ui/public/settings-view.js +75 -0
  45. package/modules/hall-ui/public/settings.css +113 -46
  46. package/modules/hall-ui/public/settings.html +18 -5
  47. package/modules/hall-ui/public/shell-game.css +10 -0
  48. package/modules/hall-ui/public/shell-game.js +98 -33
  49. package/modules/hall-ui/public/shell.js +4 -3
  50. package/modules/hall-ui/public/street.html +3 -3
  51. package/modules/hall-ui/public/studio.html +3 -3
  52. package/modules/hall-ui/public/style.css +149 -61
  53. package/modules/hall-ui/public/task.html +3 -3
  54. package/modules/hall-ui/public/thinking.html +5 -5
  55. package/modules/hall-ui/public/tweak-editor.html +3 -3
  56. package/modules/hall-ui/public/visor.css +2 -2
  57. package/modules/hall-ui/public/visor.js +7 -1
  58. package/modules/hall-ui/public/watch.html +3 -3
  59. package/modules/hall-ui/public/work.html +3 -3
  60. package/modules/hall-ui/records/craft-mode.md +9 -0
  61. package/modules/hall-ui/records/game-mode.md +5 -4
  62. package/modules/hall-ui/records/options-menu.md +11 -0
  63. package/modules/hall-ui/records/start-screen.md +4 -0
  64. package/package-lock.json +2 -2
  65. package/package.json +1 -1
  66. package/release-notes.json +12 -0
  67. package/scripts/hall-preview/pref-writes.js +8 -1
  68. package/src/module-api.js +1 -1
  69. package/tests/design_tokens_shared.mjs +5 -1
  70. package/tests/hall_controls.mjs +1 -1
  71. package/tests/hall_fullscreen.mjs +42 -21
  72. package/tests/hall_helm.mjs +103 -1
  73. package/tests/hall_start_screen.mjs +38 -3
  74. package/tests/hall_topbar_320.mjs +7 -4
  75. package/tests/hall_view_prefs.mjs +42 -0
  76. package/tests/hall_visor.mjs +3 -2
  77. package/tests/nav_permission_atoms.mjs +1 -1
@@ -11589,5 +11589,17 @@
11589
11589
  "id": "1005192",
11590
11590
  "text": "The hall now wears the new Helm look: deep night panels, ice-blue text, wide capital titles and cut-corner buttons, all from one shared design system. The start and load screens are rebuilt to match, and the hall is dark only."
11591
11591
  }
11592
+ ],
11593
+ "1.24.82": [
11594
+ {
11595
+ "id": "1005200",
11596
+ "text": "The hall now has two separate views, Game and Classic, and never mixes them. You choose one each time you sign in and can switch only in Options. Game mode stays full screen and only opens the game's places."
11597
+ }
11598
+ ],
11599
+ "1.24.83": [
11600
+ {
11601
+ "id": "1005194",
11602
+ "text": "The options menu and the choose-your-character screen now wear the Helm look: a night panel with wide capital rows, and three tall cut-corner doors with the real characters. Fields and tags across the hall are squared to match"
11603
+ }
11592
11604
  ]
11593
11605
  }
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // What it is NOT: storage. Nothing is written to disk and a restart forgets
16
16
  // everything; each recorded write is logged so the harness console shows what the
17
- // page sent. Only these five routes are recorded — every other write is refused
17
+ // page sent. Only these routes are recorded — every other write is refused
18
18
  // exactly as before.
19
19
  'use strict';
20
20
 
@@ -27,6 +27,7 @@ const wandering = lib('wandering-prefs');
27
27
  const models = lib('model-alloc-prefs');
28
28
  const render = lib('render-prefs');
29
29
  const skills = lib('skill-prefs');
30
+ const hallView = lib('hall-view-prefs'); // task 1005200: game or classic
30
31
 
31
32
  // apiPath → the /me slice it answers for (null: the skill prefs, not on /me)
32
33
  const ROUTES = {
@@ -35,6 +36,7 @@ const ROUTES = {
35
36
  'me/model-allocation': 'model_allocation',
36
37
  'me/render': 'render',
37
38
  'me/skill-prefs': null,
39
+ 'me/hall-view': 'hall_view',
38
40
  };
39
41
 
40
42
  function createPrefRecorder({ log = console.log } = {}) {
@@ -63,6 +65,7 @@ function createPrefRecorder({ log = console.log } = {}) {
63
65
  if (slice === 'wandering') return wandering.describeForApi(behavior, rank);
64
66
  if (slice === 'model_allocation') return models.describeForApi(behavior, instanceModels);
65
67
  if (slice === 'render') return render.describeForApi(behavior);
68
+ if (slice === 'hall_view') return hallView.describeForApi(behavior);
66
69
  return {
67
70
  prefs: skillPrefs, effective: skills.listEffective(skillPrefs), defaults: skills.SKILL_DEFAULTS,
68
71
  known_skills: skills.KNOWN_SKILLS, allowed_models: skills.ALLOWED_MODELS,
@@ -96,6 +99,10 @@ function createPrefRecorder({ log = console.log } = {}) {
96
99
  v = render.validatePatch(b);
97
100
  if (!v.ok) return invalid(v.errors);
98
101
  behavior = render.applyPatch(behavior, v.patch);
102
+ } else if (slice === 'hall_view') {
103
+ v = hallView.validatePatch(b);
104
+ if (!v.ok) return invalid(v.errors);
105
+ behavior = hallView.applyView(behavior, v.view);
99
106
  } else {
100
107
  v = skills.validatePatch(b);
101
108
  if (!v.ok) return invalid(v.errors);
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.81'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.83'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -133,13 +133,17 @@ test('the landing, hub, status and raise pages link it before their own sheet',
133
133
 
134
134
  test('the hall\'s old token names still resolve — each alias names a defined --ds-* token', () => {
135
135
  const style = decomment(read('modules', 'hall-ui', 'public', 'style.css'));
136
- const want = ['--line', '--radius', '--radius-sm', '--radius-pill', '--sp-hair', '--sp-xs', '--sp-sm', '--sp-md', '--sp-lg', '--sp-xl',
136
+ const want = ['--line', '--radius', '--radius-sm', '--sp-hair', '--sp-xs', '--sp-sm', '--sp-md', '--sp-lg', '--sp-xl',
137
137
  '--shadow-pop', '--z-progress', '--z-topbar', '--z-sidebar', '--z-scrim', '--z-drawer', '--z-modal', '--z-diagram-full', '--label-size'];
138
138
  for (const n of want) {
139
139
  const m = style.match(new RegExp(`${n}\\s*:\\s*var\\((--ds-[\\w-]+)\\)`));
140
140
  assert.ok(m, `${n} is an alias onto the shared sheet`);
141
141
  assert.ok(ROOT_TOKENS[m[1]], `${n} → ${m[1]} is defined in tokens.css`);
142
142
  }
143
+ // --radius-pill left the aliases with Helm (task 1005194, ADR 0402): the hall draws no pill,
144
+ // so its tier sets the name to a square corner; the shared --ds-radius-pill is untouched.
145
+ assert.match(style, /--radius-pill: 0px;/, 'the hall has no pill');
146
+ assert.equal(ROOT_TOKENS['--ds-radius-pill'], '999px', 'the shared pill is still a pill for every other surface');
143
147
  // the four-step --space-* scale still rides the --sp-* aliases
144
148
  for (const n of ['--space-1', '--space-2', '--space-3', '--space-4']) assert.match(style, new RegExp(`${n}\\s*:\\s*var\\(--sp-`));
145
149
  });
@@ -161,7 +161,7 @@ test('primary is the 46–48px door; the row size and the text button keep the 2
161
161
  }
162
162
  const field = sharedRule('select');
163
163
  assert.ok(px(prop(field, 'min-height')) >= 24, 'the field clears the floor');
164
- assert.equal(prop(field, 'border-radius'), 'var(--radius-pill)', 'a field is a pill too');
164
+ assert.equal(prop(field, 'border-radius'), 'var(--radius-pill)', 'a field spends the pill name: a pill on the status page, square in the hall, whose tier sets --radius-pill to 0px (tests/hall_helm.mjs)');
165
165
  });
166
166
 
167
167
  // ── 3. AA, measured, in both modes ───────────────────────────────────────────
@@ -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');
@@ -14,7 +14,9 @@
14
14
  // cannot see because the link is built at serve time;
15
15
  // * no hall script writes a theme choice, and the shell offers no switch;
16
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.
17
+ // * the kit's recipes exist once, as hall-tier tokens and shared rules;
18
+ // * Helm look 2 (task 1005194): the hall has no pill, and the options menu and
19
+ // character select are Helm screens written from the tokens alone.
18
20
  //
19
21
  // Run: node --test tests/hall_helm.mjs
20
22
  import { test } from 'node:test';
@@ -185,3 +187,103 @@ test('the kit\'s recipes are named once: the name voice, the cut, the segment, t
185
187
  assert.match(controls, /\n\.seg--done \{ background: var\(--ink-soft\);/, 'a finished step is information, so it is ice, not the accent');
186
188
  assert.match(controls, /\n\.seg--now \{ background: var\(--accent\);/, 'the accent is the step you can act on');
187
189
  });
190
+
191
+ // ── Helm look 2 (task 1005194): the square corner, the options menu, character select ──
192
+
193
+ // one rule's body, by its exact selector line, from a decommented sheet
194
+ const ruleOf = (css, sel) => {
195
+ const at = css.indexOf('\n' + sel + ' {');
196
+ assert.notEqual(at, -1, `no rule ${sel}`);
197
+ const open = css.indexOf('{', at);
198
+ return css.slice(open + 1, css.indexOf('}', open));
199
+ };
200
+ const NO_COLOUR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/;
201
+
202
+ test('the hall has no pill: --radius-pill is a square corner in the hall tier, and only there', () => {
203
+ assert.equal(root['--radius-pill'], '0px', 'Helm draws no pill');
204
+ // the panel and the tile keep their corners until each page's own task (DESIGN.md "The hall")
205
+ assert.equal(root['--radius'], 'var(--ds-radius-panel)');
206
+ assert.equal(root['--radius-sm'], 'var(--ds-radius-tile)');
207
+ // the shared scale is untouched, so the landing and the status page keep their pill
208
+ assert.match(read('modules', 'ui-design', 'public', 'tokens.css'), /--ds-radius-pill:\s*999px;/);
209
+ const controls = decomment(read('modules', 'hall-ui', 'public', 'controls.css'));
210
+ // the field and the status tag spend the name, so they are square in the hall with no rule of their own
211
+ assert.match(controls, /\.input \{[^}]*border-radius: var\(--radius-pill\);/);
212
+ assert.match(controls, /\ntextarea \{[^}]*border-radius: var\(--press-radius, var\(--radius-sm\)\);/, 'a textarea is square too, the tile where the tier is not loaded');
213
+ });
214
+
215
+ test('what is pressed in the top bar, and a filter chip, take the small cut and the inset focus ring', () => {
216
+ const css = decomment(STYLE.replace(/\r\n/g, '\n'));
217
+ for (const sel of ['.upd-pill', '.chip', 'html[data-fullscreen="on"] .fullscreen-toggle']) {
218
+ const body = ruleOf(css, sel);
219
+ assert.match(body, /clip-path: var\(--cut-path-sm\);/, `${sel} is cut`);
220
+ assert.match(body, /--focus-ring: var\(--press-focus\);/, `${sel}: a cut clips an outline, so the ring is inside`);
221
+ assert.match(body, /border-radius: var\(--press-radius\);/, `${sel} has no round corner`);
222
+ }
223
+ // an outer shadow would be clipped away with the corner, and would outrank the focus ring
224
+ assert.doesNotMatch(ruleOf(css, 'html[data-fullscreen="on"] .upd-pill'), /box-shadow/);
225
+ });
226
+
227
+ test('the options menu is a Helm screen: a night panel, name-voice rows, the open one the accent bar', () => {
228
+ const sheet = decomment(read('modules', 'hall-ui', 'public', 'settings.css').replace(/\r\n/g, '\n'));
229
+ const menu = sheet.slice(0, sheet.indexOf('.cli-result'));
230
+ assert.ok(menu.includes('.opt-rail') && menu.includes('.opt-screen'), 'the menu and the open screen are the first part of the sheet');
231
+ assert.doesNotMatch(menu, NO_COLOUR_LITERAL, 'no colour is typed in');
232
+ assert.doesNotMatch(menu, /border-radius:\s*\d/, 'no corner is typed in');
233
+ const rail = ruleOf(menu, '.opt-rail');
234
+ assert.match(rail, /background: var\(--night-glass\);/);
235
+ assert.match(rail, /border-left-width: var\(--line-strong\); border-right-width: var\(--line-strong\);/, 'closed left and right by the strong hairline');
236
+ const link = ruleOf(menu, '.opt-link');
237
+ assert.match(link, /font-family: var\(--name-face\);/);
238
+ assert.match(link, /text-transform: var\(--name-case\);/);
239
+ assert.match(link, /min-height: var\(--ds-space-xl\);/, 'a row clears the 24px floor many times over');
240
+ assert.match(link, /border-bottom: var\(--line\) solid var\(--rule-soft\);/, 'rows on hairlines, not boxes');
241
+ assert.match(ruleOf(menu, '.opt-link--active, .opt-link--active:hover'), /color: var\(--accent-on\); background: var\(--accent\);/);
242
+ assert.match(ruleOf(menu, '.opt-link:focus-visible'), /box-shadow: var\(--press-focus\);/, 'the ring is inside the row, so it reads on the accent bar');
243
+ const screen = ruleOf(menu, '.opt-screen');
244
+ assert.match(screen, /font-family: var\(--name-face\);/);
245
+ assert.match(screen, /border-bottom: var\(--line-strong\) solid var\(--rule\);/);
246
+ const section = ruleOf(menu, '.settings-groups .scroll');
247
+ assert.match(section, /background: none;/, 'a section is a row on a hairline, not a card');
248
+ assert.match(section, /border-bottom: var\(--line\) solid var\(--rule-soft\);/);
249
+ // the search is the hall's one field: the sheet draws no edge, corner or focus of its own for it
250
+ assert.doesNotMatch(ruleOf(menu, '.opt-search input'), /border|outline/);
251
+ assert.doesNotMatch(menu, /\.opt-search input:focus/);
252
+ // the two names are words the menu already had: its own label, and the open entry's
253
+ const js = read('modules', 'hall-ui', 'public', 'options-menu.js');
254
+ assert.match(js, /<p class="opt-title" aria-hidden="true">Options<\/p>/);
255
+ assert.match(js, /h\.setAttribute\('aria-hidden', 'true'\);/);
256
+ assert.match(js, /h\.textContent = e\.label;/);
257
+ assert.match(js, /<nav|opt-search/, 'the search is still drawn');
258
+ assert.match(read('modules', 'hall-ui', 'public', 'settings.html'), /<nav class="opt-rail" id="settings-rail" aria-label="Options"><\/nav>/);
259
+ });
260
+
261
+ test('character select is a Helm screen: the night, cut doors, the suggested door lit, the ring inside the cut', () => {
262
+ const css = decomment(STYLE.replace(/\r\n/g, '\n'));
263
+ const picker = css.slice(css.indexOf('\n.craft-picker {'), css.indexOf('\n.craft-art {'));
264
+ assert.doesNotMatch(picker, NO_COLOUR_LITERAL, 'no colour is typed in');
265
+ assert.doesNotMatch(picker, /border-radius:\s*\d|font-size:\s*\d/, 'no corner or size is typed in');
266
+ assert.match(ruleOf(css, '.craft-picker--create'), /background: var\(--bg\);/, 'a screen on the night, not a box on a scrim');
267
+ assert.match(ruleOf(css, '.craft-picker__panel--create'), /background: none;/);
268
+ const title = ruleOf(css, '.craft-picker__title');
269
+ assert.match(title, /font-family: var\(--name-face\);/);
270
+ assert.match(title, /text-transform: var\(--name-case\);/);
271
+ const door = ruleOf(css, '.craft-door');
272
+ assert.match(door, /clip-path: var\(--cut-path\);/, 'a door is pressed, so it is cut');
273
+ assert.match(door, /background: var\(--night-glass\);/);
274
+ assert.match(ruleOf(css, '.craft-door:focus-visible'), /outline: 0;[^}]*box-shadow: var\(--press-focus\);/);
275
+ assert.match(ruleOf(css, '.craft-door--pre, .craft-door--pre:hover'), /box-shadow: inset 0 0 0 2px var\(--accent\);/);
276
+ assert.match(ruleOf(css, '.craft-door::after'), /box-shadow: inherit;/, 'the edge is drawn over the portrait as well as under it');
277
+ assert.match(ruleOf(css, '.craft-door__name'), /font-family: var\(--name-face\);/);
278
+ // the real sprites, and the words where they were
279
+ const js = read('modules', 'hall-ui', 'public', 'craft-mode.js');
280
+ for (const id of ['artisan', 'artist', 'patron']) assert.ok(fs.existsSync(path.join(HALL, 'characters', id + '.png')), `${id}.png`);
281
+ assert.match(js, /id="craft-picker-title">Choose your character<\/h2>/);
282
+ assert.match(js, /aria-describedby="craft-picker-lede"/);
283
+ });
284
+
285
+ test('the visor reads in the Helm night: no hover falls back to the pack\'s dark accent', () => {
286
+ const visor = decomment(read('modules', 'hall-ui', 'public', 'visor.css'));
287
+ assert.doesNotMatch(visor, /--accent-deep/, 'the deep accent is a light-ground colour; on the night it is under 4.5:1');
288
+ assert.match(visor, /\.visor__row:hover \{ color: var\(--accent-ink\); \}/);
289
+ });
@@ -188,12 +188,21 @@ test('a sign-in passes through the start screen and the load screen before the w
188
188
  const { page, errors } = await signedIn(ctx);
189
189
  await page.waitForSelector('#game-start');
190
190
  assert.deepEqual(await state(page), { start: true, welcome: false, picker: false, game: null, full: null },
191
- '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');
192
201
  const menu = await page.evaluate(() => ({
193
202
  title: document.getElementById('gs-title').textContent,
194
- 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),
195
204
  focus: document.activeElement.textContent,
196
- 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,
197
206
  }));
198
207
  assert.deepEqual(menu, { title: 'Harbor', items: ['Start', 'Options', 'Achievements', 'Extras', 'Quit'], focus: 'Start', small: 0 });
199
208
 
@@ -253,6 +262,29 @@ test('a sign-in passes through the start screen and the load screen before the w
253
262
  await ctx.close();
254
263
  });
255
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
+
256
288
  test('on a phone the panel is the whole width, nothing scrolls sideways and every choice clears the floor (task 1005192)', { skip }, async () => {
257
289
  for (const [w, h] of [[390, 844], [320, 700]]) {
258
290
  const ctx = await hall({ viewport: { width: w, height: h } });
@@ -266,6 +298,7 @@ test('on a phone the panel is the whole width, nothing scrolls sideways and ever
266
298
  };
267
299
  });
268
300
  assert.deepEqual(m, { x: 0, w, sideways: 0, small: 0 }, `at ${w}px`);
301
+ await page.click('[data-gs="view-game"]');
269
302
  await page.click('[data-gs="start"]');
270
303
  await page.waitForSelector('#gs-load:not([hidden])');
271
304
  assert.equal(await page.evaluate(() => { const gs = document.getElementById('game-start'); return gs.scrollWidth - gs.clientWidth; }), 0, `the load screen fits ${w}px`);
@@ -279,6 +312,7 @@ test('Quit closes the tab, and says so when the browser keeps it open', { skip }
279
312
  const { page } = await signedIn(ctx);
280
313
  await page.waitForSelector('#game-start');
281
314
  await page.evaluate(() => { window.__closed = 0; window.close = () => { window.__closed += 1; }; });
315
+ await page.click('[data-gs="view-game"]');
282
316
  await page.click('[data-gs="quit"]');
283
317
  await page.waitForSelector('#gs-note:not([hidden])');
284
318
  assert.equal(await page.evaluate(() => window.__closed), 1);
@@ -291,6 +325,7 @@ test('with no city to draw, or with reduced motion, the still and the tip stand
291
325
  const ctx = await hall(opts);
292
326
  const { page, errors } = await signedIn(ctx);
293
327
  await page.waitForSelector('#game-start');
328
+ await page.click('[data-gs="view-game"]');
294
329
  await page.click('[data-gs="start"]');
295
330
  await page.waitForSelector('#gs-load:not([hidden])');
296
331
  assert.match(await page.textContent('#gs-tip'), /\.$/);
@@ -99,7 +99,9 @@ for (const scheme of ['light', 'dark']) {
99
99
  });
100
100
  }
101
101
 
102
- test('the top bar search and the mode switch are one shape: a pill with the same hairline', { skip }, async () => {
102
+ // Helm (task 1005194): the hall has no pill, so the one shape the two share is the hall's
103
+ // square corner (--radius-pill is 0px in the hall tier) inside the same hairline.
104
+ test('the top bar search and the mode switch are one shape: the square corner of the hall with the same hairline', { skip }, async () => {
103
105
  const ctx = await context(1440, 'dark');
104
106
  const page = await ctx.newPage();
105
107
  await page.goto(ORIGIN + '/work');
@@ -115,11 +117,12 @@ test('the top bar search and the mode switch are one shape: a pill with the same
115
117
  switchRadius: css(sw).borderTopLeftRadius,
116
118
  jumpEdge: css(jump).borderTopColor,
117
119
  switchShadow: css(sw).boxShadow,
118
- pill: root.getPropertyValue('--ds-radius-pill').trim(),
120
+ pill: root.getPropertyValue('--radius-pill').trim(),
119
121
  };
120
122
  }).finally(() => ctx.close());
121
- assert.equal(s.jumpRadius, s.pill, 'the search is a full pill');
122
- assert.equal(s.switchRadius, s.pill, 'the mode switch is a full pill');
123
+ assert.equal(s.pill, '0px', 'the hall tier squares the pill');
124
+ assert.equal(s.jumpRadius, s.pill, 'the search takes the corner of the hall');
125
+ assert.equal(s.switchRadius, s.pill, 'the mode switch takes the same corner');
123
126
  assert.ok(s.switchShadow.startsWith(s.jumpEdge), `the two share one hairline colour (${s.jumpEdge} vs ${s.switchShadow})`);
124
127
  });
125
128
 
@@ -0,0 +1,42 @@
1
+ // tests/hall_view_prefs.mjs — the game-or-classic choice kept on the account
2
+ // (task 1005200, modules/builder-settings/hall-view-prefs.js), and the two routes
3
+ // that read and write it.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createRequire } from 'node:module';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const ROOT = fileURLToPath(new URL('..', import.meta.url));
13
+ const prefs = require('../modules/builder-settings/hall-view-prefs.js');
14
+
15
+ test('never chosen is null, not a default view: the sign-in chooser asks', () => {
16
+ assert.equal(prefs.storedView({}), null);
17
+ assert.equal(prefs.storedView(null), null);
18
+ assert.equal(prefs.storedView({ hall_view: 'fullscreen' }), null, 'an unknown value is not a choice');
19
+ assert.deepEqual(prefs.describeForApi({ hall_view: 'game' }), { view: 'game', views: ['game', 'classic'] });
20
+ });
21
+
22
+ test('a PATCH takes exactly { view: game | classic }', () => {
23
+ assert.deepEqual(prefs.validatePatch({ view: 'classic' }), { ok: true, view: 'classic' });
24
+ assert.equal(prefs.validatePatch({}).ok, false, 'no view');
25
+ assert.equal(prefs.validatePatch({ view: 'both' }).ok, false);
26
+ assert.equal(prefs.validatePatch({ view: 'game', extra: 1 }).ok, false, 'unknown keys refused');
27
+ assert.equal(prefs.validatePatch([]).ok, false);
28
+ });
29
+
30
+ test('saving the view keeps every other behaviour preference', () => {
31
+ const before = { wandering: 2, widgets: 'rich', interaction: { verbosity: 'concise' } };
32
+ const after = prefs.applyView(before, 'game');
33
+ assert.deepEqual(after, { ...before, hall_view: 'game' });
34
+ assert.equal(before.hall_view, undefined, 'pure: the input is not changed');
35
+ });
36
+
37
+ test('the routes: own builder only, GET and PATCH on /me/hall-view', () => {
38
+ const src = fs.readFileSync(path.join(ROOT, 'modules/builder-settings/routes/settings.js'), 'utf8');
39
+ assert.match(src, /router\.get\('\/me\/hall-view', api\.requireBuilder,/);
40
+ assert.match(src, /router\.patch\('\/me\/hall-view', api\.requireBuilder,/);
41
+ assert.match(src, /hallViewPrefs\.applyView\(existing, v\.view\)/, 'writes through the pure shaper');
42
+ });
@@ -151,7 +151,7 @@ test('in game mode the glass shows the place, the main action, the sprite and th
151
151
  }));
152
152
  assert.equal(g.shown, 'block');
153
153
  assert.equal(g.place, 'Test city');
154
- assert.match(g.main, /^Claim a task/);
154
+ assert.match(g.main, /^Goal sky/, 'Claim a task opens a classic page, so it is off the glass in game mode (task 1005200)');
155
155
  assert.equal(g.who, 'Artisan');
156
156
  assert.equal(g.credits, '56,021 credits');
157
157
  assert.equal(g.sprite, '/builders/characters/artisan.png');
@@ -189,7 +189,8 @@ test('the sheet lists the character\'s actions, then this place, and runs a scen
189
189
  const { ctx, page } = await open();
190
190
  await page.click('[data-visor-sheet]');
191
191
  const rows = await page.$$eval('.visor__sheet .visor__row', (r) => r.map((x) => [...x.children].map((c) => c.textContent).join(' ')));
192
- assert.deepEqual(rows, ['Claim a task C', 'Blockers B', 'Goal sky G', 'Your thinking T', 'Back to centre 0', 'Look around drag', 'Options Esc']);
192
+ // task 1005200: Claim a task and Blockers open classic pages, so they are off the glass.
193
+ assert.deepEqual(rows, ['Goal sky G', 'Your thinking T', 'Back to centre 0', 'Look around drag', 'Options Esc']);
193
194
  await page.click('[data-visor-scene="0"]');
194
195
  assert.deepEqual(await page.evaluate(() => ({ centred: window.centred, open: OTBVisor.isOpen() })), { centred: true, open: false });
195
196
  await ctx.close();
@@ -103,7 +103,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
103
103
  // task 1005070: the remembered nav (the rail paints before /me).
104
104
  // task 1005088: the sidebar mark is drawn by reference to the one brand mark.
105
105
  // task 1005144: My agents, Project settings, Permissions and Modules left for the options menu.
106
- const SHELL_SHA = 'a4d41b30b41e49f4';
106
+ const SHELL_SHA = '89f6f166ed22a8ee';
107
107
 
108
108
  test('shell.js has not changed without its cache-bust being bumped', () => {
109
109
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);