@bongos/core 1.25.30 → 1.25.32

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 (34) hide show
  1. package/.bongos-core.json +42 -47
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/api/openapi.json +3 -6
  4. package/docs/copy-inventory.md +13 -13
  5. package/docs/copy-registry.json +14 -14
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/page-readings.json +229 -203
  8. package/modules/government/docket.js +37 -6
  9. package/modules/hall-ui/public/city.js +1 -1
  10. package/modules/hall-ui/public/gov-feeds.js +2 -2
  11. package/modules/hall-ui/public/index.html +4 -4
  12. package/modules/hall-ui/public/shell-game.css +2 -3
  13. package/modules/hall-ui/public/shell-game.js +6 -5
  14. package/modules/hall-ui/public/welcome-boot.css +18 -31
  15. package/modules/hall-ui/public/welcome-boot.js +81 -97
  16. package/modules/hall-ui/records/start-screen.md +6 -6
  17. package/modules/hall-ui/records/welcome-tower.md +29 -20
  18. package/modules/lifecycle/routes/tasks.js +1 -1
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/release-notes.json +12 -0
  22. package/src/module-api.js +1 -1
  23. package/tests/docket_load_speed.mjs +99 -0
  24. package/tests/hall_city_living.mjs +1 -1
  25. package/tests/hall_gov_counts.mjs +1 -1
  26. package/tests/hall_gov_real_site.mjs +1 -1
  27. package/tests/hall_sky_time.mjs +3 -2
  28. package/tests/hall_start_screen.mjs +3 -2
  29. package/tests/hall_street_deck.mjs +1 -1
  30. package/tests/hall_street_depth.mjs +2 -2
  31. package/tests/welcome_boot.mjs +35 -27
  32. package/tests/welcome_boot_browser.mjs +25 -19
  33. package/modules/hall-ui/public/welcome-day.webp +0 -0
  34. package/modules/hall-ui/public/welcome-night.webp +0 -0
@@ -631,6 +631,12 @@ async function attachAsks(matters, readAsks, timeoutMs) {
631
631
  // task 1004705) and the docket says `routed: true`. null routes nothing.
632
632
  async function buildDocket({ viewer, permissions, contributions = registeredContributions(), now = Date.now(), timeoutMs = SOURCE_TIMEOUT_MS, decisions = null, government = null, readAsks = null, readFlags = null, seats = null } = {}) {
633
633
  const held = new Set(permissions || []);
634
+ // `decisions`, `government` and `seats` may arrive as promises (docketFor starts
635
+ // those reads alongside the sources, task 1005310); they are awaited once the
636
+ // sources have answered. The handler is attached here, before anything waits, so
637
+ // a failure is never an unhandled rejection while the sources are reading.
638
+ const governmentReads = Promise.all([decisions, government, seats]);
639
+ governmentReads.catch(() => {});
634
640
  const ctx = Object.freeze({
635
641
  builderId: String(viewer.id),
636
642
  rank: viewer.rank || null,
@@ -681,8 +687,11 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
681
687
  const order = declared.map((d) => d.source);
682
688
  sourceStatus.sort((a, b) => order.indexOf(a.source) - order.indexOf(b.source));
683
689
  matters.sort(compareMatters);
684
- await attachAsks(matters, readAsks, timeoutMs);
685
- await attachFlags(matters, readFlags, timeoutMs);
690
+ // Asks and reword flags are two independent reads on the same matters, each
691
+ // writing its own field: read together (task 1005310), alongside the
692
+ // government reads started above.
693
+ await Promise.all([attachAsks(matters, readAsks, timeoutMs), attachFlags(matters, readFlags, timeoutMs)]);
694
+ [decisions, government, seats] = await governmentReads;
686
695
  // THE DIRECTION DESK (task 1004705): who each matter waits on, from real seats.
687
696
  const yours = seats ? routing.routeAll(matters, ctx.builderId, seats, now) : null;
688
697
 
@@ -872,16 +881,38 @@ async function findMatterFor(builder, matterId) {
872
881
 
873
882
  // The route's entry point: resolve the viewer's permissions server-side (fresh,
874
883
  // uncached — ADR 0016), then build.
875
- async function docketFor(builder) {
884
+ //
885
+ // ONE BUILD PER VIEWER AT A TIME (task 1005310). The street asks for the docket
886
+ // and for GET /government/counts in the same breath, and counts is built from
887
+ // this same docket, so every street load built it twice, side by side, and each
888
+ // slowed the other. A second ask for the SAME viewer that arrives while a build
889
+ // is running shares that build; the moment it finishes nothing is kept, so the
890
+ // next ask builds afresh. Nothing is cached: no answer is older than the request
891
+ // that receives it, and permissions are still resolved inside every build.
892
+ const building = new Map();
893
+ function docketFor(builder) {
894
+ const key = String(builder && builder.id);
895
+ const running = building.get(key);
896
+ if (running) return running;
897
+ const p = buildDocketFor(builder).finally(() => { building.delete(key); });
898
+ building.set(key, p);
899
+ return p;
900
+ }
901
+ async function buildDocketFor(builder) {
876
902
  const permissions = await resolver.resolveBuilderPermissions(builder.id);
877
903
  // The table is read per request, uncached (ADR 0016). A failed read leaves
878
904
  // every building's decision null; it never fails the city.
879
- const decisions = await board.decisionRulesView().catch((err) => {
905
+ // The government reads (decision rules → the government → its seats) run
906
+ // ALONGSIDE the sources (task 1005310): buildDocket waits for them only once
907
+ // its sources have answered. Every step below catches its own failure.
908
+ const decisions = board.decisionRulesView().catch((err) => {
880
909
  log.warn({ err: err && err.message }, 'governor docket: decision rules unreadable');
881
910
  return null;
882
911
  });
883
- const government = permissions.includes(GOVERNMENT_GATE.permission) ? await loadGovernment({ decisions }) : null;
884
- const seats = government ? await loadSeats(government) : null;
912
+ const government = permissions.includes(GOVERNMENT_GATE.permission)
913
+ ? decisions.then((d) => loadGovernment({ decisions: d }))
914
+ : Promise.resolve(null);
915
+ const seats = government.then((g) => (g ? loadSeats(g) : null));
885
916
  return buildDocket({ viewer: builder, permissions, decisions, government, readAsks: asksDb.asksFor, readFlags: asksDb.unclearFor, seats });
886
917
  }
887
918
 
@@ -56,7 +56,7 @@
56
56
  leads: '/api/bongos/government/leads',
57
57
  // the pots each goal draws from, the reserve, the spend cap (task 1004712)
58
58
  treasury: '/api/bongos/government/treasury',
59
- claims: '/api/bongos/tasks/claimable?limit=1',
59
+ claims: '/api/bongos/tasks/claimable?view=claims',
60
60
  tasks: '/api/bongos/tasks?status=active&limit=200',
61
61
  cost: '/api/bongos/public/cost-summary',
62
62
  roster: '/api/bongos/builders/roster',
@@ -42,8 +42,8 @@
42
42
  presets: '/government/presets',
43
43
  goals: '/goals',
44
44
  rollups: '/goals/rollups',
45
- // the active claims ride on the claimable feed; one claimable row is plenty
46
- claims: '/tasks/claimable?limit=1',
45
+ // the open claims alone (?view=claims, task 1005310): the claimable feed is not built
46
+ claims: '/tasks/claimable?view=claims',
47
47
  tasks: '/tasks?status=active&limit=200',
48
48
  cost: '/public/cost-summary',
49
49
  roster: '/builders/roster',
@@ -20,8 +20,8 @@
20
20
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
21
21
  <link rel="stylesheet" href="/builders/style.css?v=2026-11-11-helm4d">
22
22
  <link rel="stylesheet" href="/builders/visor.css?v=2026-11-14-world">
23
- <link rel="stylesheet" href="/builders/welcome-boot.css?v=2026-11-07-brand-mark">
24
- <link rel="stylesheet" href="/builders/shell-game.css?v=2026-11-11-view2">
23
+ <link rel="stylesheet" href="/builders/welcome-boot.css?v=2026-11-14-cloud">
24
+ <link rel="stylesheet" href="/builders/shell-game.css?v=2026-11-14-cloud">
25
25
  <link rel="stylesheet" href="/builders/hall-ask.css">
26
26
  <link rel="stylesheet" href="/builders/home-city.css?v=2026-11-14-world">
27
27
  <link rel="stylesheet" href="/builders/home-gallery.css">
@@ -102,8 +102,8 @@
102
102
  BEFORE builders.js so the chrome ids exist when events are wired. -->
103
103
  <!-- task 1004785: the first-visit-of-the-day welcome (the tower). Before shell.js, which asks it
104
104
  before the craft picker so the two never stack. Home only. -->
105
- <script src="/builders/welcome-boot.js?v=2026-11-07-brand-mark" defer></script>
106
- <script src="/builders/shell-game.js?v=2026-11-11-view2" defer></script>
105
+ <script src="/builders/welcome-boot.js?v=2026-11-14-cloud" defer></script>
106
+ <script src="/builders/shell-game.js?v=2026-11-14-cloud" defer></script>
107
107
  <script src="/builders/shell-trail.js?v=2026-11-05-numbers" defer></script>
108
108
  <script src="/builders/craft-mode.js?v=2026-11-10-visor" defer></script>
109
109
  <script src="/builders/shell.js?v=2026-11-13-founding" defer></script>
@@ -1,5 +1,5 @@
1
1
  /* shell-game.css — the game's start screen and load screen (task 1005143; shell-game.js start()),
2
- in the Helm look (task 1005192, ADR 0402). Both stand over the welcome's painting. The start
2
+ in the Helm look (task 1005192, ADR 0402). Both stand over the city in the clouds (task 1005308). The start
3
3
  screen is one full-height night panel on the left, closed left and right by an ice hairline:
4
4
  the mark and the project's name in the name voice, the five choices as full-width rows, one
5
5
  line at the foot. The load screen is the same panel language, centred: the week in the name
@@ -18,11 +18,10 @@ html.gs-open { overflow: hidden; }
18
18
  .gs--leaving { opacity: 0; pointer-events: none; }
19
19
  .gs--load { align-items: center; justify-content: center; padding: var(--ds-space-lg) var(--ds-space-gutter-narrow); }
20
20
 
21
- /* the still: the painting, covering the screen. The start screen leaves it bare (the panel is
21
+ /* the still: the city in the clouds (.cloud-scene, welcome-boot.css), covering the screen. The start screen leaves it bare (the panel is
22
22
  its own ground); the load screen lays the scrim over it so the centred panel reads anywhere. */
23
23
  .gs__art { position: absolute; inset: 0; }
24
24
  .gs--load .gs__art::after { content: ""; position: absolute; inset: 0; background: var(--scrim); }
25
- .gs__img { width: 100%; height: 100%; object-fit: cover; display: block; }
26
25
 
27
26
  /* the two panels: night over the art, an ice hairline left and right, no corner, no shadow */
28
27
  .gs__menu, .gs__load {
@@ -87,9 +87,9 @@
87
87
 
88
88
  // ---------- the start screen and the load screen (task 1005143, BONGOS-V3 R03) ----------
89
89
  // A fresh sign-in opens on the start screen: Start, Options, Achievements, Extras, Quit over a
90
- // still. Start turns game mode on and passes through the load screen, where the project city
90
+ // the city in the clouds (task 1005308; the painting before it is retired). Start turns game mode on and passes through the load screen, where the project city
91
91
  // builds itself up week by week under a tip, and then the world is there. welcome-boot.js calls
92
- // start() on Home (where a sign-in lands) and hands in the still; this file draws both screens.
92
+ // start() on Home (where a sign-in lands) and hands in the scene's painter; this file draws both screens.
93
93
  // Rules are in modules/hall-ui/records/start-screen.md.
94
94
  var MENU = [
95
95
  { id: 'start', label: 'Start' },
@@ -155,7 +155,7 @@
155
155
  }
156
156
  // The Helm panel (task 1005192, ADR 0402): the mark and the name, the choices, one line at the foot.
157
157
  function screenHtml(o) {
158
- return '<div class="gs__art" aria-hidden="true">' + (o.art ? '<img class="gs__img" alt="" src="' + esc(o.art) + '" decoding="async">' : '') + '</div>' +
158
+ return '<div class="gs__art" aria-hidden="true"></div>' +
159
159
  '<div class="gs__menu">' +
160
160
  '<div class="gs__top">' +
161
161
  '<div class="gs__head">' + markHtml() +
@@ -369,8 +369,8 @@
369
369
  return v === 'game' || v === 'classic' ? v : null;
370
370
  }
371
371
 
372
- // Put the start screen up. `o`: { name, art, done, signIn } — the project's name, the still to
373
- // stand behind both screens, what to do once the world is showing, and (signed out) how to sign
372
+ // Put the start screen up. `o`: { name, scene, done, signIn } — the project's name, the painter of
373
+ // the city that stands behind both screens (scene(host), welcome-boot.js paintScene), what to do once the world is showing, and (signed out) how to sign
374
374
  // in once a view is picked. True when it is up; false for a classic pick made before sign-in,
375
375
  // which goes straight to the hall.
376
376
  function start(o) {
@@ -387,6 +387,7 @@
387
387
  root.setAttribute('aria-modal', 'true');
388
388
  root.setAttribute('aria-labelledby', 'gs-title');
389
389
  root.innerHTML = screenHtml(opts);
390
+ if (typeof opts.scene === 'function') { try { opts.scene(root.querySelector('.gs__art')); } catch (_) { /* the ground stands */ } }
390
391
  root.addEventListener('click', onClick);
391
392
  document.addEventListener('keydown', onKey, true);
392
393
  document.body.appendChild(root);
@@ -1,7 +1,8 @@
1
1
  /* welcome-boot.css — the first-visit-of-the-day welcome (tasks 1004785, 1004818; welcome-boot.js).
2
- The scene is the owner's painting of a circuit city (welcome-day.webp / welcome-night.webp) with
3
- motion laid over it. Its palette is the painting's, by time of day rather than by hall theme, so its
4
- ink is fixed here rather than --ink (which flips with the theme). */
2
+ The scene is the city in the clouds, drawn in the hour's sky (task 1005308; the circuit-city
3
+ painting it replaced is retired). Its ink follows day or night rather than the hall theme, so it
4
+ is fixed here rather than --ink. .cloud-scene is also the ground of the game's start and load
5
+ screens (shell-game.js), which this sheet shares the page with. */
5
6
  html.wb-open { overflow: hidden; }
6
7
  .wb {
7
8
  position: fixed; inset: 0; z-index: 1000; overflow: hidden;
@@ -10,35 +11,21 @@ html.wb-open { overflow: hidden; }
10
11
  }
11
12
  .wb--day { color: #14161c; background: linear-gradient(180deg, #a9d6dc 0%, #f1d9d0 55%, #e9cfc6 100%); }
12
13
 
13
- /* the painting: covers the screen at its own 1024x572 shape, so the motion layer stays on its marks */
14
+ /* the scene: the city in the clouds (welcome-boot.js paintScene), on the hour's sky. It covers the
15
+ screen; the city stands on the bottom edge, so a tall screen shows more sky and a wide one crops it. */
14
16
  .wb__art {
15
- position: absolute; left: 50%; top: 50%; z-index: 1;
16
- width: max(100vw, calc(100vh * 1024 / 572)); aspect-ratio: 1024 / 572;
17
- transform: translate(-50%, -50%);
17
+ position: absolute; inset: 0; z-index: 1;
18
18
  transition: transform 2.2s cubic-bezier(.45, 0, .25, 1);
19
19
  }
20
- .wb__pan { position: absolute; inset: 0; transform-origin: 62% 42%; animation: wb-pan 40s ease-in-out infinite alternate; }
21
- @keyframes wb-pan { from { transform: scale(1); } to { transform: scale(1.07) translateX(-1.2%); } }
22
- .wb__img, .wb__fx, .wb-motion { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
23
- .wb__img { object-fit: cover; }
24
- .wb__img[hidden] { display: none; }
25
-
26
- /* the motion: pulses climb the circuit towers, cars cross, and at night the lights blink */
27
- .wb-pulses { opacity: 0; transition: opacity 1.6s ease-out; }
28
- .wb--live .wb-pulses { opacity: 1; }
29
- .wb-pulse { fill: none; stroke-width: 2.2; stroke-linecap: round; stroke-dasharray: 7 100; stroke-dashoffset: 7; animation: wb-climb linear infinite; }
30
- @keyframes wb-climb { to { stroke-dashoffset: -100; } }
31
- .wb--night .wb-pulse { filter: drop-shadow(0 0 3px currentColor); }
32
- .wb--day .wb-pulse { opacity: .8; stroke-width: 1.8; }
33
- .wb-car { animation: wb-car linear infinite; }
34
- @keyframes wb-car {
35
- 0% { transform: translate(0, 0); opacity: 0; }
36
- 10%, 88% { opacity: 1; }
37
- 100% { transform: translate(var(--wb-dx), var(--wb-dy)); opacity: 0; }
20
+ .wb__pan { position: absolute; inset: 0; transform-origin: 50% 70%; animation: wb-pan 40s ease-in-out infinite alternate; }
21
+ @keyframes wb-pan { from { transform: scale(1); } to { transform: scale(1.05) translateX(-1%); } }
22
+ .cloud-scene { background: linear-gradient(to bottom, var(--sky-now-top), var(--sky-now-mid) 46%, var(--sky-now-haze)); }
23
+ .cloud-scene__svg {
24
+ position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none;
25
+ background: radial-gradient(ellipse 70% 60% at 50% 72%, var(--sky-now-cloud), var(--sky-now-base) 30%, var(--sky-now-haze) 62%, transparent);
38
26
  }
39
- .wb-star { opacity: .6; }
40
- .wb-moon { animation: wb-moon 6s ease-in-out infinite alternate; filter: blur(6px); }
41
- @keyframes wb-moon { from { opacity: .35; } to { opacity: .8; } }
27
+ .cloud-scene__svg .hc-stars { opacity: var(--sky-now-stars); }
28
+
42
29
  .wb__cloud { position: absolute; z-index: 2; border-radius: 50%; background: rgba(255, 255, 255, .06); filter: blur(30px); animation: wb-drift linear infinite; pointer-events: none; }
43
30
  .wb--day .wb__cloud { background: rgba(255, 255, 255, .35); filter: blur(28px); }
44
31
  .wb__cloud--a { top: 8vh; width: 40vw; height: 7vh; animation-duration: 80s; animation-delay: -20s; }
@@ -71,7 +58,7 @@ html.wb-open { overflow: hidden; }
71
58
  font-family: var(--font-display); font-size: clamp(18px, 2.6vw, 26px); font-weight: 300;
72
59
  letter-spacing: -.01em; text-transform: lowercase; white-space: nowrap;
73
60
  }
74
- /* the mark's ink follows the painting: pale by night, dark by day; the moon stays salmon */
61
+ /* the mark's ink follows the hour: pale by night, dark by day; the moon stays salmon */
75
62
  .wb__mark { --mark-ink: rgba(255, 255, 255, .88); }
76
63
  .wb__planet { transform-box: fill-box; transform-origin: center; }
77
64
  .wb__moonlet { transform-box: view-box; transform-origin: 100px 100px; }
@@ -137,7 +124,7 @@ html.wb-open { overflow: hidden; }
137
124
  .wb__role small { display: block; margin-top: 3px; }
138
125
  .wb__role i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 7px; }
139
126
 
140
- /* the veil: white by day, black by night, lifting slowly once the painting is in */
127
+ /* the veil: white by day, black by night, lifting slowly once the scene is painted */
141
128
  .wb__veil { position: absolute; inset: 0; z-index: 6; pointer-events: none; background: #000; opacity: 1; transition: opacity 1.6s ease-out; }
142
129
  .wb--day .wb__veil { background: #fff; }
143
130
  .wb--lit .wb__veil { opacity: 0; }
@@ -145,7 +132,7 @@ html.wb-open { overflow: hidden; }
145
132
  /* the way out: the view sinks past the rooftops and the welcome fades into the hall underneath;
146
133
  bound for another room, the veil comes back instead */
147
134
  .wb--leaving { pointer-events: none; }
148
- .wb--leaving .wb__art { transform: translate(-50%, calc(-50% - 30vh)) scale(1.12); }
135
+ .wb--leaving .wb__art { transform: translateY(-30vh) scale(1.12); }
149
136
  .wb--leaving .wb__banner { transform: translate(-50%, -40vh); }
150
137
  .wb--done.wb--leaving .wb__after { opacity: 0; transform: translateY(24px); transition: opacity .5s, transform .5s; }
151
138
  .wb--leaving:not(.wb--to-room) { opacity: 0; transition: opacity 1.8s ease-in .5s; }
@@ -1,13 +1,16 @@
1
1
  // welcome-boot.js — the welcome on a builder's first visit of the day (tasks 1004785, 1004818; idea 1001262).
2
2
  //
3
- // Home only. The scene is the owner's own painting of a circuit city (welcome-day.webp and
4
- // welcome-night.webp, 2026-10-23), brought to life: a slow drift across the skyline, data pulses
5
- // climbing the circuit towers, hover cars crossing, and by night blinking aircraft lights, a breathing
6
- // moon and twinkling stars. The banner at the top is the logo, animated, and a "Built on
7
- // cloudbongos.com" credit sits in the bottom-right corner (task 1004953). The picture follows the local time of day: the day painting from 06:00 to 17:59, the
8
- // night one otherwise. It opens from a white (day) or black (night) veil, then "Good morning, <name>"
9
- // comes up with two choices. The way out is slow: the view sinks past the rooftops and fades into the
10
- // hall underneath.
3
+ // Home only. The scene is the city in the clouds (task 1005196), drawn by home-city-draw.js and
4
+ // city-draw.js in the hour's sky (sky-time.js): the Cloud Bongos core and the museum standing in
5
+ // cloud, with a slow drift. It replaced the owner's circuit-city painting (a day and a
6
+ // night WebP still), retired everywhere by task 1005308 (owner, 2026-11-14), and with it the
7
+ // lights that were pinned to the painting's pixels. paintScene() is also what the game's start and
8
+ // load screens stand on (shell-game.js), so every screen before the hall shows the same city.
9
+ // The banner at the top is the logo, animated, and a "Built on cloudbongos.com" credit sits in the
10
+ // bottom-right corner (task 1004953). The ink follows the local time of day: day from 06:00 to
11
+ // 17:59, night otherwise. It opens from a white (day) or black (night) veil, then
12
+ // "Good morning, <name>" comes up with two choices. The way out is slow: the view sinks past the
13
+ // rooftops and fades into the hall underneath.
11
14
  //
12
15
  // Rules:
13
16
  // - A signed-in builder sees it once per LOCAL day. A fresh sign-in (the sign-in click leaves a
@@ -22,10 +25,11 @@
22
25
  // picker asked (OTBCraftMode.markAsked), so the two never stack.
23
26
  // - Picking a role also sets the hall's craft mode. Governing is a seat (craft-mode.js), so the
24
27
  // Owner role shows only when OTBCraftMode offers it.
25
- // - A painting that will not load leaves the sky gradient behind it, and the welcome works the same.
26
- // - prefers-reduced-motion: no motion; the still painting and the greeting at once, leaving is immediate.
28
+ // - Without the city's painters (a page that does not load them) the hour's sky gradient stands alone,
29
+ // and the welcome works the same.
30
+ // - prefers-reduced-motion: no motion; the still city and the greeting at once, leaving is immediate.
27
31
  //
28
- // The pure parts (the day rule, the logo, the roles, the motion layout) are exported for
32
+ // The pure parts (the day rule, the logo, the roles, the scene's node rewrite) are exported for
29
33
  // tests/welcome_boot.mjs; tests/welcome_boot_browser.mjs runs the overlay itself in a real browser.
30
34
  (function () {
31
35
  'use strict';
@@ -33,8 +37,7 @@
33
37
  var DAY_KEY = 'hall-welcome-day:';
34
38
  var SIGNIN_KEY = 'hall-welcome-signin';
35
39
  var SIGNIN_HREF = '/api/bongos/auth/web/start';
36
- var ART = { day: '/builders/welcome-day.webp', night: '/builders/welcome-night.webp' };
37
- var ART_WAIT = 1500; // the veil lifts once the painting is in, or after this long
40
+ var ART_WAIT = 1500; // the veil lifts once the scene is painted, or after this long
38
41
  var RUN_MS = 3400; // the city powering up before the greeting
39
42
  var LEAVE_MS = 2400; // the way down into the hall
40
43
 
@@ -46,9 +49,9 @@
46
49
  var h = now.getHours();
47
50
  return h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
48
51
  }
49
- // Day is 06:00 to 17:59 local: a white veil and the day painting. Night: a black veil and the night one.
52
+ // Day is 06:00 to 17:59 local: a white veil and dark ink. Night: a black veil and pale ink.
53
+ // (The scene's own colours follow the hour more finely: sky-time.js.)
50
54
  function timeOfDay(now) { var h = now.getHours(); return h >= 6 && h < 18 ? 'day' : 'night'; }
51
- function artFor(when) { return when === 'day' ? ART.day : ART.night; }
52
55
  function nameOf(builder) {
53
56
  return String((builder && (builder.display_name || builder.github_login)) || 'builder');
54
57
  }
@@ -88,73 +91,61 @@
88
91
  }
89
92
  function roleById(id) { return ROLES.filter(function (r) { return r.id === id; })[0] || null; }
90
93
 
91
- // ---------- the motion, laid over the painting ----------
92
- // In the painting's own pixels (1024x572; both paintings share one layout), so every light sits on
93
- // the thing it belongs to however the picture is cropped to the screen.
94
- var AW = 1024, AH = 572;
95
- // The circuit towers: a box each, and the colour its traces glow in the night painting.
96
- var TOWERS = [
97
- { x: 140, y: 168, w: 62, h: 118, glow: '#60a5fa' },
98
- { x: 280, y: 150, w: 58, h: 200, glow: '#22d3ee' },
99
- { x: 184, y: 296, w: 100, h: 196, glow: 'var(--accent)' },
100
- { x: 636, y: 222, w: 42, h: 140, glow: 'color-mix(in srgb, var(--sea) 55%, #ffffff)' },
101
- { x: 702, y: 212, w: 84, h: 190, glow: 'color-mix(in srgb, var(--sea) 55%, #ffffff)' },
102
- { x: 2, y: 196, w: 52, h: 150, glow: 'color-mix(in srgb, var(--sea) 55%, #ffffff)' },
103
- { x: 910, y: 410, w: 110, h: 160, glow: '#22d3ee' },
104
- ];
105
- // The hover-car lanes on the right (the masts' blinking lights went in task 1005087).
106
- var LANES = [
107
- { x1: 770, y1: 350, x2: 1040, y2: 362, dur: 5.5 },
108
- { x1: 1040, y1: 340, x2: 760, y2: 352, dur: 7 },
109
- { x1: 820, y1: 258, x2: 1040, y2: 254, dur: 6.5 },
110
- ];
111
- var MOON = [665, 82];
112
- function seeded(s) { return function () { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; }; }
113
- // Where each data pulse climbs: a vertical run inside a tower, bottom to top. Pure, so it is testable.
114
- function pulseRuns(towers, perTower, seed) {
115
- var rnd = seeded(seed || 7), out = [];
116
- towers.forEach(function (t, ti) {
117
- for (var i = 0; i < perTower; i++) {
118
- var x = Math.round((t.x + 6 + rnd() * (t.w - 12)) * 10) / 10;
119
- var y1 = Math.round((t.y + t.h * (0.75 + rnd() * 0.25)) * 10) / 10;
120
- var y0 = Math.round((t.y + t.h * rnd() * 0.3) * 10) / 10;
121
- out.push({ tower: ti, x: x, y0: y0, y1: y1, glow: t.glow, dur: Math.round((2.2 + rnd() * 2.4) * 100) / 100, delay: Math.round(rnd() * 400) / 100 });
122
- }
123
- });
124
- return out;
125
- }
126
-
94
+ // ---------- the scene: the city in the clouds (task 1005308) ----------
95
+ // Home's own painters draw it (window.OTBHomeCityDraw + window.GovCity), from an empty week: the
96
+ // Cloud Bongos core and the museum, standing in cloud, in the hour's --sky-now-* tokens. It is a
97
+ // backdrop, so every door the city carries on Home (a link, a tooltip, a goal) becomes a plain
98
+ // group, nothing in it takes focus, and its ids are prefixed so they never meet Home's own city's.
127
99
  var NS = 'http://www.w3.org/2000/svg';
128
- function el(t, a, p) { var e = document.createElementNS(NS, t); for (var k in a) e.setAttribute(k, a[k]); if (p) p.appendChild(e); return e; }
129
-
130
- function drawMotion(host, when) {
131
- var night = when !== 'day';
132
- var rnd = seeded(11);
133
- var svg = el('svg', { viewBox: '0 0 ' + AW + ' ' + AH, preserveAspectRatio: 'none', 'class': 'wb-motion' }, host);
134
- if (night) {
135
- // the moon breathes; the stars stand still (task 1005087: no twinkle, no beacons)
136
- el('circle', { 'class': 'wb-moon', cx: MOON[0], cy: MOON[1], r: 26, fill: 'rgba(226,232,255,.25)' }, svg);
137
- for (var i = 0; i < 40; i++) {
138
- var sx = rnd() * AW, sy = rnd() * 140;
139
- if (Math.abs(sx - MOON[0]) < 50 && Math.abs(sy - MOON[1]) < 50) continue;
140
- el('circle', { 'class': 'wb-star', cx: sx.toFixed(1), cy: sy.toFixed(1), r: (0.6 + rnd() * 0.9).toFixed(2), fill: '#e0e7ff' }, svg);
141
- rnd(); rnd(); // the two draws the twinkle timing took, so every star keeps its place
142
- }
143
- }
144
- // data pulses climbing the circuit towers (the whole set brightens as the city powers up)
145
- var pulses = el('g', { 'class': 'wb-pulses' }, svg);
146
- pulseRuns(TOWERS, 3, 7).forEach(function (r) {
147
- el('line', { 'class': 'wb-pulse', x1: r.x, y1: r.y1, x2: r.x, y2: r.y0, pathLength: 100, stroke: night ? r.glow : '#ffffff',
148
- style: 'color:' + r.glow + ';animation-duration:' + r.dur + 's;animation-delay:' + (-r.delay) + 's' }, pulses);
149
- });
150
- // hover cars, each a light with a short trail, crossing on its lane
151
- LANES.forEach(function (l, i) {
152
- var g = el('g', { 'class': 'wb-car', style: '--wb-dx:' + (l.x2 - l.x1) + 'px;--wb-dy:' + (l.y2 - l.y1) + 'px;animation-duration:' + l.dur + 's;animation-delay:' + (-i * 1.7) + 's' }, svg);
153
- var dir = l.x2 > l.x1 ? -1 : 1;
154
- el('line', { x1: l.x1, y1: l.y1, x2: l.x1 + dir * 26, y2: l.y1, stroke: night ? 'rgba(125,211,252,.55)' : 'rgba(255,255,255,.7)', 'stroke-width': 2, 'stroke-linecap': 'round' }, g);
155
- el('circle', { cx: l.x1, cy: l.y1, r: 2.4, fill: night ? '#e0f2fe' : '#ffffff' }, g);
100
+ var SCENE_ID = 'cs-';
101
+ var DOOR_ATTRS = /^(href|tabindex|aria-label|data-tip|data-tip-body|data-goal|data-group|data-building|data-craft-door)$/;
102
+ var QUIET_RE = /\bhc-quiet\b/g, SPACES_RE = /\s+/g, URL_REF_RE = /url\(#/g;
103
+ // One shape node rewritten for the backdrop: { tag, attrs, kids, text } -> the same, door-free. Pure.
104
+ function sceneShape(n) {
105
+ var attrs = {};
106
+ Object.keys(n.attrs || {}).forEach(function (k) {
107
+ if (DOOR_ATTRS.test(k)) return;
108
+ var v = String(n.attrs[k]);
109
+ if (k === 'id') v = SCENE_ID + v;
110
+ else if (k === 'class') v = v.replace(QUIET_RE, '').replace(SPACES_RE, ' ').trim();
111
+ else v = v.replace(URL_REF_RE, 'url(#' + SCENE_ID);
112
+ if (k === 'class' && !v) return;
113
+ attrs[k] = v;
156
114
  });
157
- return svg;
115
+ return { tag: n.tag === 'a' ? 'g' : n.tag, attrs: attrs, kids: (n.kids || []).map(sceneShape), text: n.text == null ? null : n.text };
116
+ }
117
+ // The scene as shapes, or null when the painters are not on the page. Pure given the painters.
118
+ function sceneShapes(Draw, Gov) {
119
+ if (!Draw || typeof Draw.citySvg !== 'function' || typeof Draw.cityModel !== 'function') return null;
120
+ var out = Draw.citySvg(Draw.cityModel({ weeks: [{}] }), 0, { gov: Gov || null });
121
+ return { viewBox: out.viewBox, nodes: out.nodes.map(sceneShape) };
122
+ }
123
+ function build(n) {
124
+ var e = document.createElementNS(NS, n.tag);
125
+ Object.keys(n.attrs).forEach(function (k) { e.setAttribute(k, n.attrs[k]); });
126
+ if (n.text != null) e.textContent = n.text;
127
+ n.kids.forEach(function (c) { e.appendChild(build(c)); });
128
+ return e;
129
+ }
130
+ // Paint the city into host (it covers it; the host's class gives it the hour's sky). True when
131
+ // the city is drawn; false leaves the sky alone, which is still the right picture.
132
+ function paintScene(host) {
133
+ if (!host || typeof document === 'undefined') return false;
134
+ host.classList.add('cloud-scene');
135
+ var sky = window.OTBSkyTime;
136
+ if (sky && typeof sky.apply === 'function') { try { sky.apply(document); } catch (_) { /* the tokens' night default stands */ } }
137
+ var shapes;
138
+ try { shapes = sceneShapes(window.OTBHomeCityDraw, window.GovCity); } catch (_) { shapes = null; }
139
+ if (!shapes) return false;
140
+ var svg = document.createElementNS(NS, 'svg');
141
+ svg.setAttribute('class', 'cloud-scene__svg');
142
+ svg.setAttribute('viewBox', shapes.viewBox);
143
+ svg.setAttribute('preserveAspectRatio', 'xMidYMax slice');
144
+ svg.setAttribute('aria-hidden', 'true');
145
+ svg.setAttribute('focusable', 'false');
146
+ shapes.nodes.forEach(function (n) { svg.appendChild(build(n)); });
147
+ host.appendChild(svg);
148
+ return true;
158
149
  }
159
150
 
160
151
  // ---------- the overlay ----------
@@ -170,10 +161,7 @@
170
161
 
171
162
  function bannerTag(builder) { return builder ? 'div' : 'header'; }
172
163
  function overlayHtml(builder, now, when) {
173
- return '<div class="wb__art" aria-hidden="true"><div class="wb__pan">' +
174
- '<img class="wb__img" alt="" src="' + artFor(when) + '" decoding="async">' +
175
- '<div class="wb__fx" id="wb-fx"></div>' +
176
- '</div></div>' +
164
+ return '<div class="wb__art" aria-hidden="true"><div class="wb__pan" id="wb-clouds"></div></div>' +
177
165
  '<div class="wb__cloud wb__cloud--a" aria-hidden="true"></div><div class="wb__cloud wb__cloud--b" aria-hidden="true"></div>' +
178
166
  // For a visitor the welcome IS the page (the shell draws no header for them), so its logo
179
167
  // banner is the page's one <header>; over a signed-in hall the shell's header stands, and the
@@ -224,7 +212,7 @@
224
212
  if (first && first.focus) first.focus();
225
213
  }
226
214
 
227
- // The veil lifts once the painting is in (or after ART_WAIT), then the city powers up.
215
+ // The veil lifts once the scene is painted (or after ART_WAIT), then the city powers up.
228
216
  function begin() {
229
217
  if (!root || root.classList.contains('wb--lit')) return;
230
218
  root.classList.add('wb--lit');
@@ -324,13 +312,9 @@
324
312
  document.addEventListener('keydown', onKey, true);
325
313
  document.body.appendChild(root);
326
314
  document.documentElement.classList.add('wb-open');
327
- drawMotion(root.querySelector('#wb-fx'), when);
328
- var img = root.querySelector('.wb__img');
329
- // A painting that fails to load is hidden, leaving the sky gradient; the welcome goes on regardless.
330
- var go1 = function () { if (window.requestAnimationFrame) window.requestAnimationFrame(function () { window.requestAnimationFrame(begin); }); else begin(); };
331
- img.addEventListener('load', go1);
332
- img.addEventListener('error', function () { img.hidden = true; if (root) root.classList.add('wb--bare'); go1(); });
333
- if (img.complete && img.naturalWidth) go1();
315
+ // No city painters on the page: the hour's sky stands alone; the welcome goes on regardless.
316
+ if (!paintScene(root.querySelector('#wb-clouds'))) root.classList.add('wb--bare');
317
+ if (window.requestAnimationFrame) window.requestAnimationFrame(function () { window.requestAnimationFrame(begin); });
334
318
  at(ART_WAIT, begin);
335
319
  var skip = root.querySelector('[data-wb-skip]');
336
320
  if (skip && skip.focus) skip.focus();
@@ -378,7 +362,7 @@
378
362
  // The hall's front opens on Game or Classic (task 1005219): the pick is kept, the visitor
379
363
  // signs in, and comes back to that menu (shell-game.js). Without that file, the welcome.
380
364
  if (canChoose && game.start({
381
- name: projectName(window.__BRANDING__), art: artFor(timeOfDay(new Date())),
365
+ name: projectName(window.__BRANDING__), scene: paintScene,
382
366
  signIn: function () { markSigningIn(); go(SIGNIN_HREF); },
383
367
  })) return true;
384
368
  show(null);
@@ -398,11 +382,11 @@
398
382
  shown = true;
399
383
  var c = craft();
400
384
  // A fresh sign-in, or a new visit to Home, opens on the game's start screen instead (tasks
401
- // 1005143, 1005219; shell-game.js): the same still behind it, and the craft picker asked once
385
+ // 1005143, 1005219; shell-game.js): the same city behind it, and the craft picker asked once
402
386
  // the world is showing. Classic picked before signing in: no screen, straight to the hall.
403
387
  if ((fresh || ask) && canChoose) {
404
388
  if (game.start({
405
- name: projectName(window.__BRANDING__), art: artFor(timeOfDay(now)),
389
+ name: projectName(window.__BRANDING__), scene: paintScene,
406
390
  done: function () { if (c && typeof c.onAccess === 'function') c.onAccess(access, builder); },
407
391
  })) return true;
408
392
  if (fresh) return false;
@@ -413,9 +397,9 @@
413
397
  }
414
398
 
415
399
  var api = {
416
- DAY_KEY: DAY_KEY, SIGNIN_KEY: SIGNIN_KEY, ROLES: ROLES, ART: ART, TOWERS: TOWERS, LANES: LANES, AW: AW, AH: AH,
400
+ DAY_KEY: DAY_KEY, SIGNIN_KEY: SIGNIN_KEY, ROLES: ROLES, SCENE_ID: SCENE_ID,
417
401
  localDay: localDay, firstVisitToday: firstVisitToday, greetingFor: greetingFor, nameOf: nameOf, timeOfDay: timeOfDay,
418
- artFor: artFor, projectName: projectName, logoHtml: logoHtml, BUILT_ON: BUILT_ON, rolesFor: rolesFor, pulseRuns: pulseRuns,
402
+ projectName: projectName, logoHtml: logoHtml, BUILT_ON: BUILT_ON, rolesFor: rolesFor, sceneShape: sceneShape, sceneShapes: sceneShapes, paintScene: paintScene,
419
403
  onAccess: onAccess, finish: finish, close: close,
420
404
  };
421
405
  if (typeof window !== 'undefined') { window.OTBWelcome = api; watchSignInLinks(); }
@@ -4,23 +4,23 @@ Covers: Home (`shell-game.js` `start()`, `shell-game.css`, the hand-off in `welc
4
4
 
5
5
  ## What it settled
6
6
 
7
- - **A fresh sign-in opens on the start screen, on Home.** Home is where a sign-in lands (`postLoginLanding`), and `welcome-boot.js` already knows a fresh sign-in from a return visit (the flag the sign-in click leaves). On one, `OTBWelcome.onAccess` calls `OTBShellGame.start({ name, art, done })` in place of its own greeting. The day's first visit without a sign-in keeps the welcome; a signed-out visitor keeps the welcome. A sign-in that returns to a deep link or another page goes straight there, as the welcome always did. `shell.js` is untouched.
7
+ - **A fresh sign-in opens on the start screen, on Home.** Home is where a sign-in lands (`postLoginLanding`), and `welcome-boot.js` already knows a fresh sign-in from a return visit (the flag the sign-in click leaves). On one, `OTBWelcome.onAccess` calls `OTBShellGame.start({ name, scene, done })` in place of its own greeting. The day's first visit without a sign-in keeps the welcome; a signed-out visitor keeps the welcome. A sign-in that returns to a deep link or another page goes straight there, as the welcome always did. `shell.js` is untouched.
8
8
  - **Five choices, one column: Start, Options, Achievements, Extras, Quit.** Start is the one filled pill. Options is a link to `/settings`, which IS the one options menu since task 1005144 (`records/options-menu.md`); Achievements is `/profile`. Extras opens three links under the menu (the thinking sky, the atlas, the docs). Quit calls `window.close()`; a browser only closes a tab a script opened, so when the tab is still there 300ms later the screen says "Close it to quit" instead of doing nothing.
9
9
  - **Start turns game mode on** (`set(true)`), then shows the load screen. Nothing else in the hall's interface sets it.
10
- - **The load screen is the still, a tip, and the city building up.** The still is the welcome's painting for the time of day, handed in by `welcome-boot.js` (this file names no picture). In a card of the hall's own tokens, the project city is drawn by `home-city-draw.js` `citySvg` from the one bounded read Home already uses (`GET /home/city?weeks=10`), at 31 stops from the oldest week to now, about three seconds, with "The city, N weeks ago" under it. Each stop is a whole drawing set as state; nothing animates in JavaScript. With reduced motion it is the finished city at once. With no city to draw (the read failed, the scripts are missing) the still and the tip stand for 1.4s. Either way the world opens.
10
+ - **The load screen is the still, a tip, and the city building up.** The still is the city in the clouds in the hour's sky, painted by `welcome-boot.js` `paintScene`, which is handed in as `scene` (this file draws no city of its own for it; task 1005308). In a card of the hall's own tokens, the project city is drawn by `home-city-draw.js` `citySvg` from the one bounded read Home already uses (`GET /home/city?weeks=10`), at 31 stops from the oldest week to now, about three seconds, with "The city, N weeks ago" under it. Each stop is a whole drawing set as state; nothing animates in JavaScript. With reduced motion it is the finished city at once. With no city to draw (the read failed, the scripts are missing) the still and the tip stand for 1.4s. Either way the world opens.
11
11
  - **Tips rotate** (`localStorage 'hall-game-tip'` counts them), so each load shows the next one. A tip must be true of the hall today; the first one is the Escape rule from `game-mode.md` (rewritten by task 1005144, and again by task 1005250: Escape opens the game menu).
12
12
  - **The craft picker waits for the world.** While the start screen is up the shell asks no picker (`onAccess` returned true). `done` runs once the screen is gone and asks `OTBCraftMode.onAccess` then.
13
13
  - **Keys.** While either screen is up it keeps every key: the arrows and Tab walk the menu, Escape folds Extras away and does nothing else, so the shell's Escape cannot flip the bars underneath. Once the world shows, the last Escape opens the game menu (task 1005250; the options menu until then); the screen's capture listener calls `preventDefault`, so the menu never opens from under it.
14
- - **The look is Helm (task 1005192, [ADR 0402](../../../docs/adr/0402-helm-is-the-halls-look-and-the-hall-is-dark-only.md)).** The start screen is one full-height night panel over the painting (`--night-glass`, an ice hairline left and right at `--line-strong`), `--helm-panel-w` wide. On a desk it stands off the left edge by `--helm-inset`; that token is a `clamp`, so on a phone the panel is the whole width and the sheet still has no `@media`. In it: the mark (the project's logo when it has one) over the name in the name voice; Start as a full-width accent bar under `--accent-on` that names its key ("Enter", drawn by CSS so the choice's own text stays "Start"); the other four as quiet rows on `--rule-soft` hairlines; and at the foot, over the strong hairline, the tip the load screen will show next (`#gs-line`), with the Quit note under it when the browser keeps the tab. The rows are list rows in a panel, so they are bars, not cut buttons. A row's focus is the inset two-tone ring (`--press-focus`), which is what makes focus visible on the accent bar.
15
- - **The load screen is the same panel, centred** over the painting under the hall's `--scrim`: the week in the name voice (`#gs-when`), one segment per week of the history (`#gs-segs`, the shared `.segs` / `.seg`; filled up to the week being shown, counted the way the sentence words it, and hidden from a screen reader because the sentence says the same), the city, and the tip under a hairline. With no city there are no segments.
14
+ - **The look is Helm (task 1005192, [ADR 0402](../../../docs/adr/0402-helm-is-the-halls-look-and-the-hall-is-dark-only.md)).** The start screen is one full-height night panel over the city in the clouds (`--night-glass`, an ice hairline left and right at `--line-strong`), `--helm-panel-w` wide. On a desk it stands off the left edge by `--helm-inset`; that token is a `clamp`, so on a phone the panel is the whole width and the sheet still has no `@media`. In it: the mark (the project's logo when it has one) over the name in the name voice; Start as a full-width accent bar under `--accent-on` that names its key ("Enter", drawn by CSS so the choice's own text stays "Start"); the other four as quiet rows on `--rule-soft` hairlines; and at the foot, over the strong hairline, the tip the load screen will show next (`#gs-line`), with the Quit note under it when the browser keeps the tab. The rows are list rows in a panel, so they are bars, not cut buttons. A row's focus is the inset two-tone ring (`--press-focus`), which is what makes focus visible on the accent bar.
15
+ - **The load screen is the same panel, centred** over the city in the clouds under the hall's `--scrim`: the week in the name voice (`#gs-when`), one segment per week of the history (`#gs-segs`, the shared `.segs` / `.seg`; filled up to the week being shown, counted the way the sentence words it, and hidden from a screen reader because the sentence says the same), the city, and the tip under a hairline. With no city there are no segments.
16
16
  - **The literal floor is zero.** Every colour, size, corner, shadow, face and measure in `shell-game.css` is a hall-tier token (`style.css`); `tests/hall_start_screen.mjs` fails on a typed one. The fade into the world is one CSS opacity transition, stopped by the hall's reduced-motion rule.
17
17
 
18
18
  ## For the tasks after this one
19
19
 
20
20
  - R04 (task 1005144): point Options at the options menu and move Achievements/Extras targets if those pages lose their interfaces. Escape on the start screen is free for the menu to use.
21
- - `shell-game.js` is loaded on every hall page but only Home calls `start()`; `shell-game.css` is linked on Home only. A second page that wants the start screen links the sheet and calls `start()` with its own still.
21
+ - `shell-game.js` is loaded on every hall page but only Home calls `start()`; `shell-game.css` is linked on Home only. A second page that wants the start screen links the sheet and calls `start()` with its own `scene` painter.
22
22
 
23
- - New art for the start screen (the city in the clouds) is task 1005196; `welcome-boot.js` still hands in the welcome's painting.
23
+ - Since task 1005308 (owner, 2026-11-14) both screens stand on the city in the clouds from task 1005196; the circuit-city painting is retired everywhere.
24
24
 
25
25
  Tests: `tests/hall_start_screen.mjs` (the hand-off and pure parts in a vm; both screens, the panel's place at desk and phone widths, the segments, Quit, the no-city and reduced-motion paths in a real browser).
26
26