@bongos/core 1.19.622 → 1.19.624
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 +70 -35
- package/docs/copy-inventory.md +166 -158
- package/docs/copy-registry.json +286 -192
- package/docs/module-api-changelog.md +4 -0
- package/modules/builder-settings/builder-needs.js +41 -4
- package/modules/economy/credits.js +38 -0
- package/modules/economy/reward.js +5 -0
- package/modules/hall-ui/public/board-room.css +83 -0
- package/modules/hall-ui/public/board-room.html +70 -0
- package/modules/hall-ui/public/board-room.js +517 -0
- package/modules/hall-ui/public/board-room.states.json +104 -0
- package/modules/hall-ui/public/government.css +10 -57
- package/modules/hall-ui/public/government.html +10 -12
- package/modules/hall-ui/public/government.js +119 -367
- package/modules/hall-ui/public/hall-render.js +26 -7
- package/modules/hall-ui/public/palette.js +5 -2
- package/modules/hall-ui/public/shell.js +16 -0
- package/modules/sessions/db.js +65 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-session-earnings.js +123 -0
- package/scripts/gds/fitness-checks-vocabulary.js +7 -0
- package/scripts/gds/run-unit-tests.js +7 -0
- package/src/bongos/auth.js +27 -0
- package/src/bongos/serve-internal.js +21 -0
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +6 -5
- package/tests/builder_needs.mjs +122 -0
- package/tests/government_board_hash_redirect.mjs +129 -0
- package/tests/government_board_room_copy.mjs +41 -15
- package/tests/government_board_vote_ui.mjs +5 -1
- package/tests/hall_nav.mjs +5 -1
- package/tests/hall_page_gate_map.mjs +11 -0
- package/tests/hall_palette.mjs +9 -3
- package/tests/rank_tier_single_source.mjs +8 -0
- package/tests/session_earnings_mirror_db.mjs +250 -0
|
@@ -1693,5 +1693,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1693
1693
|
landed since 1.19.620 with no explicit bump. run 34321546662. (task 1002620)
|
|
1694
1694
|
1.19.622 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1695
1695
|
landed since 1.19.621 with no explicit bump. run 34322091367. (task 1002620)
|
|
1696
|
+
1.19.623 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1697
|
+
landed since 1.19.622 with no explicit bump. run 34323141703. (task 1002620)
|
|
1698
|
+
1.19.624 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1699
|
+
landed since 1.19.623 with no explicit bump. run 34326009063. (task 1002620)
|
|
1696
1700
|
---------------------------------------------------------------------------
|
|
1697
1701
|
```
|
|
@@ -219,6 +219,36 @@ function mingleIntroductionsNeed(ctx) {
|
|
|
219
219
|
|
|
220
220
|
const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed];
|
|
221
221
|
|
|
222
|
+
// Priority WITHIN a state bucket (task 1003738). computeNeeds used to sort by
|
|
223
|
+
// state alone with a stable sort, so the NEEDS array order silently decided
|
|
224
|
+
// which item a single-slot renderer showed. board_votes sits fourth, behind
|
|
225
|
+
// art_key — so any builder with an unset image key never saw a waiting board
|
|
226
|
+
// vote, while both renderers' comments claimed they showed "the highest-priority"
|
|
227
|
+
// need. Within a bucket there was no such thing.
|
|
228
|
+
//
|
|
229
|
+
// NOT keyed on `severity`, which is the obvious-looking choice and the wrong
|
|
230
|
+
// one: art_key is severity 'action' and board_votes is 'info', so ordering by
|
|
231
|
+
// severity would have PRESERVED the exact bug this fixes. The rule here is who
|
|
232
|
+
// is waiting:
|
|
233
|
+
//
|
|
234
|
+
// A NEED SOMEONE ELSE IS BLOCKED ON OUTRANKS A NEED THAT ONLY AFFECTS YOU.
|
|
235
|
+
//
|
|
236
|
+
// A sitting cannot be decided until a member votes, and its window can expire;
|
|
237
|
+
// an unset art key stops only your own art, whenever you next want it.
|
|
238
|
+
//
|
|
239
|
+
// Lower number = louder. An id missing from this map sorts last, so appending to
|
|
240
|
+
// NEEDS without a weight is safe (it just lands at the back of its bucket).
|
|
241
|
+
// founding_welcome is deliberately negative: during the founding grace the
|
|
242
|
+
// roster leads with the welcome, which is what the grace is for.
|
|
243
|
+
const NEED_PRIORITY = {
|
|
244
|
+
founding_welcome: -1,
|
|
245
|
+
board_votes: 0,
|
|
246
|
+
help_requests: 1,
|
|
247
|
+
art_key: 2,
|
|
248
|
+
task_recommendations: 3,
|
|
249
|
+
mingle_introductions: 4,
|
|
250
|
+
};
|
|
251
|
+
|
|
222
252
|
// ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
|
|
223
253
|
//
|
|
224
254
|
// A freshly-provisioned instance used to greet its founding owner with "your
|
|
@@ -265,9 +295,14 @@ function foundingWelcomeNeed(ctx) {
|
|
|
265
295
|
// functions read (see each need's doc). Returns:
|
|
266
296
|
// { items: Need[], action_needed_count, has_action_needed }
|
|
267
297
|
// items is ordered: action_needed first (loudest), then covered, then satisfied,
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
298
|
+
// and WITHIN each bucket by NEED_PRIORITY above — so a renderer that takes "the
|
|
299
|
+
// first one" really does get the most urgent, which before task 1003738 it did
|
|
300
|
+
// not (it got whichever need NEEDS happened to list first). During the founding
|
|
301
|
+
// grace nothing is action_needed, so the welcome is what that renderer shows.
|
|
302
|
+
//
|
|
303
|
+
// A renderer that can show a LIST should render every action_needed item rather
|
|
304
|
+
// than the first: ordering decides emphasis, it does not make the rest safe to
|
|
305
|
+
// drop. Home's "From the system" inbox does exactly that.
|
|
271
306
|
function computeNeeds(ctx = {}) {
|
|
272
307
|
const grace = foundingGraceActive(ctx);
|
|
273
308
|
let items = NEEDS.map((fn) => fn(ctx)).filter(Boolean);
|
|
@@ -282,7 +317,8 @@ function computeNeeds(ctx = {}) {
|
|
|
282
317
|
items.unshift(foundingWelcomeNeed(ctx));
|
|
283
318
|
}
|
|
284
319
|
const order = { action_needed: 0, covered: 1, satisfied: 2 };
|
|
285
|
-
|
|
320
|
+
const weight = (n) => NEED_PRIORITY[n.id] ?? 99;
|
|
321
|
+
items.sort((a, b) => ((order[a.state] ?? 9) - (order[b.state] ?? 9)) || (weight(a) - weight(b)));
|
|
286
322
|
const actionNeeded = items.filter((n) => n.state === 'action_needed');
|
|
287
323
|
return {
|
|
288
324
|
items,
|
|
@@ -299,4 +335,5 @@ module.exports = {
|
|
|
299
335
|
foundingGraceActive,
|
|
300
336
|
FOUNDING_GRACE_DAYS,
|
|
301
337
|
SETTINGS_PATH,
|
|
338
|
+
NEED_PRIORITY,
|
|
302
339
|
};
|
|
@@ -931,6 +931,43 @@ async function sumCreditsForTasks(builderId, taskIds) {
|
|
|
931
931
|
return rows[0] ? rows[0].total : 0;
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
+
// sumSessionEarnings — what ONE session actually earned its builder, across BOTH
|
|
935
|
+
// of the ledger's attribution keys. The authoritative answer to the question
|
|
936
|
+
// session_records.drachmae_earned exists to mirror.
|
|
937
|
+
//
|
|
938
|
+
// Why it has to be two keys: credit_log rows reach a builder by two different
|
|
939
|
+
// routes, and each stream picks exactly one. The per-task streams (the confirm
|
|
940
|
+
// credit, the idea slices) key on `task_id` and leave session_id NULL. The
|
|
941
|
+
// cost-plus session reward (ADR 0054) is the mirror image — it keys on
|
|
942
|
+
// `session_id` and leaves task_id NULL deliberately, because it pays for the
|
|
943
|
+
// SESSION's real token spend, not for any one task in it.
|
|
944
|
+
//
|
|
945
|
+
// sumCreditsForTasks() above sees only the first route. That was survivable while
|
|
946
|
+
// every instance paid both streams, but under reward mode 'cost-plus-only'
|
|
947
|
+
// (ADR 0146 — this instance's standing choice) the session reward is the ONLY
|
|
948
|
+
// stream that pays, so a task_id-keyed sum is structurally always 0 and the hall
|
|
949
|
+
// told every builder they had earned nothing for work they were genuinely paid
|
|
950
|
+
// for (task 1003634). A builder reported it as missing pay; the money was never
|
|
951
|
+
// missing, the mirror was.
|
|
952
|
+
//
|
|
953
|
+
// `executor` is a Pool or a tx client, so the caller can ask INSIDE the same
|
|
954
|
+
// transaction that just booked the reward and see it. The OR is one predicate over
|
|
955
|
+
// one scan, so a row matching both keys is summed once, not twice — the figure is
|
|
956
|
+
// a pure function of ledger state and can be re-derived any number of times.
|
|
957
|
+
async function sumSessionEarnings(executor, builderId, taskIds, sessionId) {
|
|
958
|
+
const ids = Array.isArray(taskIds) ? taskIds.map(Number).filter(Number.isFinite) : [];
|
|
959
|
+
const sid = sessionId == null ? null : String(sessionId);
|
|
960
|
+
if (ids.length === 0 && !sid) return 0;
|
|
961
|
+
const { rows } = await executor.query(
|
|
962
|
+
`SELECT COALESCE(SUM(delta), 0)::int AS total
|
|
963
|
+
FROM credit_log
|
|
964
|
+
WHERE builder_id = $1
|
|
965
|
+
AND (task_id = ANY($2::bigint[]) OR ($3::text IS NOT NULL AND session_id = $3))`,
|
|
966
|
+
[builderId, ids, sid]
|
|
967
|
+
);
|
|
968
|
+
return rows[0] ? rows[0].total : 0;
|
|
969
|
+
}
|
|
970
|
+
|
|
934
971
|
// The three ideator streams (ADR 0172) each book under a reason key that EMBEDS
|
|
935
972
|
// the idea (and, for stream 1, the task) — that string IS the idempotency
|
|
936
973
|
// predicate, which is why it can't be a bare constant:
|
|
@@ -1038,6 +1075,7 @@ module.exports = {
|
|
|
1038
1075
|
netNegativeBonusAmount,
|
|
1039
1076
|
awardNetNegativeBonus,
|
|
1040
1077
|
sumCreditsForTasks,
|
|
1078
|
+
sumSessionEarnings,
|
|
1041
1079
|
// the ledger read surface (task 1003484, blocker 1000127)
|
|
1042
1080
|
creditLedger,
|
|
1043
1081
|
IDEA_STREAM_REASON_PREFIXES,
|
|
@@ -73,6 +73,11 @@ module.exports = {
|
|
|
73
73
|
getBuilderAchievements: (...a) => achievements.getBuilderAchievements(...a),
|
|
74
74
|
getLockedAchievementsProgress: (...a) => achievements.getLockedAchievementsProgress(...a),
|
|
75
75
|
sumCreditsForTasks: (...a) => credits.sumCreditsForTasks(...a),
|
|
76
|
+
// The two-key session earnings sum (task 1003634) — transaction-participant:
|
|
77
|
+
// the sessions module passes its tx `client` so it reads the cost-plus reward
|
|
78
|
+
// its own transaction just booked. On the port because credit_log is economy's
|
|
79
|
+
// table and sessions may only cross the wall through here (ADR 0083 / 0093 §2).
|
|
80
|
+
sumSessionEarnings: (...a) => credits.sumSessionEarnings(...a),
|
|
76
81
|
// The per-row ledger read (task 1003484). On the port because the surface core
|
|
77
82
|
// most wants it for is a HALL view — "what did this idea earn me" — and a core
|
|
78
83
|
// route must reach economy through here, never by importing the module.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/* board-room.css — Page-local styles for the Board Room (/builders/board-room).
|
|
2
|
+
|
|
3
|
+
Carved out of government.css by ADR 0266, with the room itself: these rules
|
|
4
|
+
were the `#board-room` tab's and nothing on the government page reads them
|
|
5
|
+
any more, so leaving them there would have been dead weight on a sheet two
|
|
6
|
+
other rooms still load. What lives here is what only this page has — the
|
|
7
|
+
sitting card in task 1003270's order (title, ballot, the idea, the
|
|
8
|
+
apparatus) and the ballot controls. The panel is the shell's .scroll and the
|
|
9
|
+
rows are the oversight family's (oversight.css); the ?v= cache-bust is
|
|
10
|
+
stamped from this file's content hash at boot (src/asset-version.js).
|
|
11
|
+
|
|
12
|
+
TWO RULES ARE DUPLICATED FROM government.css, KNOWINGLY. `.gov-grid-note` is
|
|
13
|
+
the government family's framing-line voice, and the sitting card spends it in
|
|
14
|
+
three places (decidedByHtml, the own-idea note, the objection hint) — while
|
|
15
|
+
the Constitution panel still spends it over there. Copying two short rules is
|
|
16
|
+
the cheaper of two bad options: the alternative is this page loading a
|
|
17
|
+
stylesheet named for a different room in order to inherit them, which is what
|
|
18
|
+
the first cut of this carve did and what the review called out. The NAMES stay
|
|
19
|
+
`gov-*` because board-room.js emits them and
|
|
20
|
+
tests/government_board_room_copy.mjs reads them as literal markup. */
|
|
21
|
+
|
|
22
|
+
/* The framing line under a heading — the family voice, duplicated per the header
|
|
23
|
+
note above rather than inherited across pages. */
|
|
24
|
+
.gov-grid-note { margin: 0 0 14px; font-size: 13.5px; line-height: 1.5; color: var(--ink-soft); max-width: var(--measure); }
|
|
25
|
+
.gov-grid-note strong { color: var(--ink); font-weight: 500; }
|
|
26
|
+
.gov-grid-note code { font-family: var(--font-mono); font-size: 12.5px; color: var(--ink); overflow-wrap: anywhere; }
|
|
27
|
+
|
|
28
|
+
/* "your vote" beside the reader's own row in the open ballot. */
|
|
29
|
+
.gov-held-note { font-size: 12.5px; color: var(--ok); white-space: nowrap; }
|
|
30
|
+
|
|
31
|
+
/* ---- the Board Room: one sitting, one panel -------------------------------------
|
|
32
|
+
The card order is task 1003270's (title, ballot, the idea, the apparatus); the
|
|
33
|
+
surface is the shell's .scroll (task 1003469). The subject of the vote is the
|
|
34
|
+
panel heading; the ballot sits directly under it; the idea is prose; the
|
|
35
|
+
constitutional apparatus stays one disclosure away with the tally on its summary. */
|
|
36
|
+
.gov-item__head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 14px; }
|
|
37
|
+
.gov-item__title {
|
|
38
|
+
margin: 0 0 8px;
|
|
39
|
+
min-width: 0;
|
|
40
|
+
font-family: var(--font-display);
|
|
41
|
+
font-weight: 300;
|
|
42
|
+
font-size: 21px;
|
|
43
|
+
line-height: 1.15;
|
|
44
|
+
letter-spacing: -0.03em;
|
|
45
|
+
color: var(--ink);
|
|
46
|
+
text-shadow: var(--type-halo);
|
|
47
|
+
overflow-wrap: anywhere;
|
|
48
|
+
}
|
|
49
|
+
.gov-item__title a { color: inherit; text-decoration: none; }
|
|
50
|
+
.gov-item__title a:hover { color: var(--accent-ink); text-decoration: underline; text-underline-offset: 3px; }
|
|
51
|
+
.gov-item__title .fact-pill { vertical-align: middle; margin-left: 10px; text-shadow: none; }
|
|
52
|
+
.gov-item__state { display: inline-flex; align-items: center; gap: 10px; font-size: 12.5px; color: var(--ink-faint); font-variant-numeric: tabular-nums; }
|
|
53
|
+
.gov-item__amend { margin: 0 0 12px; font-size: 13.5px; line-height: 1.5; color: var(--ink-soft); max-width: var(--measure); overflow-wrap: anywhere; }
|
|
54
|
+
.gov-item__act { margin: 8px 0 4px; }
|
|
55
|
+
.gov-item__cast { margin: 0; font-size: 13.5px; color: var(--ink-soft); }
|
|
56
|
+
.gov-item__meta { margin: var(--sp-sm) 0 0; border-top: var(--line) solid var(--rule-soft); }
|
|
57
|
+
.gov-item__summary { padding: 12px 0 0; min-height: 24px; cursor: pointer; font-size: 12.5px; color: var(--ink-soft); list-style-position: inside; }
|
|
58
|
+
.gov-item__summary:hover { color: var(--ink); }
|
|
59
|
+
.gov-item__metabody { padding: 12px 0 0; }
|
|
60
|
+
.gov-item__metabody .gov-grid-note { margin: 0 0 8px; }
|
|
61
|
+
.gov-item__metabody .gov-grid-note:last-child { margin-bottom: 0; }
|
|
62
|
+
.gov-item__metabody .ov-rows { margin-top: 12px; }
|
|
63
|
+
/* The head over the closed sittings sits on the ground between panels, so it
|
|
64
|
+
carries the air a panel would. */
|
|
65
|
+
.gov-board__divider { margin: var(--sp-md) 0 var(--sp-sm); }
|
|
66
|
+
|
|
67
|
+
/* ---- the ballot controls ------------------------------------------------------ */
|
|
68
|
+
/* The two ballot controls sit on one row; the objection disclosure takes the
|
|
69
|
+
whole row only once it is open, so its form drops under both buttons. */
|
|
70
|
+
.gov-vote { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 10px; }
|
|
71
|
+
.gov-vote > .gov-grid-note { flex: 1 1 100%; margin: 0; }
|
|
72
|
+
.gov-vote__no { flex: 0 1 auto; }
|
|
73
|
+
.gov-vote__no[open] { flex: 1 1 100%; }
|
|
74
|
+
/* The disclosure IS the secondary button, so its marker must go — otherwise the
|
|
75
|
+
triangle sits inside the pill. */
|
|
76
|
+
.gov-vote__no > summary { display: inline-flex; cursor: pointer; }
|
|
77
|
+
.gov-vote__no > summary::marker { content: ''; }
|
|
78
|
+
.gov-vote__no > summary::-webkit-details-marker { display: none; }
|
|
79
|
+
.gov-vote__no > *:not(summary) { margin-top: 10px; }
|
|
80
|
+
.gov-vote__no textarea { display: block; width: 100%; max-width: var(--measure); box-sizing: border-box; }
|
|
81
|
+
.gov-vote__sections { display: flex; flex-wrap: wrap; gap: 6px 16px; }
|
|
82
|
+
.gov-vote__section { display: inline-flex; align-items: center; gap: 8px; min-height: 24px; font-size: 13px; color: var(--ink); cursor: pointer; }
|
|
83
|
+
.gov-vote__section input { width: 16px; height: 16px; margin: 0; }
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Board Room — {{worldName}}</title>
|
|
7
|
+
<meta name="description" content="The Board Room — where a Full Idea that cleared the completeness grade is ratified, and where the constitution itself is amended.">
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
+
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
12
|
+
<link rel="stylesheet" href="/builders/oversight.css">
|
|
13
|
+
<!-- The room's own sheet: the sitting card and the ballot controls moved here
|
|
14
|
+
out of government.css with the room (ADR 0266), so neither page carries the
|
|
15
|
+
other's rules. board-room.css's header records the two family rules it
|
|
16
|
+
duplicates rather than inherit across pages. -->
|
|
17
|
+
<link rel="stylesheet" href="/builders/board-room.css">
|
|
18
|
+
</head>
|
|
19
|
+
<body data-page="board-room" data-page-title="Board Room">
|
|
20
|
+
|
|
21
|
+
<div class="app">
|
|
22
|
+
<aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
|
|
23
|
+
<div class="app-scrim" id="app-scrim" hidden></div>
|
|
24
|
+
<div class="app-body">
|
|
25
|
+
<header class="app-topbar" id="app-topbar"></header>
|
|
26
|
+
<div class="app-content">
|
|
27
|
+
<!-- The board page head (task 1003307): the LABEL voice, not a display
|
|
28
|
+
line, because on a working surface the page's own name is the least
|
|
29
|
+
interesting thing on it. -->
|
|
30
|
+
<div class="page-head page-head--board">
|
|
31
|
+
<h1 class="page-head__h">Board Room</h1>
|
|
32
|
+
<p class="page-head__sub" id="board-sub">Loading…</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<main id="hall-main">
|
|
36
|
+
<!-- task 1003469: the sealed state is a panel with a heading, not a bare
|
|
37
|
+
line on the ground. board-room.js writes the floor into the lede from
|
|
38
|
+
the pack's rank label, so the copy cannot drift. Unlike its
|
|
39
|
+
neighbours this page's floor is the VOTE atom (xenos), so the sealed
|
|
40
|
+
state is what a signed-out or ineligible visitor sees, not a rank
|
|
41
|
+
notice — see ADR 0266. -->
|
|
42
|
+
<section class="scroll" id="board-sealed" aria-labelledby="board-sealed-h" hidden>
|
|
43
|
+
<h2 class="scroll__h" id="board-sealed-h">Restricted</h2>
|
|
44
|
+
<p class="scroll__lede" id="board-sealed-body">This page is for members of the board.</p>
|
|
45
|
+
</section>
|
|
46
|
+
|
|
47
|
+
<!-- A sitting IS the panel (task 1003469), so the room itself is bare
|
|
48
|
+
ground: the lede sits over the sittings, each of which is a .scroll.
|
|
49
|
+
The <h1> above is the room's name, so there is no second heading. -->
|
|
50
|
+
<section id="board-body" hidden>
|
|
51
|
+
<p class="gov-grid-note">Read an idea, then decide. An objection needs a reason and the section it faults.</p>
|
|
52
|
+
<div id="gov-board"><span class="kit-skel"></span></div>
|
|
53
|
+
</section>
|
|
54
|
+
</main>
|
|
55
|
+
|
|
56
|
+
<footer class="app-foot" id="app-foot"></footer>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
62
|
+
|
|
63
|
+
<script src="/builders/shell.js?v=2026-07-02-shell1"></script>
|
|
64
|
+
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
65
|
+
<script src="/builders/palette.js" defer></script>
|
|
66
|
+
<script src="/builders/dom-utils.js?v=2026-06-16-board"></script>
|
|
67
|
+
<script src="/builders/hall-kit.js"></script>
|
|
68
|
+
<script src="/builders/board-room.js?v=2026-09-09-board-carve"></script>
|
|
69
|
+
</body>
|
|
70
|
+
</html>
|