@bongos/core 1.25.5 → 1.25.7
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.
- package/.bongos-core.json +69 -49
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +56 -2
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +20 -19
- package/docs/copy-registry.json +32 -23
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +49 -52
- package/modules/hall-ui/public/shell-game.js +132 -5
- package/modules/hall-ui/public/style.css +59 -0
- package/modules/hall-ui/public/visor.css +11 -0
- package/modules/hall-ui/public/visor.js +14 -5
- package/modules/hall-ui/public/welcome-boot.js +27 -11
- package/modules/hall-ui/records/game-menu.md +22 -0
- package/modules/hall-ui/records/game-mode.md +2 -2
- package/modules/hall-ui/records/options-menu.md +1 -1
- package/modules/hall-ui/records/start-screen.md +12 -2
- package/modules/hall-ui/records/visor.md +4 -0
- package/modules/onboarding/genesis-home.js +35 -1
- package/modules/onboarding/routes/onboarding.js +7 -2
- package/modules/provisioning/catalog-bridge.js +3 -0
- package/modules/provisioning/paid-shape-gate.js +3 -1
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/routes/provisioning.js +7 -4
- package/modules/provisioning/test-project.js +65 -0
- package/modules/ui-design/public/brand.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +25 -0
- package/scripts/bongos/government-walk.js +2 -2
- package/src/branding.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/currency_label.mjs +3 -1
- package/tests/hall_game_menu.mjs +195 -0
- package/tests/hall_options_menu.mjs +4 -1
- package/tests/hall_start_screen.mjs +62 -8
- package/tests/hall_visor.mjs +40 -5
- package/tests/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_paid_shape_gate.mjs +16 -0
- package/tests/provisioning_settings_env.mjs +2 -2
- package/tests/test_project.mjs +122 -0
- package/tests/wizard_demo.mjs +25 -4
|
@@ -313,6 +313,16 @@
|
|
|
313
313
|
/* THE PLATE ON THE SKY (task 1005196): words and controls that sit on the hour's sky stand
|
|
314
314
|
on a small plate of the night, sized to what it holds, never on a darkened sky. */
|
|
315
315
|
--night-plate: color-mix(in srgb, var(--bg) 88%, transparent);
|
|
316
|
+
/* THE SMOKED PANE (task 1005252, the owner's pick from the glass lab): a plate on the visor
|
|
317
|
+
blurs what is behind it, so a scene's own words and marks (a screenshot of the hall under
|
|
318
|
+
review carries a visor of its own) go soft instead of reading as a second visor. Lighter
|
|
319
|
+
than the plate, but the backdrop is dimmed too, so ice on it still holds AA over a white
|
|
320
|
+
sky; a lit top edge makes it a pane. Where blur is missing or less transparency is asked
|
|
321
|
+
for, the plate stays the night plate. */
|
|
322
|
+
--smoke: color-mix(in srgb, var(--bg) 62%, transparent);
|
|
323
|
+
--smoke-blur: blur(14px) saturate(.8) brightness(.85);
|
|
324
|
+
--smoke-sheen: linear-gradient(to bottom, color-mix(in srgb, var(--ink-soft) 16%, transparent), transparent 40%);
|
|
325
|
+
--smoke-edge: inset 0 1px 0 color-mix(in srgb, var(--ink-soft) 35%, transparent), inset 0 -1px 0 color-mix(in srgb, var(--bg) 60%, transparent);
|
|
316
326
|
--line-strong: 1.5px;
|
|
317
327
|
--helm-panel-w: 400px;
|
|
318
328
|
--helm-row: 60px;
|
|
@@ -504,6 +514,14 @@ html[data-faces="helm"] {
|
|
|
504
514
|
--font-body: var(--helm-body);
|
|
505
515
|
}
|
|
506
516
|
|
|
517
|
+
/* Less transparency asked for (task 1005252): the visor's smoked panes go back to plates of the
|
|
518
|
+
night. `html` lifts it over visor.css, which loads after this sheet. */
|
|
519
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
520
|
+
html .visor__place, html .visor__who, html .visor__hint, html .visor__progress-words {
|
|
521
|
+
background: var(--night-plate); box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
507
525
|
/* The Kill Switch Rule (DESIGN.md). Nothing escapes it, and no motion is
|
|
508
526
|
authored in JavaScript, because JavaScript motion escapes it. */
|
|
509
527
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -891,6 +909,9 @@ html[data-theme="dark"] .brand-mark__mark { --mark-ink: #e2e1e4; }
|
|
|
891
909
|
html[data-game="on"] .visor__hint:not([data-visor-sheet]) { display: none; }
|
|
892
910
|
html[data-game="on"] .visor__hint--sheet { min-width: 44px; justify-content: center; }
|
|
893
911
|
html[data-game="on"] .visor__hint--sheet .visor__dots { display: block; }
|
|
912
|
+
/* task 1005252: a phone keeps the night plate, not the smoked pane — a live blur under a HUD
|
|
913
|
+
that the scene pans beneath is the costly kind of drawing, and a phone has the weakest GPU */
|
|
914
|
+
html[data-game="on"] .visor__who, html[data-game="on"] .visor__hint { background: var(--night-plate); box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
894
915
|
html[data-game="on"] .visor__hint--sheet > :not(.visor__dots) { position: absolute; width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
895
916
|
html[data-game="on"] .visor__main .visor__key { display: none; }
|
|
896
917
|
html[data-game="on"] .visor__sheet { bottom: calc(var(--visor-inset-bottom) + 112px); max-height: 60vh; }
|
|
@@ -1516,6 +1537,44 @@ html.opt-window-open { overflow: hidden; }
|
|
|
1516
1537
|
}
|
|
1517
1538
|
.opt-window__x:hover { color: var(--ink); background: var(--glass); }
|
|
1518
1539
|
.opt-window__x:focus-visible { outline: 0; box-shadow: var(--press-focus); }
|
|
1540
|
+
/* THE GAME MENU (shell-game.js openMenu, task 1005250): game mode's Escape. One small
|
|
1541
|
+
night panel over the place, its rows stacked as buttons in three runs (what you can
|
|
1542
|
+
open, leaving, going back); Return to game is the one lit row. */
|
|
1543
|
+
html.game-menu-open { overflow: hidden; }
|
|
1544
|
+
.game-menu { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; }
|
|
1545
|
+
.game-menu[hidden] { display: none; }
|
|
1546
|
+
.game-menu::before { content: ''; position: absolute; inset: 0; background: var(--scrim); opacity: .33; pointer-events: none; }
|
|
1547
|
+
.game-menu__box {
|
|
1548
|
+
position: relative; box-sizing: border-box;
|
|
1549
|
+
width: min(300px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow-y: auto;
|
|
1550
|
+
padding: var(--ds-space-md);
|
|
1551
|
+
background: var(--night-glass);
|
|
1552
|
+
border: var(--line-strong) solid var(--rule);
|
|
1553
|
+
border-radius: var(--press-radius);
|
|
1554
|
+
box-shadow: var(--ds-shadow-pop);
|
|
1555
|
+
}
|
|
1556
|
+
.game-menu__title {
|
|
1557
|
+
margin: 0 0 var(--ds-space-md); text-align: center;
|
|
1558
|
+
font-family: var(--name-face); font-size: var(--ds-text-xl); font-weight: var(--name-weight);
|
|
1559
|
+
letter-spacing: var(--name-space); text-transform: var(--name-case); color: var(--ink);
|
|
1560
|
+
}
|
|
1561
|
+
.game-menu__items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--ds-space-xs); }
|
|
1562
|
+
.game-menu__gap { margin-top: var(--ds-space-sm); }
|
|
1563
|
+
.game-menu__item {
|
|
1564
|
+
display: flex; align-items: center; justify-content: center; box-sizing: border-box;
|
|
1565
|
+
width: 100%; min-height: 40px; padding: var(--ds-space-hair) var(--ds-space-sm);
|
|
1566
|
+
background: var(--glass); color: var(--ink);
|
|
1567
|
+
border: var(--line) solid var(--rule); border-radius: var(--press-radius);
|
|
1568
|
+
font-family: var(--name-face); font-size: var(--ds-text-xl); font-weight: 400;
|
|
1569
|
+
letter-spacing: var(--name-space); text-transform: var(--name-case); text-align: center;
|
|
1570
|
+
text-decoration: none; cursor: pointer;
|
|
1571
|
+
}
|
|
1572
|
+
.game-menu__item:hover { color: var(--ink); border-color: var(--ink-soft); text-decoration: none; }
|
|
1573
|
+
.game-menu__item:focus-visible { outline: 0; box-shadow: var(--press-focus); }
|
|
1574
|
+
.game-menu__item--lit { background: var(--accent); color: var(--accent-on); border-color: transparent; font-weight: var(--name-weight-lit); }
|
|
1575
|
+
.game-menu__item--lit:hover { background: var(--accent-lift); color: var(--accent-on); border-color: transparent; }
|
|
1576
|
+
.game-menu__note { margin: var(--ds-space-sm) 0 0; font-size: var(--ds-text-md); line-height: 1.5; text-align: center; text-wrap: pretty; color: var(--ink); }
|
|
1577
|
+
.game-menu__note[hidden] { display: none; }
|
|
1519
1578
|
/* Said once on entering, since the choice is remembered and a page can open in it. */
|
|
1520
1579
|
.fullscreen-hint {
|
|
1521
1580
|
position: fixed;
|
|
@@ -79,6 +79,17 @@ html[data-game="on"] .visor:not([hidden]) {
|
|
|
79
79
|
font-size: var(--ds-text-xs); letter-spacing: var(--name-space); line-height: 1; color: var(--ink-soft);
|
|
80
80
|
padding: var(--ds-space-hair) var(--ds-space-xs); background: var(--night-plate); clip-path: var(--cut-path-sm);
|
|
81
81
|
}
|
|
82
|
+
/* smoked glass (task 1005252): where the browser can blur, each plate is a pane of smoked glass
|
|
83
|
+
that softens the scene behind it, so a scene that carries marks of its own (the review of a
|
|
84
|
+
hall screenshot) cannot line up with ours. Still sized to its words: the sky outside the
|
|
85
|
+
plates is untouched. Asking for less transparency puts the night plate back (style.css,
|
|
86
|
+
beside the Kill Switch, since the visor adds no media query of its own). */
|
|
87
|
+
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
|
88
|
+
.visor__place, .visor__who, .visor__hint, .visor__progress-words {
|
|
89
|
+
background: var(--smoke-sheen), var(--smoke); box-shadow: var(--smoke-edge);
|
|
90
|
+
-webkit-backdrop-filter: var(--smoke-blur); backdrop-filter: var(--smoke-blur);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
82
93
|
|
|
83
94
|
/* the foot row: you on the left, the one thing to press in the middle, the key hints on the
|
|
84
95
|
right. The two side groups lean in a little, as the glass curves. */
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
// went with it).
|
|
17
17
|
// Centre: your character's main action, the one accent button, with its key. Right: the
|
|
18
18
|
// quiet key hints, no boxes: your other actions and "Actions" (key A), which opens the
|
|
19
|
-
// sheet (your character's actions, then the keys of the place you are in, then
|
|
20
|
-
// a key of its own; Escape closes it).
|
|
19
|
+
// sheet (your character's actions, then the keys of the place you are in, then the game
|
|
20
|
+
// menu on a key of its own; Escape closes it). It is only in the sheet (task 1005248; the
|
|
21
|
+
// game menu since task 1005250).
|
|
21
22
|
// - every key comes from resolveKeys: one owner per key, never two (task 1005248).
|
|
22
23
|
//
|
|
23
24
|
// The rules it keeps (ADR 0399):
|
|
@@ -65,7 +66,9 @@
|
|
|
65
66
|
};
|
|
66
67
|
var SHEET_KEY = 'a';
|
|
67
68
|
var CLOSE_KEY = 'esc'; // shown once: the sheet's Close (Escape)
|
|
68
|
-
|
|
69
|
+
// The sheet's last row is the game menu (options, log out, exit; task 1005250): shell-game.js
|
|
70
|
+
// opens it for a data-game-menu link, and the address (the options) is the fallback.
|
|
71
|
+
var OPTIONS = { id: 'options', label: 'Game menu', key: 'm', href: '/settings' };
|
|
69
72
|
// Keys a scene binds on its own page (the sky's L 0 / ?, the gallery's F, the palette's K).
|
|
70
73
|
// A moved key never lands on one of them; a scene that declares one keeps it.
|
|
71
74
|
var AVOID = ['l', '0', '/', '?', 'f', 'k', '+', '-', '='];
|
|
@@ -377,7 +380,7 @@
|
|
|
377
380
|
'<button class="visor__close" type="button" data-visor-close aria-label="Close actions">' + keyHtml(CLOSE_KEY) + '</button></div>' +
|
|
378
381
|
'<p class="visor__cap">' + esc(NAMES[id]) + '</p><ul class="visor__rows">' + rows + '</ul>' +
|
|
379
382
|
(place ? '<p class="visor__cap">This place</p><ul class="visor__rows">' + place + '</ul>' : '') +
|
|
380
|
-
'<ul class="visor__rows"><li><a class="visor__row" data-visor-act="options" href="' + esc(hallHref(keys.options.href)) + '"><span>' + esc(keys.options.label) + '</span>' + keyHtml(keys.options.key) + '</a></li></ul>';
|
|
383
|
+
'<ul class="visor__rows"><li><a class="visor__row" data-visor-act="options" data-game-menu href="' + esc(hallHref(keys.options.href)) + '"><span>' + esc(keys.options.label) + '</span>' + keyHtml(keys.options.key) + '</a></li></ul>';
|
|
381
384
|
}
|
|
382
385
|
function shown() { return !!(root && !root.hidden); }
|
|
383
386
|
function render() {
|
|
@@ -460,6 +463,7 @@
|
|
|
460
463
|
return;
|
|
461
464
|
}
|
|
462
465
|
// Options opens the game's options window over the place when it can (shell-game.js)
|
|
466
|
+
if (a.id === OPTIONS.id && window.OTBGameMenu && typeof window.OTBGameMenu.open === 'function') { window.OTBGameMenu.open(); return; }
|
|
463
467
|
if (a.id === OPTIONS.id && window.OTBOptionsOverlay && typeof window.OTBOptionsOverlay.open === 'function') { window.OTBOptionsOverlay.open(); return; }
|
|
464
468
|
if (a.href) window.location.assign(hallHref(a.href));
|
|
465
469
|
}
|
|
@@ -475,6 +479,7 @@
|
|
|
475
479
|
if (t.closest('[data-visor-unfocus]')) { dropFocus(); return; }
|
|
476
480
|
if (t.closest('[data-visor-lead]')) { if (focus && focus.lead && !focus.lead.busy) { closeSheet(false); focus.lead.run(); } return; }
|
|
477
481
|
if (t.closest('[data-visor-sheet]')) { openSheet(); return; }
|
|
482
|
+
if (t.closest('[data-game-menu]')) { closeSheet(false); return; } // shell-game.js opens the menu
|
|
478
483
|
// You: your profile in the game's window, the place still drawn behind it. With no
|
|
479
484
|
// window (shell-game.js absent), the link opens the profile page.
|
|
480
485
|
if (t.closest('[data-visor-me]')) {
|
|
@@ -502,7 +507,11 @@
|
|
|
502
507
|
function busy() {
|
|
503
508
|
var html = document.documentElement;
|
|
504
509
|
if (html.classList && (html.classList.contains('craft-picker-open') || html.classList.contains('gs-open'))) return true;
|
|
505
|
-
|
|
510
|
+
// a dialog that is up: one bound once keeps aria-modal while it is hidden (the options
|
|
511
|
+
// window, the game menu), and a hidden one must not hold the keys (task 1005250)
|
|
512
|
+
var up = document.querySelectorAll('dialog[open], [aria-modal="true"]');
|
|
513
|
+
for (var i = 0; i < up.length; i++) if (!up[i].closest('[hidden]')) return true;
|
|
514
|
+
return false;
|
|
506
515
|
}
|
|
507
516
|
// Which action (or the sheet) a key press means, or null. Pure, for the tests.
|
|
508
517
|
function keyFor(id, e) {
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
// flag in localStorage that the return visit consumes) opens on the game's start screen
|
|
15
15
|
// instead (task 1005143, shell-game.js); without that file it is greeted here as before. Storage that
|
|
16
16
|
// throws cannot remember, so such a browser is never shown it (it would replay on every visit).
|
|
17
|
-
// -
|
|
18
|
-
//
|
|
17
|
+
// - Home opens on the game's Game / Classic chooser once each time the site is opened, signed in
|
|
18
|
+
// or not (task 1005219); signed out, a pick goes on to sign in and the return opens that menu.
|
|
19
|
+
// A visitor who is not signed in sees this Welcome only in a hall without shell-game.js, with a
|
|
20
|
+
// "Sign in with GitHub" button in place of the role choices. Only on Home, never over a deep link (#/task/N, #/idea/N).
|
|
19
21
|
// - shell.js asks onAccess() BEFORE the craft picker; a load where the welcome showed marks the
|
|
20
22
|
// picker asked (OTBCraftMode.markAsked), so the two never stack.
|
|
21
23
|
// - Picking a role also sets the hall's craft mode. Governing is a seat (craft-mode.js), so the
|
|
@@ -368,29 +370,43 @@
|
|
|
368
370
|
function onAccess(access, builder) {
|
|
369
371
|
if (shown) return true;
|
|
370
372
|
if (deepLink()) return false;
|
|
373
|
+
var game = window.OTBShellGame;
|
|
374
|
+
var canChoose = !!(game && typeof game.start === 'function');
|
|
371
375
|
if (access && access.authed === false) {
|
|
372
376
|
if (!onHome()) return false;
|
|
373
377
|
shown = true;
|
|
378
|
+
// The hall's front opens on Game or Classic (task 1005219): the pick is kept, the visitor
|
|
379
|
+
// signs in, and comes back to that menu (shell-game.js). Without that file, the welcome.
|
|
380
|
+
if (canChoose && game.start({
|
|
381
|
+
name: projectName(window.__BRANDING__), art: artFor(timeOfDay(new Date())),
|
|
382
|
+
signIn: function () { markSigningIn(); go(SIGNIN_HREF); },
|
|
383
|
+
})) return true;
|
|
374
384
|
show(null);
|
|
375
385
|
return true;
|
|
376
386
|
}
|
|
377
387
|
if (!access || access.authed !== true || !builder || builder.id == null) return false;
|
|
378
388
|
var key = DAY_KEY + builder.id, now = new Date();
|
|
389
|
+
// Every time the site is opened, Home asks Game or Classic first (task 1005219; owner,
|
|
390
|
+
// 2026-11-12: "every visit"), and that stands in for the day's welcome.
|
|
391
|
+
var ask = canChoose && onHome() && typeof game.seen === 'function' && !game.seen();
|
|
379
392
|
try {
|
|
380
393
|
// Every fresh sign-in is greeted; otherwise only the day's first visit.
|
|
381
394
|
var fresh = takeSignedIn();
|
|
382
|
-
if (!fresh && !firstVisitToday(window.localStorage.getItem(key), now)) return false;
|
|
395
|
+
if (!fresh && !ask && !firstVisitToday(window.localStorage.getItem(key), now)) return false;
|
|
383
396
|
window.localStorage.setItem(key, localDay(now));
|
|
384
|
-
} catch (_) { return false; }
|
|
397
|
+
} catch (_) { if (!ask) return false; }
|
|
385
398
|
shown = true;
|
|
386
399
|
var c = craft();
|
|
387
|
-
// A fresh sign-in opens on the game's start screen instead (
|
|
388
|
-
// same still behind it, and the craft picker asked once
|
|
389
|
-
|
|
390
|
-
if (fresh
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
400
|
+
// 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
|
|
402
|
+
// the world is showing. Classic picked before signing in: no screen, straight to the hall.
|
|
403
|
+
if ((fresh || ask) && canChoose) {
|
|
404
|
+
if (game.start({
|
|
405
|
+
name: projectName(window.__BRANDING__), art: artFor(timeOfDay(now)),
|
|
406
|
+
done: function () { if (c && typeof c.onAccess === 'function') c.onAccess(access, builder); },
|
|
407
|
+
})) return true;
|
|
408
|
+
if (fresh) return false;
|
|
409
|
+
}
|
|
394
410
|
if (c && typeof c.markAsked === 'function') c.markAsked();
|
|
395
411
|
show(builder);
|
|
396
412
|
return true;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# The game menu — game mode's Escape: options, log out, exit (task 1005250)
|
|
2
|
+
|
|
3
|
+
Covers: every game place (`shell-game.js` `openMenu`, `style.css` `.game-menu`), and the visor's two ways in (`visor.js`). Game mode itself is in `game-mode.md`; the options window it opens is in `options-menu.md`.
|
|
4
|
+
|
|
5
|
+
## What it settled (owner, 2026-11-12: "a game menu like in WoW")
|
|
6
|
+
|
|
7
|
+
- **Why.** Game mode has no sidebar, so it had no Sign out and no way to quit. Escape opened the options straight away, and the options held neither.
|
|
8
|
+
- **One small menu over the place.** In game mode the last Escape (one no dialog, sheet, picker or scene used) opens it; Escape again, the scrim or Return to game shuts it. It is the hall's shared dialog (`OTBDialog.bind`): Escape, the scrim, the focus trap and the focus return are the dialog's. The arrows walk the rows.
|
|
9
|
+
- **The rows** (`GAME_MENU`), in three runs: **Options** (shuts the menu and opens the options window), **Change character** (craft-mode's own `changeCharacter`, with its own confirm; no row on a page without it); **Log out**, **Exit game**; **Return to game**, the one lit row.
|
|
10
|
+
- **Log out is a link to the sign-out address.** `shell.js` already turns a click on any such link into the real sign-out (the POST, the cookies, the bounce to the front page), so the menu writes no sign-out of its own. Signing in again passes the view chooser and the start screen, as every sign-in does.
|
|
11
|
+
- **Exit game closes the tab**, and does not sign out (the start screen's Quit, the same way). A browser only closes a tab a script opened; when the tab is still there 300ms later the menu says "Your browser kept this tab open. Close it to quit."
|
|
12
|
+
- **The visor.** The Actions sheet's last row is "Game menu" on M (it was "Options" on O), and M on the glass opens it too. Escape is on the glass nowhere: one owner per key (task 1005248), and the shell's last Escape is not a visor key. The row is a link to `/settings` carrying `data-game-menu`; `shell-game.js` opens the menu for such a link, and the address is the fallback. A plain link to `/settings` (the profile chip, the start screen's Options) still opens the options window directly.
|
|
13
|
+
- **Where it does not open.** On the options menu's own page and inside its frame, Escape is the options menu's, as before. Outside game mode there is no game menu: classic has the sidebar's Sign out. A page that cannot draw it (no `OTBDialog`) opens the options, as before the menu existed.
|
|
14
|
+
- **A visor fix that came with it.** `visor.js` `busy()` counted a hidden dialog as up: a dialog bound once keeps `aria-modal` while hidden, so after the options window (or this menu) had been opened and shut once, the visor's letter keys stayed dead until a reload. It now skips a dialog inside `[hidden]`.
|
|
15
|
+
- **A new advanced action** is one row in `GAME_MENU` and one line in `onMenuClick`.
|
|
16
|
+
|
|
17
|
+
## Not done
|
|
18
|
+
|
|
19
|
+
- The start screen is not shown again on Log out without a sign-in in between; "back to the start screen, still signed in" would be its own row.
|
|
20
|
+
- Exit game does not sign out. If it should, it is one line in `exitGame`.
|
|
21
|
+
|
|
22
|
+
Tests: `tests/hall_game_menu.mjs` (the real `shell-game.js`, `hall-dialog.js` and `visor.js` in a real browser: Escape, each row, the visor's two ways in, a phone width, classic mode); `tests/hall_visor.mjs` (the hint and the sheet row); `tests/hall_options_menu.mjs` (the tip, and the fallback to the options window).
|
|
@@ -8,7 +8,7 @@ Covers: every shelled hall page (`shell-game.js`, `shell.js`). Spec: `docs/specs
|
|
|
8
8
|
- **Game mode is the existing full-screen switch, made hall-wide. There is no second full-screen mode.** The switch was already on every page (task 1004430) but remembered per page, so a reader had to turn it on 33 times. Game mode is one `localStorage` key, `hall-game`; while it is `on`, `shell.js` `storedFullscreen()` answers yes for every page, whatever that page remembers. What hides, the corner button and the Escape hand-off are the shell's, unchanged.
|
|
9
9
|
- **`shell-game.js` holds the mode and nothing else**: `window.OTBShellGame` = `on()`, `set(bool)`, `hint()`. It stamps `html[data-game="on"]` for a sheet or script that wants to read the mode. `shell.js` reads it through a fallback object, so a page without the file is never in game mode. It is its own file because `shell.js` sits on the 1,500-line ratchet; the start screen and options menu (R03, R04) are expected to grow here, not in `shell.js`.
|
|
10
10
|
- **Load order**: `shell-game.js` before `shell-trail.js` / `craft-mode.js` / `shell.js`, loaded the same way (deferred or not) as the shell on that page. Test-pinned.
|
|
11
|
-
- **Game or classic, never both (task 1005200, owner 2026-11-11).** Inside game mode there is no way back to the bars: the corner button is hidden (`style.css`) and does nothing if pressed, Escape opens the options menu, and the "how to leave" line is never shown. The only switch is Options → Preferences → View (`settings-view.js`), plus the chooser at every sign-in (`records/start-screen.md`). A page's own `hall-fullscreen:<page>` memory is still never overwritten, so switching to classic hands each page back what it remembered.
|
|
11
|
+
- **Game or classic, never both (task 1005200, owner 2026-11-11).** Inside game mode there is no way back to the bars: the corner button is hidden (`style.css`) and does nothing if pressed, Escape opens the game menu (options, log out, exit; task 1005250, `records/game-menu.md`), and the "how to leave" line is never shown. The only switch is Options → Preferences → View (`settings-view.js`), plus the chooser at every sign-in (`records/start-screen.md`). A page's own `hall-fullscreen:<page>` memory is still never overwritten, so switching to classic hands each page back what it remembered.
|
|
12
12
|
- **Game mode reaches only the game's places** (`shell-game.js` `PLACES`: home, city, goal-map, thinking, atlas, settings, profile). Any other hall page `location.replace`s to the city on load, and so does switching to game on one. A screen embedded in the options menu is exempt. The visor drops an action whose `href` is not a place (`visor.js` `inGame`). A page joins `PLACES` when it has a game version.
|
|
13
13
|
- **The choice is kept on the account**: `GET/PATCH /api/bongos/me/hall-view` (`modules/builder-settings/hall-view-prefs.js`, key `hall_view` in `behavior_prefs`, null = never chosen). `hall-game` in localStorage is only the copy a page paints from; `sync()` reads the account on every page load and switches if another browser changed it.
|
|
14
14
|
- **No page may opt out.** `<body data-fullscreen-switch="off">` would keep that page's sidebar in game mode; the test fails on one. A new hall page needs `#app-topbar` and the `shell-game.js` tag, like the rest.
|
|
@@ -17,6 +17,6 @@ Covers: every shelled hall page (`shell-game.js`, `shell.js`). Spec: `docs/specs
|
|
|
17
17
|
## For the tasks after this one
|
|
18
18
|
|
|
19
19
|
- A HUD or options menu that wants Escape must call `preventDefault()` on the press. `shell.js` `onFullscreenKey` waits one tick before leaving so a listener added after it still wins.
|
|
20
|
-
- Since R04 (task 1005144, ADR 0401) the last Escape in game mode
|
|
20
|
+
- Since R04 (task 1005144, ADR 0401) the last Escape in game mode was the options menu's (`shell-game.js` `onEscape`); since task 1005250 it opens the game menu, whose Options row opens the options (`records/game-menu.md`). On the options menu's own page it still closes it. Since task 1005200 there is no way back to the bars inside game mode.
|
|
21
21
|
|
|
22
22
|
Tests: `tests/hall_fullscreen.mjs` (the eight `game mode:` cases run the real `shell-game.js` + `shell.js`), and the script lists in `tests/hall_city.mjs`, `tests/hall_street.mjs`, `tests/hall_my_agents.mjs`, `tests/goal_map_page.mjs`.
|
|
@@ -27,7 +27,7 @@ The menu is a Helm screen, written from the hall tier's tokens alone (`settings.
|
|
|
27
27
|
|
|
28
28
|
## The options window (owner, 2026-11-11: "WoW sizing, an overlay")
|
|
29
29
|
|
|
30
|
-
- **In game mode the menu opens over the place, not as a page.**
|
|
30
|
+
- **In game mode the menu opens over the place, not as a page.** The game menu's Options row (task 1005250, `records/game-menu.md`; until then Escape itself), or a click on any link to `/settings` (the profile chip), draws one window (`shell-game.js` `openOverlay`, `window.OTBOptionsOverlay`), built on the shared dialog (`OTBDialog.bind`: Escape, scrim, close button, focus trap and return), with the page behind made `inert` while it is up, holding the menu framed with `?embed=1`; the place stays drawn behind it. Escape, the scrim or the × shuts it; `options-menu.js` `close()` shuts the window when it is in one. A page that cannot draw it falls back to the menu page. A plain visit to `/settings` is the page, as before.
|
|
31
31
|
- **Window sized, see-through.** The window is `min(1040px, …) × min(720px, …)` of glass at 48% of the ground with a light blur, over the shared `--scrim` at a third. Every embedded screen is drawn at `zoom: 0.82` with no ground of its own (`style.css`), and the menu inside it takes smaller words (`settings.css`, `html[data-embed="on"]`); a window-high length divides by the zoom (`--opt-vh`).
|
|
32
32
|
- **One review palette.** A framed screen docks no artist palette of its own (`craft-mode.js` `applyDocked`); the page around it has one.
|
|
33
33
|
- **Tests:** `tests/hall_options_menu.mjs` ("away from the menu, Escape opens the options as a window…"), `tests/page_notes_panel.mjs` (the frame docks no panel).
|
|
@@ -8,9 +8,9 @@ Covers: Home (`shell-game.js` `start()`, `shell-game.css`, the hand-off in `welc
|
|
|
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
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.
|
|
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: Escape opens the
|
|
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
|
-
- **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
|
|
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
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
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.
|
|
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.
|
|
@@ -29,3 +29,13 @@ Tests: `tests/hall_start_screen.mjs` (the hand-off and pure parts in a vm; both
|
|
|
29
29
|
Every sign-in now opens on a chooser before the start screen: **Game** or **Classic**, each with one line saying what it is. The view chosen last time (the browser's `hall-game` copy) is marked "Last time", lit with the accent on its edge, and focused, so Enter keeps it. Game goes on to the start screen as before; Classic closes the screen and the hall is as it always was. Either choice is saved to the account (`PATCH /api/bongos/me/hall-view`). The Extras list lost The docs: it is not a game place. Tests: `tests/hall_start_screen.mjs`.
|
|
30
30
|
|
|
31
31
|
**In the start menu's shape (task 1005206, owner 2026-11-11, from the V3 Helm start-screen mock-up).** The chooser is drawn like the menu it leads to: "How do you want to see it?" is a small label in the name voice, and the view Enter takes (last time's, else Game) is the accent bar with its "Enter" key, exactly as Start is (`.gs__item--lit` shares Start's rules); the other view is a quiet row on a hairline. Each row keeps its one line; on the lit bar the line and the "Last time" tag take `--accent-on`. Focus starts on the lit row.
|
|
32
|
+
|
|
33
|
+
## Land on the chooser (task 1005219, owner 2026-11-12)
|
|
34
|
+
|
|
35
|
+
The hall's front (Home, `/builders`) now opens on the Game / Classic chooser **every time the site is opened**, signed in or not: one `sessionStorage` mark per tab (`hall-chooser-seen`), so moving about the hall — game mode sends a classic page Home — never asks twice. This supersedes "a new visit keeps the welcome" and "a signed-out visitor keeps the welcome" above; the painted welcome remains only for a hall without `shell-game.js` and for the day's first visit to a page other than Home.
|
|
36
|
+
|
|
37
|
+
- **Signed out:** the pick is kept in `localStorage` (`hall-view-pending`) and the visitor goes to GitHub sign-in (`/api/bongos/auth/web/start`, which returns by the referer). On the return `start()` spends the pick: Game opens straight on the start screen's menu, Classic shows no screen and the hall is as it always was. Either is saved to the account then, as at the chooser.
|
|
38
|
+
- **Signed in:** the pick goes straight to that menu, as at sign-in.
|
|
39
|
+
- The marketing page at the apex root is unchanged (the owner's pick: the hall's front, not the apex).
|
|
40
|
+
|
|
41
|
+
Tests: `tests/hall_start_screen.mjs` (the vm hand-off, and the signed-out round trip for both picks in a real browser).
|
|
@@ -12,6 +12,9 @@ The visor is the control and the scene is the view: every main call to action in
|
|
|
12
12
|
- **The rule.** A key in `ACTIONS` or a scene's registration is a recommendation. `resolveKeys(id)` in `visor.js` hands every key out once, and the glass, the sheet and `keyFor` all read it; nothing else names a key. Who keeps a key, in order: the visor's own (A Actions, Escape Close); the keys a scene binds itself (an action without `bind`, the `keys` lines), because the visor cannot move a key it does not bind, so a second claim on one shows no key; the character's actions; the scene's `bind` actions; Options. A later claimant moves to the next free letter of its own name, then of the alphabet, never onto a key a scene binds on its page (`AVOID`: L 0 / ? F K + - =); with none free it has no key.
|
|
13
13
|
- **Options is only in the sheet** (the owner likes it there), on O, or its next free letter (the Patron's O is the Board room, so P). Pressing it opens the options window over the place, like a click. Escape on the glass is the sheet's Close only. The shell's "last Escape opens the options menu" (`shell-game.js`, ADR 0399 §2.4) is not a visor key and is unchanged.
|
|
14
14
|
- **Tests:** `tests/hall_visor.mjs` proves no shared key for every character over the sky, the city and Home, and over a hostile scene that claims A, Escape, a character key and one key twice.
|
|
15
|
+
## The game menu on the glass (task 1005250)
|
|
16
|
+
|
|
17
|
+
The Actions sheet's last row is "Game menu" on M, where it was "Options" on O: it opens the game menu (options, log out, exit; `records/game-menu.md`), and the options are a row inside that. The key rule above is unchanged: Escape is on the glass nowhere, and the shell's last Escape now opens the game menu. `busy()` no longer counts a hidden dialog as up. Where an older bullet below says "Options (Esc)", this is current.
|
|
15
18
|
|
|
16
19
|
## A place's own buttons on the glass (task 1005209, the Governor City first)
|
|
17
20
|
|
|
@@ -55,6 +58,7 @@ The glass was redrawn to the owner's mock-up "Cloud city: in the visor, minimal"
|
|
|
55
58
|
- **The four progress segments (task 1005203).** Under the place: the version the project is building, in quarters, then the same reading in words (`BONGOS-V2 · 89%`; the screen reader hears the version's name too). The owner chose the measure (2026-11-11): the version's `percent_complete` from the public `GET /versions/progress`, the number /status reports. The version is the starred focus (`GET /versions/focus`) while it is still open, else the first one `building`; with neither, or a failed read, no segments. A quarter wholly built is the soft ink, the quarter being built is the accent, the rest are outlines; each wears an outline of the night, and the words stand on a night plate (`--night-plate`, the rule task 1005196 set), for a midday sky. The arc band is `--visor-arc` (64px) and the scene inset grew to 88px (`--visor-inset-top`), so the sky's top offsets in `thinking.css` grew by 24px. Hidden on a phone, with the place. Harness fixture: `versions__progress.json`.
|
|
56
59
|
- **In the mock-up, not built, because the visor has no data for it:** the marker and name on the building in view (the visor owns no camera, ADR 0399 §2.2), and the party bars with a speaker's name and a Voice key. Each is a scene or a later task handing the visor real data; none is drawn empty.
|
|
57
60
|
- **It reads over any sky, with no per-hour style, and it darkens none of it** (task 1005196, owner 2026-11-11: "I don't like how dark the edges of the view are"). The glass paints nothing of its own: the bands of `--night-glass` at its top and bottom edge are gone, and so is the dark halo round its type. The marks carry themselves. The place, each key hint and your name and credits stand on a small cut plate of the night (`--night-plate`, the ground at 88%), sized to the words; every line is drawn twice (a wider solid stroke of the night under the ice hairline, `.visor__line--under`), so an arc is ice on night and a dark line on cream cloud; a key sits on a chip of the night. One new token, `--night-plate`. Tried first and dropped: a 4px night stroke round each letter with no plate, which filled the condensed capitals in.
|
|
61
|
+
- **The plates are smoked glass** (task 1005252, the owner's pick from a lab of seven treatments, 2026-11-12). Reviewing a screenshot of the hall put a second visor under ours, and solid plates stacked on its plates. Where the browser can blur, the place, each hint, your name and credits and the progress words are panes: the scene behind them blurred, slightly dimmed and greyed (`--smoke-blur`), a lighter tint of the night (`--smoke`, 62%), a faint sheen (`--smoke-sheen`) and a lit top edge (`--smoke-edge`). Still sized to the words, so the sky outside them is untouched. Asking for less transparency, or a browser without backdrop blur, gets the night plate (the fallback is in style.css, because visor.css carries no media query). Tried and not picked: glass bands top and foot (bring back the band task 1005196 removed), a blurred lens rim, and halos only (too weak over a white sky). On a phone (the shared 640px block in style.css) the hints and your credits keep the night plate: a live blur under a HUD the scene pans beneath costs every frame, and a phone has the weakest GPU. Every colour in the four smoke tokens derives from `--bg` or `--ink-soft`. **Seen and approved (2026-11-12):** the builder who asked for it picked smoked glass in the live lab (all seven treatments over four backdrops), then saw before/after renders of the real visor sheets (a midday sky, and a screenshot of the visor under the visor) and said "Ship it". They then saw it staged: the real hall served by the local preview harness (Home in game mode, the artisan held, the city pannable under the glass), and answered "Looks good, land it". Not yet looked at: the night hour.
|
|
58
62
|
- **A phone.** The shared phone block in `style.css` (no breakpoint of the visor's own): the top arc, its notch and the place step aside (the top of a phone is the scene's and the shell's); the foot row stays one line with nothing leaning (the sprite, the count over its word, the accent button whose name may wrap and whose key is left to the sheet, and of the hints only Actions, as three dots on a night chip). Between a phone and a wide desk the hints wrap onto a second line, right-aligned, rather than run into the button.
|
|
59
63
|
- **Scenes.** The top arc, the notch and the place sit inside the top 64px (`--visor-arc`; the segments under them take the inset to 88px, `--visor-inset-top`), so no scene's top row is struck through. Home no longer shifts its greeting row left (there is no pill top-right to clear). The city's door rises above the foot row at every width, because the accent button is centred where the door stood. The sky's `visor-on` offsets are unchanged.
|
|
60
64
|
- **Looking at it.** The stand-in scene in `tests/hall_visor.mjs` over the hall's real sheets; put a bright gradient behind it to check midday.
|
|
@@ -276,6 +276,40 @@ async function startFounding(builderId, deps = {}) {
|
|
|
276
276
|
return { ok: true, mode };
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// isTestProject(deps) — is this hall a TEST project? The platform marks one (task 1005036,
|
|
280
|
+
// modules/provisioning/test-project.js) and the mark arrives as branding project.test. Genesis is paused
|
|
281
|
+
// for every other project (owner, 2026-11-01), so only a test project may switch it on by hand.
|
|
282
|
+
function isTestProject(deps = {}) {
|
|
283
|
+
try {
|
|
284
|
+
const b = deps.branding !== undefined ? deps.branding : require('../../src/module-api').branding();
|
|
285
|
+
return !!(b && b.project && b.project.test === 'on');
|
|
286
|
+
} catch (_) { return false; }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ownerStart(builderId, deps) — the owner's own start (POST /founding/start): a test project only.
|
|
290
|
+
async function ownerStart(builderId, deps = {}) {
|
|
291
|
+
if (!isTestProject(deps)) return { ok: false, reason: 'not_a_test_project' };
|
|
292
|
+
return startFounding(builderId, deps);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// setEnabled(enabled, builderId, deps) — the Test features panel's genesis switch (task 1005036): a test
|
|
296
|
+
// project only. On starts genesis if it never started (startFounding turns the switch on); if it already
|
|
297
|
+
// began, it just turns the switch back on. Off pauses it — the founding record is kept, the hall opens full.
|
|
298
|
+
async function setEnabled(enabled, builderId, deps = {}) {
|
|
299
|
+
if (!isTestProject(deps)) return { ok: false, reason: 'not_a_test_project' };
|
|
300
|
+
const s = seams(deps);
|
|
301
|
+
if (enabled === true) {
|
|
302
|
+
const started = await s.get(fm.KEYS.STARTED_AT);
|
|
303
|
+
if (!(started && started.value)) return startFounding(builderId, deps);
|
|
304
|
+
const ended = await s.get(fm.KEYS.ENDED_AT);
|
|
305
|
+
if (ended && ended.value) return { ok: false, reason: 'already_alive' };
|
|
306
|
+
await s.set(fm.KEYS.ENABLED, 'true', builderId);
|
|
307
|
+
} else {
|
|
308
|
+
await s.set(fm.KEYS.ENABLED, 'false', builderId);
|
|
309
|
+
}
|
|
310
|
+
return { ok: true, enabled: enabled === true, mode: await fm.foundingModeFor(modeDeps(s)) };
|
|
311
|
+
}
|
|
312
|
+
|
|
279
313
|
// savePlan — the decide stage's door: record the plan, file the five stages as tasks,
|
|
280
314
|
// and move to scope. Only while deciding.
|
|
281
315
|
async function savePlan(body, builderId, deps = {}) {
|
|
@@ -508,7 +542,7 @@ async function onTaskShipped(deps = {}) {
|
|
|
508
542
|
|
|
509
543
|
module.exports = {
|
|
510
544
|
PLAN_KEY, STAGE_INFO, PROPER, DEMO_PEOPLE, DEMO_HOURS, intIn, parsePlan, shapeGenesis, demoMinutes, demoPlan,
|
|
511
|
-
readGenesis, startFounding, savePlan, applyGovernmentChoice, closeStage, comeAlive, skipGenesis, onStagePassed,
|
|
545
|
+
readGenesis, startFounding, ownerStart, setEnabled, savePlan, applyGovernmentChoice, closeStage, comeAlive, skipGenesis, onStagePassed,
|
|
512
546
|
putFinishedStages, onTaskShipped,
|
|
513
547
|
// the founding-mode deps this file reads the mode with, for founding-birth.js (task 1004506)
|
|
514
548
|
modeDepsFor: (deps) => modeDeps(seams(deps)),
|
|
@@ -124,7 +124,7 @@ const GENESIS_REFUSALS = {
|
|
|
124
124
|
not_at_legislation: 409, legislation_not_passed: 409, stage_tasks_unfinished: 409,
|
|
125
125
|
no_stage_tasks: 409, unknown_stage: 400, no_board: 503,
|
|
126
126
|
bad_days: 400, bad_passes: 400, bad_advisors: 400, bad_government: 400, bad_expected_builders: 400,
|
|
127
|
-
bad_demo: 400, bad_minutes: 400,
|
|
127
|
+
bad_demo: 400, bad_minutes: 400, not_a_test_project: 403,
|
|
128
128
|
};
|
|
129
129
|
let genesisListening = false;
|
|
130
130
|
|
|
@@ -158,7 +158,12 @@ function mountGenesisRoutes(router) {
|
|
|
158
158
|
await foundingBirth.ensureBirth();
|
|
159
159
|
return { genesis: await genesis.readGenesis(), viewer_id: String(req.builder.id) };
|
|
160
160
|
}));
|
|
161
|
-
router.post('/founding/start', api.requireBuilder, owns, handle((req) => genesis.
|
|
161
|
+
router.post('/founding/start', api.requireBuilder, owns, handle((req) => genesis.ownerStart(req.builder.id)));
|
|
162
|
+
// The Test features panel's genesis switch (task 1005036): a test project only, the owner's call.
|
|
163
|
+
router.put('/founding/enabled', api.requireBuilder, owns, (req, res, next) => {
|
|
164
|
+
if (api.validateOrRespond(req, res, { enabled: { type: 'boolean' } })) return;
|
|
165
|
+
next();
|
|
166
|
+
}, handle((req) => genesis.setEnabled(req.body && req.body.enabled === true, req.builder.id)));
|
|
162
167
|
// The plan's ranges and vocabularies are genesis-home.js parsePlan's (one source);
|
|
163
168
|
// this schema only names the fields so an unknown one is refused.
|
|
164
169
|
router.post('/founding/plan', api.requireBuilder, owns, (req, res, next) => {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
const api = require('../../src/module-api');
|
|
21
21
|
const { effectiveSettings } = require('./provisioning');
|
|
22
|
+
const { isTestProject } = require('./test-project');
|
|
22
23
|
|
|
23
24
|
const log = api.logger('provisioning');
|
|
24
25
|
|
|
@@ -57,6 +58,8 @@ function catalogStatusForInstance(row) {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
function catalogEntryForInstance(row, { ownerGithubId } = {}) {
|
|
61
|
+
// A test project is never on the public map (task 1005036, ./test-project.js): the honest refusal, not a failure.
|
|
62
|
+
if (isTestProject(row)) return null;
|
|
60
63
|
const origin = catalogOriginForInstance(row);
|
|
61
64
|
if (!origin) return null;
|
|
62
65
|
const owner = Number(ownerGithubId);
|
|
@@ -104,6 +104,7 @@ const ONE_FREE_SHAPES = Object.freeze(new Set(['cloud-host']));
|
|
|
104
104
|
// the response closes, so a second request sent meanwhile — from any web process — is
|
|
105
105
|
// refused (the wizard never sends two). ./free-place-lock.js says how it is held.
|
|
106
106
|
const freePlace = require('./free-place-lock');
|
|
107
|
+
const { isTestProject } = require('./test-project');
|
|
107
108
|
|
|
108
109
|
// Shapes that are not requestable through this route AT ALL — no permission admits them,
|
|
109
110
|
// because there is no rank at which "ask the API for a second control plane" is a
|
|
@@ -159,7 +160,8 @@ function shapeAuthority(rawShape) {
|
|
|
159
160
|
async function otherLiveProjects(db, builderId, shape, rawSlug) {
|
|
160
161
|
const slug = String(rawSlug || '').trim().toLowerCase();
|
|
161
162
|
const rows = await provisioning.listInstancesForOwner(db, builderId);
|
|
162
|
-
|
|
163
|
+
// A test project holds no free place (task 1005036): it is for trying features, and only an archon makes one.
|
|
164
|
+
return rows.filter((r) => r.hosting_shape === shape && r.status !== 'torn_down' && r.slug !== slug && !isTestProject(r));
|
|
163
165
|
}
|
|
164
166
|
|
|
165
167
|
// requireAuthorityForShape — the create route's shape gate. Mount it AFTER requireBuilder
|
|
@@ -23,7 +23,7 @@ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundle
|
|
|
23
23
|
// pure declarations beside this file, for the same reason: storage and the read
|
|
24
24
|
// projection both have to read them.
|
|
25
25
|
const { PLANET_PHYSICS_QUESTIONS, canonicalAnswer } = require('./planet-physics');
|
|
26
|
-
const { softLimitsFor } = require('./soft-limits'); const { demoView } = require('./demo'); // the demo marker's owner view (task 1004420)
|
|
26
|
+
const { softLimitsFor } = require('./soft-limits'); const { demoView } = require('./demo'); const { isTestProject, TEST_COMPANION_ENV } = require('./test-project'); // the demo marker's owner view (task 1004420); the test mark (task 1005036)
|
|
27
27
|
const { dnsResolves, recordDnsResolved, lookupAnswers } = require('./dns-resolves'); // has the platform's lookup seen the address answer (task 1004505)
|
|
28
28
|
const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look'); const { BIRTH_COMPANION_ENV } = require('./birth'); // born into genesis (task 1004506)
|
|
29
29
|
|
|
@@ -453,7 +453,7 @@ function publicInstance(row) {
|
|
|
453
453
|
detail,
|
|
454
454
|
// Which startup options those answers hide, grey or annotate, each with its reason
|
|
455
455
|
// (BV2.PS02, spec D6). Advice only — `enforced: false` says so on every read.
|
|
456
|
-
soft_limits: softLimitsFor(detail), demo: demoView(row), // null unless a demo nobody carried over (task 1004420, ./demo.js)
|
|
456
|
+
soft_limits: softLimitsFor(detail), demo: demoView(row), is_test: isTestProject(row), // null unless a demo nobody carried over (task 1004420, ./demo.js)
|
|
457
457
|
// The creation picker's module set (task 1002339): the always-on core, what
|
|
458
458
|
// the owner ends up with, and whether that came from their own toggles or
|
|
459
459
|
// from the type's starter bundle because they never answered. Resolved, not
|
|
@@ -513,7 +513,7 @@ function settingsEnvVars(row, prefix) {
|
|
|
513
513
|
// cutoff and every unresolved review counts, which is the right answer for a
|
|
514
514
|
// project that has been strict since it stood up.
|
|
515
515
|
const SETTINGS_COMPANION_ENV = Object.freeze({
|
|
516
|
-
ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, ...BIRTH_COMPANION_ENV, // + accent, logo (look.js); birth, demo box (birth.js)
|
|
516
|
+
ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, ...BIRTH_COMPANION_ENV, ...TEST_COMPANION_ENV, // + accent, logo (look.js); birth, demo box (birth.js); test mark (test-project.js)
|
|
517
517
|
});
|
|
518
518
|
|
|
519
519
|
// The web.env block the runner writes at STANDUP (task 1003139): a comment that
|
|
@@ -64,7 +64,7 @@ const catalogBridge = require('../catalog-bridge');
|
|
|
64
64
|
const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
|
|
65
65
|
const { callbackPage } = require('./callback-page'); const githubSetup = require('../github-setup');
|
|
66
66
|
// the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
|
|
67
|
-
const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo'); const birth = require('../birth');
|
|
67
|
+
const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo'); const birth = require('../birth'); const testProject = require('../test-project');
|
|
68
68
|
const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
|
|
69
69
|
const { settingsPush, failPushConflict } = require('../settings-push'); // a settings save's push + receipt, shared with routes/look.js (task 1004421)
|
|
70
70
|
const { requireProjectOwner, projectActor, isProjectOwner } = require('../route-helpers'); // the owner-or-archon gate (task 1004159, ADR 0328)
|
|
@@ -361,7 +361,7 @@ module.exports = function provisioningRoutes() {
|
|
|
361
361
|
// exactly the set the panel preselected. An explicit [] is the different
|
|
362
362
|
// answer "none of them". maxItems is a sanity ceiling well above a catalog
|
|
363
363
|
// that is three modules wide today.
|
|
364
|
-
modules: { type: 'array', itemsType: 'string', maxItems: 50 }, terms_acknowledged: { type: 'boolean' }, demo: { type: 'object' }, ship_mode: { type: 'string', enum: ['auto', 'manual'] }, genesis: { type: 'boolean' }, // the wizard's terms tick (task 1004417, ../terms-ack.js); the demo route's { people, hours_each } (task 1004420, ../demo.js)
|
|
364
|
+
modules: { type: 'array', itemsType: 'string', maxItems: 50 }, terms_acknowledged: { type: 'boolean' }, demo: { type: 'object' }, ship_mode: { type: 'string', enum: ['auto', 'manual'] }, genesis: { type: 'boolean' }, test: { type: 'boolean' }, // the wizard's terms tick (task 1004417, ../terms-ack.js); the demo route's { people, hours_each } (task 1004420, ../demo.js)
|
|
365
365
|
})) return;
|
|
366
366
|
const body = req.body || {};
|
|
367
367
|
const detailFault = badProjectDetail(body.detail);
|
|
@@ -372,6 +372,8 @@ module.exports = function provisioningRoutes() {
|
|
|
372
372
|
if (screened) return res.fail('screened_out', screened);
|
|
373
373
|
const demoAsk = demo.parseDemo(body.demo); // a demo is a normal project marked demo, its picked answers under the owner's own (task 1004420)
|
|
374
374
|
if (!demoAsk.ok) return res.fail('bad_demo', { status: 400, message: demoAsk.reason });
|
|
375
|
+
const testAsk = testProject.parseTestAsk(body, req.builder); // a test project: archon only; genesis only on one (task 1005036, ../test-project.js)
|
|
376
|
+
if (!testAsk.ok) return res.fail(testAsk.code, { status: testAsk.status, message: testAsk.message });
|
|
375
377
|
const slug = String(body.slug || '').trim().toLowerCase();
|
|
376
378
|
if (!provisioning.isValidSlug(slug)) {
|
|
377
379
|
return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
|
|
@@ -477,8 +479,9 @@ module.exports = function provisioningRoutes() {
|
|
|
477
479
|
// provision intent exists, so the runner's web.env already carries the marker and the demo's box.
|
|
478
480
|
// Best-effort: a project that misses its marker opens as an ordinary hall; the create still stands.
|
|
479
481
|
// Genesis is OFF by default (owner, 2026-11-01): a new project opens as the full hall. Only a demo (its
|
|
480
|
-
// time box lives on the birth marker) or
|
|
481
|
-
if (created && (demoAsk.demo ||
|
|
482
|
+
// time box lives on the birth marker) or a test project asking for genesis is born into it.
|
|
483
|
+
if (created && (demoAsk.demo || testAsk.genesis)) await birth.stampBirth(pool, instance.id).catch((err) => log.error('[provisioning] birth marker not stamped', err));
|
|
484
|
+
if (created && testAsk.test) await testProject.stampTestProject(pool, instance.id).catch((err) => log.error('[provisioning] test mark not stamped', err));
|
|
482
485
|
// The wizard's ship mode (task 1004629) rides beside it; a miss means the project asks at its first ship.
|
|
483
486
|
if (created) await birth.stampShipMode(pool, instance.id, body.ship_mode).catch((err) => log.error('[provisioning] ship mode not stamped', err));
|
|
484
487
|
const demoRow = created && demoAsk.demo ? await demo.startDemoProject(pool, provisioning, instance.id, demoAsk.demo) : null;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/provisioning/test-project.js — a TEST project: one made to try features that are not
|
|
4
|
+
// live yet (task 1005036, idea 1001299; owner, 2026-11-01).
|
|
5
|
+
//
|
|
6
|
+
// A test project is an ordinary project with a mark on it. The mark changes four things and
|
|
7
|
+
// nothing else: it is labelled, it never reaches the public map, it does not count toward a
|
|
8
|
+
// builder's one free hosted project, and it is the only kind that may switch not-yet-live
|
|
9
|
+
// features on (genesis today; the opt-in modules through the hall's own toggle). A project
|
|
10
|
+
// without the mark behaves exactly as before, which is the property the tests pin.
|
|
11
|
+
//
|
|
12
|
+
// WHO MAY MAKE ONE. Archon only — a plain rank test, as route-helpers.js's archon override
|
|
13
|
+
// is, never a permission atom. Unfinished features are for the people building the platform,
|
|
14
|
+
// and a test project is not a way around the one-free-project limit for anyone else.
|
|
15
|
+
//
|
|
16
|
+
// WHERE THE MARK LIVES. The reserved settings-jsonb key `test_project`, stamped once on the
|
|
17
|
+
// row the create route just made (never on an existing row), like `founding_birth` beside it.
|
|
18
|
+
// It is out of SETTINGS_VOCAB, so the settings PATCH cannot write it and effectiveSettings
|
|
19
|
+
// never projects it. It reaches the instance as the companion TEST_PROJECT, which
|
|
20
|
+
// src/branding.js maps onto `project.test` — that is how the hall labels itself and how the
|
|
21
|
+
// instance refuses the feature switches on a project that is not a test one.
|
|
22
|
+
|
|
23
|
+
const TEST_KEY = 'test_project';
|
|
24
|
+
|
|
25
|
+
const storedOf = (row) => (row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings) ? row.settings : {});
|
|
26
|
+
|
|
27
|
+
// isTestProject(row) — PURE. True only for a row stamped 'on'.
|
|
28
|
+
function isTestProject(row) {
|
|
29
|
+
return storedOf(row)[TEST_KEY] === 'on';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// parseTestAsk(body, builder) — PURE. What the create request asks for: { ok: true, test, genesis },
|
|
33
|
+
// or { ok: false, status, code, message }. `genesis: true` is the test project's way of being
|
|
34
|
+
// born with genesis on, so it means nothing without `test: true`.
|
|
35
|
+
function parseTestAsk(body, builder) {
|
|
36
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
37
|
+
const test = b.test === true;
|
|
38
|
+
const genesis = b.genesis === true;
|
|
39
|
+
if (genesis && !test) {
|
|
40
|
+
return { ok: false, status: 400, code: 'genesis_needs_test', message: 'Genesis is only switched on for a test project: send "test": true as well.' };
|
|
41
|
+
}
|
|
42
|
+
if (test && !(builder && builder.rank === 'archon')) {
|
|
43
|
+
return { ok: false, status: 403, code: 'test_project_forbidden', message: 'Only an archon can create a test project.' };
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, test, genesis };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// stampTestProject(db, instanceId) — the create route's one write, on the row it just made.
|
|
49
|
+
// Write-once: a mark already there is kept (the WHERE says so).
|
|
50
|
+
async function stampTestProject(db, instanceId) {
|
|
51
|
+
await db.query(
|
|
52
|
+
`UPDATE provisioning_instances
|
|
53
|
+
SET settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object($2::text, 'on')
|
|
54
|
+
WHERE id = $1 AND NOT (COALESCE(settings, '{}'::jsonb) ? $2::text)`,
|
|
55
|
+
[instanceId, TEST_KEY],
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The companion env (SETTINGS_COMPANION_ENV): 'on' for a test project, empty for every other,
|
|
60
|
+
// so the runner's in-place patch never leaves a stale line behind.
|
|
61
|
+
const TEST_COMPANION_ENV = Object.freeze({
|
|
62
|
+
TEST_PROJECT: (row) => (isTestProject(row) ? 'on' : ''),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
module.exports = { isTestProject, parseTestAsk, stampTestProject, TEST_COMPANION_ENV };
|