@bongos/core 1.20.58 → 1.20.60
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 +198 -108
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +15 -2
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0358-a-hall-wears-a-look-and-its-night-takes-a-tint.md +52 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +317 -6
- package/docs/api-reference.md +8 -3
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +339 -330
- package/docs/copy-registry.json +524 -439
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +729 -678
- package/migrations/core_268_builders_main_discipline.sql +35 -0
- package/modules/copy-desk/artist-stats.js +56 -0
- package/modules/copy-desk/module.json +1 -1
- package/modules/copy-desk/page-status.js +39 -0
- package/modules/copy-desk/routes/copy-desk.js +7 -16
- package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
- package/modules/economy/credits-by-craft.js +208 -0
- package/modules/economy/reward.js +8 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/hall-render.js +2 -2
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +5 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +8 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/ideas/ideator-stats.js +149 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/inbox.js +7 -0
- package/modules/lifecycle/activity.js +5 -2
- package/modules/lifecycle/db-analytics.js +4 -2
- package/modules/lifecycle/db-tasks.js +49 -1
- package/modules/lifecycle/db.js +3 -2
- package/modules/lifecycle/page-tweak-reads.js +23 -5
- package/modules/provisioning/look.js +169 -0
- package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +4 -3
- package/modules/provisioning/routes/look.js +204 -0
- package/modules/provisioning/routes/provisioning.js +5 -59
- package/modules/provisioning/settings-push.js +76 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/projects.html +499 -28
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +6 -3
- package/modules/ui-design/kit/serve.js +50 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/fitness.js +4 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/bongos/db.js +44 -6
- package/src/bongos/role-stats.js +155 -0
- package/src/bongos/routes/builders.js +15 -2
- package/src/bongos/routes/me.js +11 -3
- package/src/bongos/routes.js +4 -1
- package/src/bongos/serve-internal.js +6 -0
- package/src/branding.js +26 -1
- package/src/hall-look.js +99 -0
- package/src/module-api.js +1 -1
- package/src/night-tint.js +26 -0
- package/tests/copy_desk_page_reads.mjs +1 -1
- package/tests/currency_label.mjs +7 -4
- package/tests/fixtures/page-ledger.mjs +1 -1
- package/tests/hall_tokens.mjs +10 -1
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/look_night_tint.mjs +169 -0
- package/tests/main_discipline.mjs +81 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/profile_role_stats.mjs +416 -0
- package/tests/projects_hub.mjs +5 -4
- package/tests/projects_hub_app_status.mjs +1 -1
- package/tests/projects_hub_app_step.mjs +8 -7
- package/tests/projects_hub_look.mjs +255 -0
- package/tests/projects_hub_module_picker.mjs +2 -2
- package/tests/provision_settings_apply.mjs +12 -4
- package/tests/provisioning_look.mjs +376 -0
- package/tests/provisioning_settings.mjs +10 -7
- package/tests/provisioning_settings_apply.mjs +4 -4
- package/tests/provisioning_settings_env.mjs +4 -0
- package/tests/role_stats_db.mjs +237 -0
- package/tests/tweak_replaces_artist_review.mjs +1 -1
- package/tests/wizard_draft_resume.mjs +2 -2
- package/tests/wizard_front_door.mjs +2 -2
- package/tests/wizard_intent_resume.mjs +16 -15
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- core_268 — a builder's MAIN role, chosen by the builder (task 1004433 /
|
|
2
|
+
-- WA6.RP03, goal 1000095, criterion wa6-roles-read-true-in-public)
|
|
3
|
+
--
|
|
4
|
+
-- The owner's 2026-09-30 decision on the role-shaped profile (task 1004431, the
|
|
5
|
+
-- approved mock docs/design/mocks/profile-roles/): one person has one profile,
|
|
6
|
+
-- each builder chooses their own MAIN role, and it leads their header, their
|
|
7
|
+
-- chips and their leaderboard column.
|
|
8
|
+
--
|
|
9
|
+
-- preferred_disciplines (027 + 086) cannot carry it. It is a set, and
|
|
10
|
+
-- updateBuilderDisciplines stores it in the CANONICAL order of the enabled
|
|
11
|
+
-- disciplines whatever order the builder picked, so its first element is the
|
|
12
|
+
-- alphabet's choice, not theirs. A separate nullable column says the one thing
|
|
13
|
+
-- the set cannot: which of them the builder put first.
|
|
14
|
+
--
|
|
15
|
+
-- NULL means "not chosen", and is a real answer: the profile then falls back to
|
|
16
|
+
-- the craft the builder has earned most in, and says the role was inferred. No
|
|
17
|
+
-- backfill, because nobody has chosen yet and inventing a choice for them is the
|
|
18
|
+
-- thing the column exists not to do.
|
|
19
|
+
--
|
|
20
|
+
-- No CHECK on the value. The discipline vocabulary is open (a module may
|
|
21
|
+
-- contribute one, ADR 0272), so the route validates against the enabled set and
|
|
22
|
+
-- against the builder's own preferred set, the same way preferred_disciplines
|
|
23
|
+
-- itself is validated since 086 dropped its CHECK.
|
|
24
|
+
--
|
|
25
|
+
-- Additive and idempotent: one nullable column, no default, no data. An older
|
|
26
|
+
-- release running against it never reads the column. Rollback is DROP COLUMN.
|
|
27
|
+
|
|
28
|
+
ALTER TABLE builders
|
|
29
|
+
ADD COLUMN IF NOT EXISTS main_discipline text;
|
|
30
|
+
|
|
31
|
+
COMMENT ON COLUMN builders.main_discipline IS
|
|
32
|
+
'The builder''s own choice of MAIN role (task 1004433). NULL = not chosen; the profile then infers it from credits earned. Always one of preferred_disciplines when set (enforced in updateBuilderDisciplines).';
|
|
33
|
+
|
|
34
|
+
INSERT INTO schema_migrations (version) VALUES ('core_268_builders_main_discipline')
|
|
35
|
+
ON CONFLICT DO NOTHING;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// modules/copy-desk/artist-stats.js — the artist's LEDGER READS: the studio
|
|
2
|
+
// corner's credit rows, and the profile's artist numbers (task 1004433 /
|
|
3
|
+
// WA6.RP03). Apart from routes/copy-desk.js because that file is at the
|
|
4
|
+
// 1,500-line ratchet (the page-decisions.js precedent); the route requires
|
|
5
|
+
// `tallyCreditRows` from here for GET /copy-desk/tally, so the studio and the
|
|
6
|
+
// profile read the same rows through the same call.
|
|
7
|
+
//
|
|
8
|
+
// Reads only. Nothing here writes, and nothing here returns a word of copy:
|
|
9
|
+
// counts, sums and surface labels. That is what lets `copy-desk.artistStats` be
|
|
10
|
+
// the one port this module provides without opening a copy write path
|
|
11
|
+
// (tests/copy_no_cms.mjs pins exactly this one).
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const api = require('../../src/module-api');
|
|
16
|
+
const pageData = require('./page-data');
|
|
17
|
+
const pageStatus = require('./page-status');
|
|
18
|
+
|
|
19
|
+
// Every `tweak.page_approved:` credit row the builder has, through the reward
|
|
20
|
+
// port (credit_log is economy's table). No reward port, or no approved page
|
|
21
|
+
// landed yet (economy books one row per landed round, task 1004324), reads as
|
|
22
|
+
// none: a tally of 0 is the truth then.
|
|
23
|
+
//
|
|
24
|
+
// ONE bounded query. An approved page books one row, so the cap is thousands of
|
|
25
|
+
// pages for one artist; past it the tally says `truncated` (creditLedger's
|
|
26
|
+
// `total` is the unpaginated count) rather than paging in a loop.
|
|
27
|
+
const TALLY_ROW_CAP = 5000;
|
|
28
|
+
async function tallyCreditRows(builderId) {
|
|
29
|
+
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
30
|
+
if (!reward || typeof reward.creditLedger !== 'function') return { rows: [], truncated: false };
|
|
31
|
+
const { rows, total } = await reward.creditLedger(builderId, { reasonPrefix: pageStatus.TALLY_REASON_PREFIX, limit: TALLY_ROW_CAP });
|
|
32
|
+
const list = rows || [];
|
|
33
|
+
return { rows: list, truncated: Number(total) > list.length };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The profile's artist numbers, for core's profile route through the
|
|
37
|
+
// `copy-desk.artistStats` port: the SAME reads the studio's tally and page list
|
|
38
|
+
// make, composed by page-status.js composeArtistStats. No lifecycle port reads
|
|
39
|
+
// as null (could not ask), never as zero. A missing page inventory leaves the
|
|
40
|
+
// per-surface lines empty and keeps the tally, which comes from the ledger.
|
|
41
|
+
async function readArtistStats(builderId, { now = new Date() } = {}) {
|
|
42
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
43
|
+
if (!lifecycle || typeof lifecycle.listPageTweakTasks !== 'function') return null;
|
|
44
|
+
const inv = pageData.loadInventory();
|
|
45
|
+
// Only this artist's rounds, read from their own web claims: a public page
|
|
46
|
+
// must not cost a scan of every round the project has ever had.
|
|
47
|
+
const [tasks, credit] = await Promise.all([lifecycle.listPageTweakTasks({ artistId: builderId }), tallyCreditRows(builderId)]);
|
|
48
|
+
const out = pageStatus.composeArtistStats({
|
|
49
|
+
builderId, inventory: inv.ok ? inv.data : null, tasks, creditRows: credit.rows, now,
|
|
50
|
+
});
|
|
51
|
+
out.inventory_known = !!inv.ok;
|
|
52
|
+
out.truncated = credit.truncated;
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { tallyCreditRows, readArtistStats, TALLY_ROW_CAP };
|
|
@@ -347,6 +347,44 @@ function composeTally({ builderId, tasks, creditRows, now }) {
|
|
|
347
347
|
};
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
// The profile's ARTIST numbers (task 1004433 / WA6.RP03; the mock is
|
|
351
|
+
// docs/design/mocks/profile-roles/): the corner tally above, unchanged, plus
|
|
352
|
+
// "N of M tweaked" per surface for THIS artist. The studio's per-surface line
|
|
353
|
+
// (composePages) counts every artist's rounds; a profile is one person's record,
|
|
354
|
+
// so a page counts here when it has a shipped round whose artist is them. M is
|
|
355
|
+
// the surface's whole page count, the same denominator the studio shows.
|
|
356
|
+
//
|
|
357
|
+
// `pages_approved` is the tally's own count of approved ROUNDS (each one paid);
|
|
358
|
+
// `pages` is how many DISTINCT pages those rounds touched, the sum of the
|
|
359
|
+
// per-surface N. The two differ once an artist tweaks the same page twice.
|
|
360
|
+
//
|
|
361
|
+
// Counts and sums only: no wording, no page title beyond the surface's label,
|
|
362
|
+
// because the owner's decision (a) keeps these numbers public and the pages
|
|
363
|
+
// themselves behind a sign-in.
|
|
364
|
+
function composeArtistStats({ builderId, inventory, tasks, creditRows, now }) {
|
|
365
|
+
const tally = composeTally({ builderId, tasks, creditRows, now });
|
|
366
|
+
const byPage = indexRounds(tasks);
|
|
367
|
+
const mine = new Set();
|
|
368
|
+
for (const [pageId, rounds] of byPage) {
|
|
369
|
+
if (rounds.some((r) => r.status === 'shipped' && String(r.artist_id) === String(builderId))) mine.add(pageId);
|
|
370
|
+
}
|
|
371
|
+
const all = (inventory && inventory.pages) || [];
|
|
372
|
+
const surfaces = orderedSurfaces(inventory).map((id) => {
|
|
373
|
+
const on = all.filter((p) => p && p.surface === id);
|
|
374
|
+
const tweaked = on.filter((p) => mine.has(p.id)).length;
|
|
375
|
+
return { id, label: SURFACE_LABELS[id] || id, tweaked, total: on.length, line: `${tweaked} of ${on.length} tweaked` };
|
|
376
|
+
});
|
|
377
|
+
return {
|
|
378
|
+
pages_approved: tally.pages_tweaked,
|
|
379
|
+
pages: surfaces.reduce((n, s) => n + s.tweaked, 0),
|
|
380
|
+
surfaces_touched: surfaces.filter((s) => s.tweaked > 0).length,
|
|
381
|
+
credits: tally.credits,
|
|
382
|
+
this_week: tally.this_week,
|
|
383
|
+
week_since: tally.week_since,
|
|
384
|
+
surfaces,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
350
388
|
// ---------------------------------------------------------------------------
|
|
351
389
|
// The recommendation, "what should I tweak next" (task 1004318 / BV2.TW07,
|
|
352
390
|
// ADR 0341 D8). The fourth read, over the same ledger rows as the other three
|
|
@@ -689,4 +727,5 @@ module.exports = {
|
|
|
689
727
|
composePage,
|
|
690
728
|
lastRoundShippedAt,
|
|
691
729
|
composeTally,
|
|
730
|
+
composeArtistStats,
|
|
692
731
|
};
|
|
@@ -62,6 +62,7 @@ const pageStatus = require('../page-status');
|
|
|
62
62
|
const pages = require('../pages');
|
|
63
63
|
const docx = require('../docx');
|
|
64
64
|
const { waitingRoundOrFail, refuseDecision, nextWaiting } = require('../page-decisions');
|
|
65
|
+
const { tallyCreditRows, readArtistStats } = require('../artist-stats');
|
|
65
66
|
|
|
66
67
|
const { validateOrRespond } = api;
|
|
67
68
|
|
|
@@ -360,26 +361,16 @@ function pageReadsOrFail(res) {
|
|
|
360
361
|
return lifecycle;
|
|
361
362
|
}
|
|
362
363
|
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
// landed yet (economy books one row per landed round, task 1004324), reads as
|
|
366
|
-
// none: a tally of 0 is the truth then.
|
|
367
|
-
//
|
|
368
|
-
// ONE bounded query. An approved page books one row, so the cap is thousands of
|
|
369
|
-
// pages for one artist; past it the tally says `truncated` (creditLedger's
|
|
370
|
-
// `total` is the unpaginated count) rather than paging in a loop.
|
|
371
|
-
const TALLY_ROW_CAP = 5000;
|
|
372
|
-
async function tallyCreditRows(builderId) {
|
|
373
|
-
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
374
|
-
if (!reward || typeof reward.creditLedger !== 'function') return { rows: [], truncated: false };
|
|
375
|
-
const { rows, total } = await reward.creditLedger(builderId, { reasonPrefix: pageStatus.TALLY_REASON_PREFIX, limit: TALLY_ROW_CAP });
|
|
376
|
-
const list = rows || [];
|
|
377
|
-
return { rows: list, truncated: Number(total) > list.length };
|
|
378
|
-
}
|
|
364
|
+
// The tally's credit rows and the profile's artist numbers live in
|
|
365
|
+
// ../artist-stats.js (task 1004433); this file is at the 1,500-line ratchet.
|
|
379
366
|
|
|
380
367
|
module.exports = function buildCopyDeskRouter() {
|
|
381
368
|
const router = express.Router();
|
|
382
369
|
|
|
370
|
+
if (!api.hasProvider('copy-desk.artistStats')) {
|
|
371
|
+
api.registerProvider('copy-desk.artistStats', readArtistStats);
|
|
372
|
+
}
|
|
373
|
+
|
|
383
374
|
// Page asks resolve when the page's tweak ships (ADR 0341 D6). Re-registered
|
|
384
375
|
// on every router build (tests rebuild it), never stacked.
|
|
385
376
|
if (_askListenerOff) _askListenerOff();
|
|
@@ -287,7 +287,16 @@ await test('the Approval queue decides only through the port, and a send-back wr
|
|
|
287
287
|
|
|
288
288
|
await test('the module manifest declares no seam another module could write copy through', () => {
|
|
289
289
|
const manifest = JSON.parse(read('module.json'));
|
|
290
|
-
|
|
290
|
+
// ONE port, and it is a read of counts (task 1004433 / WA6.RP03): the
|
|
291
|
+
// profile's artist numbers. Anything more provided here would be a seam
|
|
292
|
+
// another module could reach copy through, so the list is pinned exactly and
|
|
293
|
+
// the provider is held to returning no wording below.
|
|
294
|
+
assert.deepEqual(manifest.provides, ['copy-desk.artistStats'], 'a provided port here would be a copy write path for every other module');
|
|
295
|
+
const stats = read('artist-stats.js');
|
|
296
|
+
assert.equal(/\b(pool|query|writeFile|appendFile|INSERT|UPDATE|DELETE)\b/.test(stats.replace(/\/\/.*$/gm, '')), false,
|
|
297
|
+
'the artist-stats port reads through other ports and writes nothing');
|
|
298
|
+
assert.equal(/\b(lines|before|after|reading|description|title)\s*:/.test(stats.replace(/\/\/.*$/gm, '')), false,
|
|
299
|
+
'the artist-stats port returns counts and sums, never wording');
|
|
291
300
|
// R03 consumes `lifecycle` to create the proposal task. That direction is the
|
|
292
301
|
// safe one and the point: this module can put a work request INTO the ledger,
|
|
293
302
|
// and nothing can pull copy OUT of this module.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/credits-by-craft.js — WHICH CRAFT EARNED A BUILDER'S CREDITS
|
|
4
|
+
// (task 1004433 / WA6.RP03, goal 1000095, criterion wa6-roles-read-true-in-public).
|
|
5
|
+
//
|
|
6
|
+
// WHY. builders.total_credits is one number, and every public surface read it as
|
|
7
|
+
// "payout from shipped work". That is true for an engineer and false for the
|
|
8
|
+
// other two crafts: an artist is paid per approved page (`tweak.page_approved:`),
|
|
9
|
+
// an ideator per idea stream (`idea.credit.*`), and neither has to ship a task to
|
|
10
|
+
// earn. The owner's 2026-09-30 rules need the split: a role other than the main
|
|
11
|
+
// one shows (chip, Activity tab, leaderboard entry) only once it has EARNED
|
|
12
|
+
// credits, and a leaderboard filtered to one role ranks by the credits earned in
|
|
13
|
+
// that craft. So the split is a ledger fact, read here, once.
|
|
14
|
+
//
|
|
15
|
+
// THE RULE, per credit_log row:
|
|
16
|
+
//
|
|
17
|
+
// tweak.page_approved:<task> artist (ADR 0341 D10, the artist lane)
|
|
18
|
+
// idea.credit.* · idea_promotion_bonus ideator (ADR 0172, every idea stream,
|
|
19
|
+
// the spark/developer split too)
|
|
20
|
+
// task.shipped · task.confirmed · the craft of the TASK's discipline
|
|
21
|
+
// ship.net_negative
|
|
22
|
+
// session.token_reward the craft of the tasks that session
|
|
23
|
+
// shipped, split evenly across them; a
|
|
24
|
+
// session that names none is engineering
|
|
25
|
+
// (an AI build session is, by default)
|
|
26
|
+
// anything else (achievement.*, `other` — credited, but to no craft
|
|
27
|
+
// rank_change, a hand grant)
|
|
28
|
+
//
|
|
29
|
+
// A task's discipline maps to a craft through DISCIPLINE_CRAFT. `ui` is the
|
|
30
|
+
// module-contributed design discipline (ADR 0079, 0272) and counts as ARTIST:
|
|
31
|
+
// the artist's craft is how the project looks and reads (docs/packs/artist.md),
|
|
32
|
+
// and UI design is that. A discipline this table does not know (a module may
|
|
33
|
+
// contribute one later) counts as engineering, the default craft, rather than
|
|
34
|
+
// vanishing from every total.
|
|
35
|
+
//
|
|
36
|
+
// THE SUM IS EXACT. A session's reward split across three tasks is a third each,
|
|
37
|
+
// so the per-craft figures arrive as fractions. They are rounded by LARGEST
|
|
38
|
+
// REMAINDER against the builder's true ledger total, so the crafts and `other`
|
|
39
|
+
// always add up to exactly what the ledger holds. Rounding each craft on its own
|
|
40
|
+
// would let the parts disagree with the whole by a credit, on a page that shows
|
|
41
|
+
// both.
|
|
42
|
+
//
|
|
43
|
+
// ONLY READS. Nothing here writes, and nothing is reachable from a ship path.
|
|
44
|
+
|
|
45
|
+
const api = require('../../src/module-api');
|
|
46
|
+
const { pool } = api;
|
|
47
|
+
const { ARTIST_PAGE_REASON_PREFIX } = require('./credits');
|
|
48
|
+
|
|
49
|
+
const CRAFTS = Object.freeze(['engineer', 'artist', 'ideator']);
|
|
50
|
+
const DEFAULT_CRAFT = 'engineer';
|
|
51
|
+
const OTHER = 'other';
|
|
52
|
+
|
|
53
|
+
const DISCIPLINE_CRAFT = Object.freeze({
|
|
54
|
+
engineer: 'engineer',
|
|
55
|
+
artist: 'artist',
|
|
56
|
+
ui: 'artist',
|
|
57
|
+
ideator: 'ideator',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// The per-task reasons whose craft is the task's own. The same three the
|
|
61
|
+
// lifecycle's CREDITS_AWARDED_SUBQUERY sums as a task's payout.
|
|
62
|
+
const TASK_REASONS = Object.freeze(['task.shipped', 'task.confirmed', 'ship.net_negative']);
|
|
63
|
+
|
|
64
|
+
// The craft a builder's PREFERRED discipline names, or null for one that is not
|
|
65
|
+
// a craft at all. The same table, read the other way round: an unknown
|
|
66
|
+
// discipline is engineering money (above) but is no role chip. core's
|
|
67
|
+
// src/bongos/role-stats.js asks this through the reward port, so the ui-is-artist
|
|
68
|
+
// rule has exactly one home.
|
|
69
|
+
function craftForPreference(discipline) {
|
|
70
|
+
return DISCIPLINE_CRAFT[discipline] || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function craftOfDiscipline(discipline) {
|
|
74
|
+
if (discipline === OTHER) return OTHER;
|
|
75
|
+
return DISCIPLINE_CRAFT[discipline] || DEFAULT_CRAFT;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The artist prefix goes into the SQL as a LIKE pattern, so its underscore (a
|
|
79
|
+
// LIKE wildcard) is escaped, the idea-size.js rule. Checked at load: the prefix
|
|
80
|
+
// is economy's own constant, never a request value.
|
|
81
|
+
if (!/^[a-z0-9_.]+:$/i.test(ARTIST_PAGE_REASON_PREFIX)) {
|
|
82
|
+
throw new Error('economy/credits-by-craft: the artist reason prefix is not a plain literal');
|
|
83
|
+
}
|
|
84
|
+
const ARTIST_LIKE = ARTIST_PAGE_REASON_PREFIX.replace(/([\\%_])/g, '\\$1') + '%';
|
|
85
|
+
|
|
86
|
+
// One statement for the whole set of builders. Each ledger row becomes one or
|
|
87
|
+
// more (builder, discipline, amount) parts, then they are grouped. The session
|
|
88
|
+
// branch is the only one that fans out: one row per task its session shipped.
|
|
89
|
+
//
|
|
90
|
+
// THE INDEXES IT RIDES, all pre-existing, so this needs no migration: both
|
|
91
|
+
// branches filter `cl.builder_id = ANY($1)`, served by idx_credit_log_builder
|
|
92
|
+
// (builder_id, recorded_at) from migrations/003_pms.sql, so the read is that
|
|
93
|
+
// person's rows, never the whole ledger; the task join is the tasks primary
|
|
94
|
+
// key, and the session join is session_records' UNIQUE (session_id) from
|
|
95
|
+
// migrations/062_session_records.sql. tests/profile_role_stats.mjs pins both
|
|
96
|
+
// index definitions, so dropping either reds a test rather than going quiet.
|
|
97
|
+
const CREDITS_BY_CRAFT_SQL = `
|
|
98
|
+
WITH parts AS (
|
|
99
|
+
SELECT cl.builder_id,
|
|
100
|
+
cl.delta::numeric AS amount,
|
|
101
|
+
CASE
|
|
102
|
+
WHEN cl.reason LIKE $2 THEN 'artist'
|
|
103
|
+
WHEN cl.reason LIKE 'idea.credit.%' OR cl.reason = 'idea_promotion_bonus' THEN 'ideator'
|
|
104
|
+
WHEN cl.reason = ANY($3::text[]) THEN COALESCE(t.discipline, '${DEFAULT_CRAFT}')
|
|
105
|
+
ELSE '${OTHER}'
|
|
106
|
+
END AS discipline
|
|
107
|
+
FROM credit_log cl
|
|
108
|
+
LEFT JOIN tasks t ON t.id = cl.task_id
|
|
109
|
+
WHERE cl.builder_id = ANY($1::bigint[])
|
|
110
|
+
AND cl.reason <> 'session.token_reward'
|
|
111
|
+
UNION ALL
|
|
112
|
+
SELECT cl.builder_id,
|
|
113
|
+
cl.delta::numeric / GREATEST(COALESCE(cardinality(sr.task_ids), 0), 1) AS amount,
|
|
114
|
+
COALESCE(st.discipline, '${DEFAULT_CRAFT}') AS discipline
|
|
115
|
+
FROM credit_log cl
|
|
116
|
+
LEFT JOIN session_records sr ON sr.session_id = cl.session_id
|
|
117
|
+
LEFT JOIN LATERAL unnest(COALESCE(sr.task_ids, '{}'::bigint[])) AS u(task_id) ON true
|
|
118
|
+
LEFT JOIN tasks st ON st.id = u.task_id
|
|
119
|
+
WHERE cl.builder_id = ANY($1::bigint[])
|
|
120
|
+
AND cl.reason = 'session.token_reward'
|
|
121
|
+
)
|
|
122
|
+
SELECT builder_id, discipline, SUM(amount)::float8 AS amount
|
|
123
|
+
FROM parts
|
|
124
|
+
GROUP BY builder_id, discipline`;
|
|
125
|
+
|
|
126
|
+
// apportion — round fractional parts to integers that sum to `total` exactly
|
|
127
|
+
// (largest remainder; ties go to the earlier key, so the answer is stable).
|
|
128
|
+
// Pure. `parts` is { key: number }, in the order keys should win ties.
|
|
129
|
+
function apportion(parts, total) {
|
|
130
|
+
const keys = Object.keys(parts);
|
|
131
|
+
const out = {};
|
|
132
|
+
let floorSum = 0;
|
|
133
|
+
const rema = [];
|
|
134
|
+
keys.forEach((k, i) => {
|
|
135
|
+
const v = Number(parts[k]) || 0;
|
|
136
|
+
const f = Math.floor(v);
|
|
137
|
+
out[k] = f;
|
|
138
|
+
floorSum += f;
|
|
139
|
+
rema.push({ k, r: v - f, i });
|
|
140
|
+
});
|
|
141
|
+
// When `total` is the rounded sum of the parts (the only way this file calls
|
|
142
|
+
// it), `left` is round(sum of remainders), so 0 <= left <= keys.length and one
|
|
143
|
+
// pass hands out every unit.
|
|
144
|
+
const left = Math.round(Number(total) || 0) - floorSum;
|
|
145
|
+
rema.sort((a, b) => b.r - a.r || a.i - b.i);
|
|
146
|
+
for (let j = 0; j < left && j < rema.length; j++) out[rema[j].k] += 1;
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// foldCraftRows — the grouped SQL rows into one exact row per builder. Pure,
|
|
151
|
+
// so the rounding and the discipline mapping are testable without a database.
|
|
152
|
+
// Returns Map<builderIdString, { engineer, artist, ideator, other, total }>,
|
|
153
|
+
// with every builder asked for present (at zero when they have earned nothing:
|
|
154
|
+
// "no credits" is an answer, not a gap).
|
|
155
|
+
function foldCraftRows(ids, rows) {
|
|
156
|
+
const raw = new Map();
|
|
157
|
+
for (const id of ids) {
|
|
158
|
+
const parts = {};
|
|
159
|
+
for (const c of CRAFTS) parts[c] = 0;
|
|
160
|
+
parts[OTHER] = 0;
|
|
161
|
+
raw.set(id, parts);
|
|
162
|
+
}
|
|
163
|
+
for (const r of rows || []) {
|
|
164
|
+
const parts = raw.get(String(r.builder_id));
|
|
165
|
+
if (!parts) continue;
|
|
166
|
+
parts[craftOfDiscipline(r.discipline)] += Number(r.amount) || 0;
|
|
167
|
+
}
|
|
168
|
+
const out = new Map();
|
|
169
|
+
for (const [id, parts] of raw) {
|
|
170
|
+
const total = Math.round(Object.values(parts).reduce((a, b) => a + b, 0));
|
|
171
|
+
out.set(id, { ...apportion(parts, total), total });
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function idOf(v) {
|
|
177
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// creditsByCraft — the split for MANY builders in ONE query. The leaderboard's
|
|
181
|
+
// role toggle (RP07) asks for a page of builders at once, the profile for one.
|
|
182
|
+
async function creditsByCraft(builderIds, { client = null } = {}) {
|
|
183
|
+
const ids = [...new Set((Array.isArray(builderIds) ? builderIds : [builderIds]).map(idOf).filter(Boolean))];
|
|
184
|
+
if (ids.length === 0) return new Map();
|
|
185
|
+
const q = client || pool;
|
|
186
|
+
const { rows } = await q.query(CREDITS_BY_CRAFT_SQL, [ids, ARTIST_LIKE, TASK_REASONS]);
|
|
187
|
+
return foldCraftRows(ids, rows);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The crafts a builder has EARNED in, in CRAFTS order. The owner's rule is that
|
|
191
|
+
// a role other than the main one shows only once it has earned credits; this is
|
|
192
|
+
// the "has earned" half, and the only place it is decided.
|
|
193
|
+
function earnedCrafts(row) {
|
|
194
|
+
if (!row) return [];
|
|
195
|
+
return CRAFTS.filter((c) => Number(row[c]) > 0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
creditsByCraft,
|
|
200
|
+
foldCraftRows,
|
|
201
|
+
apportion,
|
|
202
|
+
craftOfDiscipline,
|
|
203
|
+
craftForPreference,
|
|
204
|
+
earnedCrafts,
|
|
205
|
+
CRAFTS,
|
|
206
|
+
DISCIPLINE_CRAFT,
|
|
207
|
+
CREDITS_BY_CRAFT_SQL,
|
|
208
|
+
};
|
|
@@ -22,6 +22,7 @@ const streak = require('./streak');
|
|
|
22
22
|
const ideaSizeLib = require('./idea-size');
|
|
23
23
|
const ideaSizeHistorical = require('./idea-size-historical');
|
|
24
24
|
const ideaBrightnessLib = require('./idea-brightness');
|
|
25
|
+
const creditsByCraftLib = require('./credits-by-craft');
|
|
25
26
|
|
|
26
27
|
module.exports = {
|
|
27
28
|
// credit allocation — transaction-participant: caller passes its tx `client`
|
|
@@ -125,6 +126,13 @@ module.exports = {
|
|
|
125
126
|
// still alive.
|
|
126
127
|
ideaBrightness: (...a) => ideaBrightnessLib.ideaBrightness(...a),
|
|
127
128
|
ideaBrightnesses: (...a) => ideaBrightnessLib.ideaBrightnesses(...a),
|
|
129
|
+
// WHICH CRAFT EARNED IT (task 1004433 / WA6.RP03). total_credits split into
|
|
130
|
+
// engineer / artist / ideator / other, exact to the ledger. The profile reads
|
|
131
|
+
// it to decide which roles show; the leaderboard's role toggle ranks by it.
|
|
132
|
+
creditsByCraft: (...a) => creditsByCraftLib.creditsByCraft(...a),
|
|
133
|
+
earnedCrafts: (...a) => creditsByCraftLib.earnedCrafts(...a),
|
|
134
|
+
craftForPreference: (...a) => creditsByCraftLib.craftForPreference(...a),
|
|
135
|
+
get CRAFTS() { return creditsByCraftLib.CRAFTS; },
|
|
128
136
|
computeStreak: (...a) => streak.computeStreak(...a),
|
|
129
137
|
wouldExtendStreak: (...a) => streak.wouldExtendStreak(...a),
|
|
130
138
|
};
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
|
|
92
92
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
93
93
|
|
|
94
|
-
<script src="/builders/shell.js?v=2026-
|
|
94
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
95
95
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
96
96
|
<script src="/builders/palette.js" defer></script>
|
|
97
97
|
<script src="/builders/atlas.js?v=2026-09-02-index" defer></script>
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
|
|
51
51
|
<!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
|
|
52
52
|
chrome exists before the page script runs. -->
|
|
53
|
-
<script src="/builders/shell.js?v=2026-
|
|
53
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
54
54
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
55
55
|
<script src="/builders/palette.js" defer></script>
|
|
56
56
|
<!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
|
|
61
61
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
62
62
|
|
|
63
|
-
<script src="/builders/shell.js?v=2026-
|
|
63
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
64
64
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
65
65
|
<script src="/builders/palette.js" defer></script>
|
|
66
66
|
<script src="/builders/dom-utils.js?v=2026-06-16-board"></script>
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
|
|
149
149
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
150
150
|
|
|
151
|
-
<script src="/builders/shell.js?v=2026-
|
|
151
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
152
152
|
<script src="/builders/palette.js" defer></script>
|
|
153
153
|
<script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
|
|
154
154
|
<script src="/builders/collab-lib.js?v=2026-09-24-archive"></script>
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
|
|
55
55
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
56
56
|
|
|
57
|
-
<script src="/builders/shell.js?v=2026-
|
|
57
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
58
58
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
59
59
|
<script src="/builders/palette.js" defer></script>
|
|
60
60
|
<script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
|
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
|
|
178
178
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
179
179
|
|
|
180
|
-
<script src="/builders/shell.js?v=2026-
|
|
180
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
181
181
|
<script src="/builders/palette.js" defer></script>
|
|
182
182
|
<script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
|
|
183
183
|
<script src="/builders/hall-kit.js"></script>
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
|
|
75
75
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
76
76
|
|
|
77
|
-
<script src="/builders/shell.js?v=2026-
|
|
77
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
78
78
|
<script src="/builders/palette.js" defer></script>
|
|
79
79
|
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
80
80
|
<script src="/builders/hall-kit.js"></script>
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
|
|
98
98
|
<!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
|
|
99
99
|
time so the chrome exists before the deferred page scripts execute. -->
|
|
100
|
-
<script src="/builders/shell.js?v=2026-
|
|
100
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
101
101
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
102
102
|
<script src="/builders/palette.js" defer></script>
|
|
103
103
|
<script src="/builders/diagram-viewer.js?v=2026-06-14-diagviewer-1" defer></script>
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
|
|
99
99
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
100
100
|
|
|
101
|
-
<script src="/builders/shell.js?v=2026-
|
|
101
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
102
102
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
103
103
|
<script src="/builders/palette.js" defer></script>
|
|
104
104
|
<script src="/builders/md-reader.js?v=2026-09-02-mdreader-2"></script>
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
|
|
55
55
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
56
56
|
|
|
57
|
-
<script src="/builders/shell.js?v=2026-
|
|
57
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
58
58
|
<script src="/builders/palette.js" defer></script>
|
|
59
59
|
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
60
60
|
<script src="/builders/hall-kit.js"></script>
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
<div id="gate-modal-root"></div>
|
|
77
77
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
78
78
|
|
|
79
|
-
<script src="/builders/shell.js?v=2026-
|
|
79
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
80
80
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
81
81
|
<script src="/builders/palette.js" defer></script>
|
|
82
82
|
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
</div>
|
|
138
138
|
</div>
|
|
139
139
|
|
|
140
|
-
<script src="/builders/shell.js?v=2026-
|
|
140
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
141
141
|
<script src="/builders/palette.js" defer></script>
|
|
142
142
|
|
|
143
143
|
<script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
|
|
@@ -230,7 +230,7 @@
|
|
|
230
230
|
|
|
231
231
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
232
232
|
|
|
233
|
-
<script src="/builders/shell.js?v=2026-
|
|
233
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
234
234
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
235
235
|
<script src="/builders/palette.js" defer></script>
|
|
236
236
|
<script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
|
|
135
135
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
136
136
|
|
|
137
|
-
<script src="/builders/shell.js?v=2026-
|
|
137
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
138
138
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
139
139
|
<script src="/builders/palette.js" defer></script>
|
|
140
140
|
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
@@ -510,8 +510,8 @@
|
|
|
510
510
|
? `+${fmtNum(karma30d)} in 30d`
|
|
511
511
|
: (karma30d < 0 ? `${fmtNum(karma30d)} in 30d` : '±0 in 30d');
|
|
512
512
|
const karmaTrend = `<span class="karma-badge__trend ${signClasses(karma30d, 'karma-badge__trend')}">${karmaTrendText}</span>`;
|
|
513
|
-
const karmaTooltip = `Karma = peer recognition and approvals. ${curLabel(true)} =
|
|
514
|
-
const drachmaeTooltip = `${curLabel(true)} =
|
|
513
|
+
const karmaTooltip = `Karma = peer recognition and approvals. ${curLabel(true)} = everything earned. The two counters move independently — see the karma feed for the audit trail.`;
|
|
514
|
+
const drachmaeTooltip = `${curLabel(true)} = everything earned: shipped work, approved pages and ideas. Karma is a separate counter for peer recognition and approvals.`;
|
|
515
515
|
|
|
516
516
|
// V3.R84 (#302), criterion C3: monthly-budget banner. `warn` (>=80%) is a
|
|
517
517
|
// heads-up; `over` (>=100%) means cost-incurring actions (ship, art pipeline)
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
|
|
53
53
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
54
54
|
|
|
55
|
-
<script src="/builders/shell.js?v=2026-
|
|
55
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
56
56
|
<script src="/builders/palette.js" defer></script>
|
|
57
57
|
<script src="/builders/dom-utils.js?v=2026-06-23-contrib" defer></script>
|
|
58
58
|
<!-- task-detail.js still owns the #/idea/N and #/blocker/N overlay, so a ref
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
|
|
226
226
|
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
227
227
|
|
|
228
|
-
<script src="/builders/shell.js?v=2026-
|
|
228
|
+
<script src="/builders/shell.js?v=2026-10-01-look"></script>
|
|
229
229
|
<script src="/builders/palette.js" defer></script>
|
|
230
230
|
<script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
|
|
231
231
|
<!-- task 1003069 (R08) — the quick-filing form's routed result links a task
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
<script src="/builders/brand-apply.js?v=2026-06-27-r75" defer></script>
|
|
107
107
|
<!-- The app shell (sidebar + top bar + view router). Dependency-free; loaded
|
|
108
108
|
BEFORE builders.js so the chrome ids exist when events are wired. -->
|
|
109
|
-
<script src="/builders/shell.js?v=2026-
|
|
109
|
+
<script src="/builders/shell.js?v=2026-10-01-look" defer></script>
|
|
110
110
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
111
111
|
<script src="/builders/palette.js" defer></script>
|
|
112
112
|
<!-- task 2166: the shared component kit (window.OTBKit) — pager/ledger/rail.
|