@bongos/core 1.20.83 → 1.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.bongos-core.json +186 -81
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
  8. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
  9. package/docs/adr/README.md +1 -1
  10. package/docs/api/openapi.json +153 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/architecture.md +3 -0
  13. package/docs/copy-inventory.md +60 -59
  14. package/docs/copy-registry.json +72 -63
  15. package/docs/design/project-startup-direction.md +63 -0
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +8 -0
  18. package/docs/page-inventory.json +9 -3
  19. package/docs/page-readings.json +427 -410
  20. package/docs/recipes/private-npm-distribution.md +1 -1
  21. package/migrations/core_269_session_transcripts.sql +35 -0
  22. package/migrations/core_270_craft_achievements.sql +128 -0
  23. package/modules/economy/achievements.js +199 -4
  24. package/modules/economy/credits.js +16 -0
  25. package/modules/economy/reward.js +3 -0
  26. package/modules/hall-ui/public/builders.js +1 -1
  27. package/modules/hall-ui/public/genesis-home.js +17 -3
  28. package/modules/hall-ui/public/hall-render.js +24 -2
  29. package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
  30. package/modules/hall-ui/public/roster.css +21 -0
  31. package/modules/hall-ui/public/roster.html +3 -0
  32. package/modules/hall-ui/public/roster.js +121 -21
  33. package/modules/hall-ui/public/roster.states.json +26 -1
  34. package/modules/hall-ui/records/builders-roster.md +20 -0
  35. package/modules/lifecycle/publish-reconciler.js +25 -0
  36. package/modules/lifecycle/workflow-dispatch.js +21 -11
  37. package/modules/npm-release/release.js +10 -1
  38. package/modules/onboarding/founding-birth.js +116 -0
  39. package/modules/onboarding/genesis-home.js +72 -5
  40. package/modules/onboarding/port.js +9 -2
  41. package/modules/onboarding/routes/onboarding.js +8 -1
  42. package/modules/provisioning/birth.js +64 -0
  43. package/modules/provisioning/dns-resolves.js +54 -0
  44. package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
  45. package/modules/provisioning/pollers/liveness-sweep.js +5 -0
  46. package/modules/provisioning/provisioning.js +7 -6
  47. package/modules/provisioning/routes/provisioning.js +6 -2
  48. package/modules/provisioning/tests/liveness.mjs +48 -0
  49. package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
  50. package/modules/public-landing/public/projects.html +40 -15
  51. package/modules/sessions/db.js +58 -0
  52. package/modules/sessions/routes/sessions.js +97 -0
  53. package/modules/status-ui/public/status.js +39 -7
  54. package/modules/status-ui/public/style.css +24 -0
  55. package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
  56. package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
  57. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
  58. package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
  59. package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
  60. package/package-lock.json +2 -2
  61. package/package.json +1 -1
  62. package/release-notes.json +45 -0
  63. package/scripts/gds/provision.js +77 -53
  64. package/scripts/gds/session-digest.js +11 -3
  65. package/scripts/gds/session-transcript-build.js +155 -0
  66. package/scripts/gds/session-transcript-upload.js +29 -0
  67. package/scripts/gds/ship-session-upload.js +6 -1
  68. package/scripts/gds/upgrade-commit-identity.js +32 -0
  69. package/scripts/gds/upgrade.js +1 -1
  70. package/scripts/hall-preview/refresh-fixtures.js +2 -0
  71. package/src/bongos/db.js +13 -0
  72. package/src/bongos/role-stats.js +36 -2
  73. package/src/bongos/routes/public.js +13 -2
  74. package/src/bongos/routes.js +3 -0
  75. package/src/branding.js +9 -0
  76. package/src/module-api.js +1 -1
  77. package/tests/achievements_reconcile.mjs +13 -0
  78. package/tests/craft_achievements.mjs +349 -0
  79. package/tests/founding_birth.mjs +200 -0
  80. package/tests/founding_builders.mjs +2 -1
  81. package/tests/founding_mode.mjs +3 -1
  82. package/tests/genesis_home.mjs +71 -2
  83. package/tests/hall_builders_page_pass.mjs +2 -1
  84. package/tests/hub_map_orb_states.mjs +5 -1
  85. package/tests/leaderboard_roles.mjs +272 -0
  86. package/tests/npm_release_release.mjs +37 -2
  87. package/tests/projects_hub.mjs +5 -3
  88. package/tests/projects_hub_dns_ready.mjs +153 -0
  89. package/tests/projects_hub_pre_uat.mjs +9 -5
  90. package/tests/provision_dns_first.mjs +171 -0
  91. package/tests/provision_settings_apply.mjs +6 -1
  92. package/tests/provisioning_settings_env.mjs +49 -0
  93. package/tests/session_transcript.mjs +273 -0
  94. package/tests/ui_design_kit.mjs +1 -1
  95. package/tests/upgrade_commit_identity.mjs +89 -0
  96. package/tests/wizard_demo.mjs +29 -3
@@ -57,7 +57,7 @@ npm publish ./clients/bongos-client # real (publishConfig makes it
57
57
 
58
58
  **Publishing is automatic on merge** as of 2026-08-08: the `publish` workflow rides behind the required `unit` check and waits for the `integration` lane's verdict on the commit it releases (task 1004290), auto-bumps a patch when nobody bumped, builds through the fail-closed no-leak gate, publishes, and tags — see the workflow header (`.github/workflows/publish.yml`) and ADR 0161. It ships inert until the owner arms it: repo variable `PUBLISH_ON_MERGE=1`, a **trusted publisher** registered on npmjs.com for `@bongos/core` naming this repo + `publish.yml`, and the secret `RELEASE_PUSH_TOKEN` (contents:write, for the bump commit). There is deliberately **no** `NPM_TOKEN` — the lane authenticates by OIDC, and the workflow sets neither `NPM_TOKEN` nor `NODE_AUTH_TOKEN` because either one would send npm down the legacy token path and skip the OIDC exchange entirely. A **minor/major bump stays a human in-task edit**. The update subscription ([ADR 0136](../adr/0136-update-channel-subscription-policy.md)) consumes whatever is published, on its next sweep.
59
59
 
60
- **Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub App needs **Actions: write** to start the job. From a terminal the same job runs as `gh workflow run publish.yml -f release_version=<x.y.z>`. The arming order is in the ADR.
60
+ **Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub credential needs **Actions: write** to start the job: the `GITHUB_PUSH_TOKEN` access token (what cloudbongos.com uses), or the GitHub App where one is configured. From a terminal the same job runs as `gh workflow run publish.yml -f release_version=<x.y.z>`. The arming order is in the ADR.
61
61
 
62
62
  The steps below remain as the **manual fallback** (lane disarmed, npm outage, or a deliberate out-of-band release):
63
63
 
@@ -0,0 +1,35 @@
1
+ -- core_269_session_transcripts.sql — the FULL, secret-scrubbed transcript of every
2
+ -- session (ADR 0051, task 1001004).
3
+ --
4
+ -- WHY A SEPARATE TABLE and not a widened session_records.detail / a new jsonb
5
+ -- column on it: session_records is the BFG's hot, searched, paged table
6
+ -- (searchSessions, summarizeSessions, /mine all SELECT from it, and its rows are
7
+ -- KB by design — ADR 0027). A transcript is MB. Putting it on the same row would
8
+ -- make every list query drag (or have to remember to exclude) a fat jsonb, and
9
+ -- would make a TOASTed-column slip on one query a performance incident. One row
10
+ -- per session in each table, joined on session_id, keeps the evaluator's read
11
+ -- path exactly as cheap as it is today. ADR 0051 §1 allowed either shape
12
+ -- ("new transcript jsonb column, or a widened detail"); this is the third
13
+ -- option that satisfies its intent without the cost.
14
+ --
15
+ -- The content is scrubbed ON THE BUILDER'S BOX before upload (modules/security/
16
+ -- secret-scrub.js, the task-1003 scrubber); this table never holds raw text.
17
+ --
18
+ -- builder_id is denormalised from session_records ON PURPOSE: the read route's
19
+ -- ownership check (own transcript vs Archon-only cross-builder) must not depend
20
+ -- on a join succeeding, and the upload's ownership guard needs it at write time.
21
+ --
22
+ -- Retention: keep forever (ADR 0051 §4, Lars 2026-06-13). Storage-cost work is a
23
+ -- deliberate future task, not designed in here.
24
+
25
+ CREATE TABLE IF NOT EXISTS session_transcripts (
26
+ session_id text PRIMARY KEY, -- Claude Code session id, == session_records.session_id
27
+ builder_id bigint NOT NULL REFERENCES builders(id),
28
+ turn_count integer NOT NULL DEFAULT 0,
29
+ byte_size integer NOT NULL DEFAULT 0, -- serialized size of `turns`, for corpus accounting
30
+ truncated boolean NOT NULL DEFAULT false, -- true when the uploader hit its own size ceiling
31
+ turns jsonb NOT NULL DEFAULT '[]'::jsonb, -- per-turn scrubbed content (prompts, assistant text, tool calls + results)
32
+ uploaded_at timestamptz NOT NULL DEFAULT now()
33
+ );
34
+
35
+ CREATE INDEX IF NOT EXISTS idx_session_transcripts_builder ON session_transcripts (builder_id);
@@ -0,0 +1,128 @@
1
+ -- core_270_craft_achievements.sql — achievements for artist and ideator moments
2
+ -- (task 1004438 / WA6.RP08, goal 1000095, criterion wa6-roles-read-true-in-public).
3
+ --
4
+ -- WHY. Every achievement was a claim or ship event, and the one art badge
5
+ -- ("art-pipeline-shipper") fires when CODE touches art/ — it rewards engineering,
6
+ -- not art. An artist who rewrites pages in the studio and an ideator whose thoughts
7
+ -- become work earned nothing on the shelf. These seven are each keyed to a LEDGER
8
+ -- fact the economy already books (credit_log), so they are awarded once, by the
9
+ -- same reconcile every other achievement uses (modules/economy/achievements.js
10
+ -- STATE_EVALUATORS), and backfilled below for work that already happened.
11
+ --
12
+ -- credit_bonus is 0 on all seven: they recognise work the ledger has already
13
+ -- paid for, and a backfill that minted bonus credits for past work is a money
14
+ -- decision this migration does not make.
15
+ --
16
+ -- A core migration, not an economy_* one: `achievements` and
17
+ -- `builder_achievements` are core tables (modules/economy/CLAUDE.md, Tables).
18
+
19
+ INSERT INTO achievements (id, name, description, icon, credit_bonus, sort_order) VALUES
20
+ ('first-thought-ignited', 'First Thought Ignited',
21
+ 'A thought of yours became shipped work.',
22
+ '💡', 0, 90),
23
+ ('first-full-idea', 'First Full Idea',
24
+ 'A Full Idea of yours passed the completeness bar.',
25
+ '📜', 0, 100),
26
+ ('first-board-ratification', 'Ratified by the Board',
27
+ 'The board ratified an idea of yours.',
28
+ '🏛️', 0, 110),
29
+ ('thought-built-ten', 'A Thought That Built Ten',
30
+ 'One thought of yours led to ten or more shipped tasks.',
31
+ '🌟', 0, 120),
32
+ ('first-page-approved', 'First Page Approved',
33
+ 'A page you rewrote in Tweak Mode was approved and shipped.',
34
+ '🎨', 0, 130),
35
+ ('ten-pages-approved', 'Ten Pages Approved',
36
+ 'Ten pages you rewrote in Tweak Mode were approved and shipped.',
37
+ '🖼️', 0, 140),
38
+ ('whole-surface-tweaked', 'A Whole Surface',
39
+ 'Every page of one surface was rewritten by you and approved.',
40
+ '🗺️', 0, 150)
41
+ ON CONFLICT (id) DO UPDATE
42
+ SET name = EXCLUDED.name,
43
+ description = EXCLUDED.description,
44
+ icon = EXCLUDED.icon,
45
+ credit_bonus = EXCLUDED.credit_bonus,
46
+ sort_order = EXCLUDED.sort_order;
47
+
48
+ -- The misleading one, renamed rather than retired: builders who earned it keep it,
49
+ -- and it now says what it is — engineering work on the art pipeline. The palette
50
+ -- icon moves to First Page Approved, the artist's own badge.
51
+ UPDATE achievements
52
+ SET name = 'Art Pipeline Engineer',
53
+ description = 'Ship engineering work on the art/ pipeline (code, not art; an artist earns First Page Approved).',
54
+ icon = '🛠️'
55
+ WHERE id = 'art-pipeline-shipper';
56
+
57
+ -- ---- Backfill: everyone who already earned one, from the ledger ---------------
58
+ -- Each unlock is stamped with the FIRST qualifying credit's time and task, so the
59
+ -- shelf dates the moment, not this migration. ON CONFLICT DO NOTHING: re-running
60
+ -- converges, and an unlock the live reconcile already wrote is left alone.
61
+ -- "A Whole Surface" needs the page inventory, which is a file, not a table, so
62
+ -- SQL cannot award it. It is backfilled by the lifecycle's periodic sweep, which
63
+ -- runs economy's backfillCraftAchievements ONCE at every server start (so the
64
+ -- deploy that applies this migration awards it within the first sweep), and by
65
+ -- the reconcile on the artist's next approved page; by hand,
66
+ -- `node scripts/gds/diagnose-achievements.js --apply`.
67
+
68
+ -- the idea-stream and artist-lane prefixes, spelled as LIKE literals with the
69
+ -- underscore escaped (modules/economy/credits.js IDEA_STREAM_REASON_PREFIXES,
70
+ -- ARTIST_PAGE_REASON_PREFIX; tests/craft_achievements.mjs holds them together)
71
+ INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
72
+ SELECT DISTINCT ON (builder_id) builder_id, 'first-thought-ignited', recorded_at, task_id
73
+ FROM credit_log
74
+ WHERE reason LIKE 'idea.credit.task\_ship:%' AND delta > 0
75
+ ORDER BY builder_id, recorded_at, id
76
+ ON CONFLICT DO NOTHING;
77
+
78
+ INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
79
+ SELECT DISTINCT ON (builder_id) builder_id, 'first-full-idea', recorded_at, task_id
80
+ FROM credit_log
81
+ WHERE reason LIKE 'idea.credit.full\_pass:%' AND delta > 0
82
+ ORDER BY builder_id, recorded_at, id
83
+ ON CONFLICT DO NOTHING;
84
+
85
+ INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
86
+ SELECT DISTINCT ON (builder_id) builder_id, 'first-board-ratification', recorded_at, task_id
87
+ FROM credit_log
88
+ WHERE reason LIKE 'idea.credit.governance\_pass:%' AND delta > 0
89
+ ORDER BY builder_id, recorded_at, id
90
+ ON CONFLICT DO NOTHING;
91
+
92
+ -- ten distinct shipped tasks paid under ONE idea (reason idea.credit.task_ship:<idea>:<task>);
93
+ -- stamped at the tenth task's payment
94
+ INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
95
+ SELECT DISTINCT ON (builder_id) builder_id, 'thought-built-ten', recorded_at, task_id
96
+ FROM (
97
+ SELECT builder_id, recorded_at, task_id, id,
98
+ DENSE_RANK() OVER (PARTITION BY builder_id, split_part(reason, ':', 2)
99
+ ORDER BY first_paid) AS nth
100
+ FROM (
101
+ SELECT builder_id, recorded_at, task_id, id, reason,
102
+ MIN(recorded_at) OVER (PARTITION BY builder_id, split_part(reason, ':', 2), split_part(reason, ':', 3)) AS first_paid
103
+ FROM credit_log
104
+ WHERE reason LIKE 'idea.credit.task\_ship:%' AND delta > 0
105
+ ) per_task
106
+ ) ranked
107
+ WHERE nth >= 10
108
+ ORDER BY builder_id, recorded_at, id
109
+ ON CONFLICT DO NOTHING;
110
+
111
+ INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
112
+ SELECT DISTINCT ON (builder_id) builder_id, 'first-page-approved', recorded_at, task_id
113
+ FROM credit_log
114
+ WHERE reason LIKE 'tweak.page\_approved:%' AND delta > 0
115
+ ORDER BY builder_id, recorded_at, id
116
+ ON CONFLICT DO NOTHING;
117
+
118
+ -- the tenth approved page (one row per round, keyed on its task)
119
+ INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
120
+ SELECT builder_id, 'ten-pages-approved', recorded_at, task_id
121
+ FROM (
122
+ SELECT builder_id, recorded_at, task_id,
123
+ ROW_NUMBER() OVER (PARTITION BY builder_id ORDER BY recorded_at, id) AS nth
124
+ FROM credit_log
125
+ WHERE reason LIKE 'tweak.page\_approved:%' AND delta > 0
126
+ ) pages
127
+ WHERE nth = 10
128
+ ON CONFLICT DO NOTHING;
@@ -41,7 +41,7 @@ const streak = require('./streak');
41
41
  // getLockedAchievementsProgress/renderProgressText), moved from src/bongos/db.js,
42
42
  // reads the shared pool through the published doorway — a module never requires a
43
43
  // core internal. The evaluators above take a tx `client` from the caller.
44
- const { pool } = require('../../src/module-api');
44
+ const { pool, resolveOptional } = require('../../src/module-api');
45
45
 
46
46
  async function alreadyUnlocked(client, builderId, achievementId) {
47
47
  const r = await client.query(
@@ -367,6 +367,88 @@ async function eligible_streak7(client, builderId) {
367
367
  return s.longest_streak >= 7;
368
368
  }
369
369
 
370
+ // ---- the craft achievements (task 1004438 / WA6.RP08) --------------------------
371
+ // An artist and an ideator earn on the LEDGER, not on a claim: each predicate
372
+ // below is a fact credit_log already holds (the reason prefixes are economy's own,
373
+ // the same strings the writers book and migrations/core_270 backfills from). None
374
+ // needs a ship of the builder's own; reconcileAfterCraftCredit (below) runs them
375
+ // the moment such a credit is booked.
376
+ const IDEA_SHIP_LIKE = 'idea.credit.task\\_ship:%';
377
+ const IDEA_FULL_LIKE = 'idea.credit.full\\_pass:%';
378
+ const IDEA_BOARD_LIKE = 'idea.credit.governance\\_pass:%';
379
+ const PAGE_LIKE = 'tweak.page\\_approved:%';
380
+ const THOUGHT_TASKS = 10;
381
+ const PAGES_MANY = 10;
382
+
383
+ //
384
+ // ONE READ FOR ALL SEVEN. craftFacts asks the builder's craft ledger once —
385
+ // served by idx_credit_log_builder (builder_id, recorded_at), so it is that
386
+ // person's rows, never the whole ledger — and every craft rule is a pure test of
387
+ // its answer. The full reconcile and the craft-only one read the SAME facts
388
+ // through the SAME rules (CRAFT_RULES), so the two paths cannot disagree about
389
+ // who earned what. Within one reconcile the facts are read once and shared.
390
+ const CRAFT_FACTS_SQL = `
391
+ SELECT bool_or(reason LIKE $2) AS ignited,
392
+ bool_or(reason LIKE $3) AS full_idea,
393
+ bool_or(reason LIKE $4) AS ratified,
394
+ COUNT(*) FILTER (WHERE reason LIKE $5)::int AS pages,
395
+ (SELECT COALESCE(MAX(n), 0)::int FROM (
396
+ SELECT COUNT(DISTINCT split_part(reason, ':', 3)) AS n
397
+ FROM credit_log
398
+ WHERE builder_id = $1 AND reason LIKE $2 AND delta > 0
399
+ GROUP BY split_part(reason, ':', 2)) per_thought) AS best_thought_tasks
400
+ FROM credit_log
401
+ WHERE builder_id = $1 AND delta > 0`;
402
+
403
+ async function craftFacts(client, builderId) {
404
+ const r = await client.query(CRAFT_FACTS_SQL, [builderId, IDEA_SHIP_LIKE, IDEA_FULL_LIKE, IDEA_BOARD_LIKE, PAGE_LIKE]);
405
+ const row = r.rows[0] || {};
406
+ return {
407
+ ignited: row.ignited === true,
408
+ fullIdea: row.full_idea === true,
409
+ ratified: row.ratified === true,
410
+ pages: Number(row.pages) || 0,
411
+ bestThoughtTasks: Number(row.best_thought_tasks) || 0,
412
+ };
413
+ }
414
+
415
+ // Every page of one surface rewritten by this artist and approved: the studio's
416
+ // own "N of M tweaked" per surface (copy-desk's `copy-desk.artistStats` port, the
417
+ // one the profile header reads), so the inventory has one reader. Asked ONLY of a
418
+ // builder with an approved page — anyone else structurally cannot earn it, and
419
+ // the port is not free. A port that is absent or fails is "could not tell", never
420
+ // a badge.
421
+ async function wholeSurfaceTweaked(builderId, facts) {
422
+ if (!(facts.pages > 0)) return false;
423
+ const port = resolveOptional('copy-desk.artistStats');
424
+ if (typeof port !== 'function') return false;
425
+ const stats = await port(builderId);
426
+ const surfaces = (stats && Array.isArray(stats.surfaces)) ? stats.surfaces : [];
427
+ return surfaces.some((s) => Number(s.total) > 0 && Number(s.tweaked) >= Number(s.total));
428
+ }
429
+
430
+ // id → (facts, builderId) → boolean | Promise<boolean>, in sort order.
431
+ const CRAFT_RULES = [
432
+ ['first-thought-ignited', (f) => f.ignited],
433
+ ['first-full-idea', (f) => f.fullIdea],
434
+ ['first-board-ratification', (f) => f.ratified],
435
+ // one thought, ten shipped tasks: stream 1's reason is idea.credit.task_ship:<idea>:<task>
436
+ ['thought-built-ten', (f) => f.bestThoughtTasks >= THOUGHT_TASKS],
437
+ ['first-page-approved', (f) => f.pages > 0],
438
+ ['ten-pages-approved', (f) => f.pages >= PAGES_MANY],
439
+ ['whole-surface-tweaked', (f, builderId) => wholeSurfaceTweaked(builderId, f)],
440
+ ];
441
+ const CRAFT_IDS = CRAFT_RULES.map(([id]) => id);
442
+
443
+ // The STATE_EVALUATORS form of a rule: read the facts once per reconcile (the
444
+ // shared `ctx`), then test. diagnose-achievements.js reaches the rules this way.
445
+ function craftEvaluator(rule) {
446
+ return async (client, builderId, ctx = {}) => {
447
+ if (!ctx.craftFacts) ctx.craftFacts = craftFacts(client, builderId);
448
+ return rule(await ctx.craftFacts, builderId);
449
+ };
450
+ }
451
+
370
452
  // Ordered by achievements.sort_order so banners/backfill output read naturally.
371
453
  const STATE_EVALUATORS = [
372
454
  { id: 'first-ship', fn: eligible_firstShip },
@@ -378,6 +460,7 @@ const STATE_EVALUATORS = [
378
460
  { id: 'subtractor', fn: eligible_subtractor },
379
461
  { id: 'gds-shipper', fn: eligible_gdsShipper },
380
462
  { id: 'breaking-the-100c-bar', fn: eligible_breaking100c },
463
+ ...CRAFT_RULES.map(([id, rule]) => ({ id, fn: craftEvaluator(rule) })),
381
464
  ];
382
465
 
383
466
  // Unlock every achievement the builder currently qualifies for but hasn't yet.
@@ -387,9 +470,10 @@ const STATE_EVALUATORS = [
387
470
  async function reconcileBuilderAchievements(client, builderId, opts = {}) {
388
471
  const { taskId = null } = opts;
389
472
  const results = [];
473
+ const ctx = {}; // shared by the craft rules: their facts are read once per reconcile
390
474
  for (const ev of STATE_EVALUATORS) {
391
475
  if (await alreadyUnlocked(client, builderId, ev.id)) continue;
392
- if (await ev.fn(client, builderId)) {
476
+ if (await ev.fn(client, builderId, ctx)) {
393
477
  const unlocked = await unlockAchievement(client, builderId, ev.id, taskId);
394
478
  if (unlocked) results.push(unlocked);
395
479
  }
@@ -397,6 +481,83 @@ async function reconcileBuilderAchievements(client, builderId, opts = {}) {
397
481
  return results;
398
482
  }
399
483
 
484
+ // reconcileCraftAchievements — ONLY the seven craft rules, in two reads: what the
485
+ // builder already holds of them, then (only if anything is left) their craft
486
+ // facts. The payout hook below runs this, not the full sixteen-evaluator
487
+ // reconcile: an idea credit or an approved page cannot change a streak, a claim
488
+ // count or a shipped diff, so asking about those on every payment is waste.
489
+ async function reconcileCraftAchievements(client, builderId, opts = {}) {
490
+ const { taskId = null } = opts;
491
+ const held = await client.query(
492
+ 'SELECT achievement_id FROM builder_achievements WHERE builder_id = $1 AND achievement_id = ANY($2::text[])',
493
+ [builderId, CRAFT_IDS]
494
+ );
495
+ const have = new Set((held.rows || []).map((r) => r.achievement_id));
496
+ const open = CRAFT_RULES.filter(([id]) => !have.has(id));
497
+ if (!open.length) return [];
498
+ const facts = await craftFacts(client, builderId);
499
+ const results = [];
500
+ for (const [id, rule] of open) {
501
+ if (await rule(facts, builderId)) {
502
+ const unlocked = await unlockAchievement(client, builderId, id, taskId);
503
+ if (unlocked) results.push(unlocked);
504
+ }
505
+ }
506
+ return results;
507
+ }
508
+
509
+ // backfillCraftAchievements — the part of the backfill a migration cannot do.
510
+ // migrations/core_270 backfills six badges from the ledger in SQL, but "A Whole
511
+ // Surface" needs the page inventory, which is a file the database never sees. So
512
+ // the lifecycle's periodic sweep (publish-reconciler.js) runs this ONCE per
513
+ // process start: every builder the craft ledger has paid, through the craft-only
514
+ // reconcile, in id order, bounded. Idempotent, so a restart that runs it again
515
+ // unlocks nothing twice. Returns { checked, unlocked }.
516
+ async function backfillCraftAchievements({ client = null, limit = 500 } = {}) {
517
+ const q = client || pool;
518
+ const { rows } = await q.query(
519
+ `SELECT DISTINCT builder_id FROM credit_log
520
+ WHERE delta > 0 AND (reason LIKE $1 OR reason LIKE $2 OR reason LIKE $3 OR reason LIKE $4)
521
+ ORDER BY builder_id
522
+ LIMIT $5`,
523
+ [IDEA_SHIP_LIKE, IDEA_FULL_LIKE, IDEA_BOARD_LIKE, PAGE_LIKE, limit]
524
+ );
525
+ let unlocked = 0;
526
+ for (const r of rows) {
527
+ const got = await reconcileAfterCraftCredit(q, r.builder_id);
528
+ unlocked += got.length;
529
+ }
530
+ return { checked: rows.length, unlocked };
531
+ }
532
+
533
+ // reconcileAfterCraftCredit — the craft writers' hook (task 1004438). An idea
534
+ // stream or an approved page pays a builder inside SOMEONE ELSE'S ship (or an
535
+ // idea route's own read), so the ship-time reconcile — which runs for the shipper
536
+ // — never reaches the ideator or the artist. credits.js calls this for each payee
537
+ // it just booked. It can never fail the payment: on a transaction client it runs
538
+ // under a SAVEPOINT and rolls back to it on any error; on a pool (an idea route
539
+ // pays through the pool, one idempotent statement) SAVEPOINT itself fails, nothing
540
+ // is open to poison, and the reconcile runs plainly inside a try. Returns the
541
+ // just-unlocked list, or [] on any failure.
542
+ async function reconcileAfterCraftCredit(client, builderId, opts = {}) {
543
+ if (builderId == null || !client || typeof client.query !== 'function') return [];
544
+ let saved = false;
545
+ try {
546
+ await client.query('SAVEPOINT ach_craft_credit');
547
+ saved = true;
548
+ } catch (_) { /* not in a transaction: a pool */ }
549
+ try {
550
+ const out = await reconcileCraftAchievements(client, builderId, opts);
551
+ if (saved) await client.query('RELEASE SAVEPOINT ach_craft_credit');
552
+ return out;
553
+ } catch (_) {
554
+ if (saved) {
555
+ try { await client.query('ROLLBACK TO SAVEPOINT ach_craft_credit'); } catch (_e) { /* the caller's tx decides */ }
556
+ }
557
+ return [];
558
+ }
559
+ }
560
+
400
561
  // -------------------------------------------------------------------------
401
562
  // Achievements — minimal getters added by migration 006_pms_v2.
402
563
  // Full evaluator + unlock writes live in src/bongos/achievements.js (task K1).
@@ -436,6 +597,10 @@ async function getLeaderboardWithAchievements(limit = 10, perBuilderCap = 5) {
436
597
  b.avatar_url,
437
598
  b.total_credits,
438
599
  b.rank,
600
+ -- task 1004437: karma too, as credits.leaderboard carries it, so the
601
+ -- hall's roster can read this one public list (with its roles) and
602
+ -- keep its karma column and lens. The profile already publishes it.
603
+ b.karma,
439
604
  COALESCE((
440
605
  SELECT array_agg(t.achievement_id ORDER BY t.sort_order)
441
606
  FROM (
@@ -504,7 +669,14 @@ async function getLockedAchievementsProgress(builderId) {
504
669
  AND a.outcome = 'shipped' AND a.released_at IS NOT NULL
505
670
  AND b2.outcome = 'shipped' AND b2.released_at IS NOT NULL
506
671
  AND tstzrange(a.claimed_at, a.released_at)
507
- && tstzrange(b2.claimed_at, b2.released_at)) AS overlap_pairs
672
+ && tstzrange(b2.claimed_at, b2.released_at)) AS overlap_pairs,
673
+ -- task 1004438: the craft counters, from the ledger
674
+ (SELECT COUNT(*) FROM credit_log
675
+ WHERE builder_id = $1 AND reason LIKE 'tweak.page\\_approved:%' AND delta > 0) AS pages_approved,
676
+ (SELECT COALESCE(MAX(n), 0) FROM (
677
+ SELECT COUNT(DISTINCT split_part(reason, ':', 3)) AS n FROM credit_log
678
+ WHERE builder_id = $1 AND reason LIKE 'idea.credit.task\\_ship:%' AND delta > 0
679
+ GROUP BY split_part(reason, ':', 2)) t) AS best_thought_tasks
508
680
  )
509
681
  SELECT a.id AS achievement_id,
510
682
  a.name,
@@ -516,7 +688,9 @@ async function getLockedAchievementsProgress(builderId) {
516
688
  c.art_ships,
517
689
  c.pms_ships,
518
690
  c.total_credits,
519
- c.overlap_pairs
691
+ c.overlap_pairs,
692
+ c.pages_approved,
693
+ c.best_thought_tasks
520
694
  FROM achievements a
521
695
  CROSS JOIN counters c
522
696
  WHERE NOT EXISTS (
@@ -594,6 +768,25 @@ function renderProgressText(row) {
594
768
  return artShips === 0
595
769
  ? 'needs one shipped task touching art/'
596
770
  : 'keep building';
771
+ // task 1004438: the craft achievements
772
+ case 'first-thought-ignited':
773
+ return 'needs one thought of yours to become shipped work';
774
+ case 'first-full-idea':
775
+ return 'needs one Full Idea to pass the completeness bar';
776
+ case 'first-board-ratification':
777
+ return 'needs the board to ratify one idea of yours';
778
+ case 'thought-built-ten': {
779
+ const best = Math.max(0, Math.min(Number(row.best_thought_tasks || 0), 10));
780
+ return `${best} of 10 shipped tasks from one thought`;
781
+ }
782
+ case 'first-page-approved':
783
+ return 'needs one page you rewrote to be approved';
784
+ case 'ten-pages-approved': {
785
+ const pages = Math.max(0, Math.min(Number(row.pages_approved || 0), 10));
786
+ return `${pages} of 10 pages approved`;
787
+ }
788
+ case 'whole-surface-tweaked':
789
+ return 'needs every page of one surface rewritten by you and approved';
597
790
  case 'gds-shipper':
598
791
  return pmsShips === 0
599
792
  ? 'needs one shipped task touching src/bongos/ or scripts/gds/'
@@ -612,6 +805,8 @@ module.exports = {
612
805
  evaluateAfterShip,
613
806
  evaluateAfterCreditAward,
614
807
  reconcileBuilderAchievements,
808
+ reconcileAfterCraftCredit,
809
+ backfillCraftAchievements,
615
810
  STATE_EVALUATORS,
616
811
  alreadyUnlocked,
617
812
  unlockAchievement,
@@ -429,9 +429,21 @@ async function bookIdeaSplit(client, { payees, reason, taskId = null, describe }
429
429
  if (rowCount > 0) credited += p.delta;
430
430
  booked.push({ ...p, booked: rowCount > 0 });
431
431
  }
432
+ // task 1004438: an idea credit is an ideator's moment (First Thought Ignited,
433
+ // First Full Idea, Ratified by the Board, A Thought That Built Ten), and it is
434
+ // booked inside someone else's ship, so the shipper's reconcile never reaches
435
+ // the payee. Every idea stream books through here; each payee just paid is
436
+ // reconciled, and the hook can never fail the payment.
437
+ for (const p of booked) {
438
+ if (p.booked) await craftAchievements().reconcileAfterCraftCredit(client, p.builderId, { taskId });
439
+ }
432
440
  return { credited, payees: booked };
433
441
  }
434
442
 
443
+ // Lazy: achievements.js sits beside this file and requires nothing of it, but a
444
+ // load-time require would make the two files' load order matter for no reason.
445
+ function craftAchievements() { return require('./achievements'); }
446
+
435
447
  // Stream 2 (ADR 0172 / IC2): a Full Idea with a passing completeness score pays
436
448
  // its author IDEA_FULL_PASS_CREDIT, ONCE. Transaction-participant like the rest
437
449
  // of the reward port — the caller passes its `client` (a pool works too, since
@@ -1243,6 +1255,10 @@ async function awardArtistPageCredit(client, { taskId, config = rewardConfig() }
1243
1255
  [t.artist_id, taskId, priced.effective, reason,
1244
1256
  `Approved page tweak, task #${taskId}: ${priced.description} to its artist (artist lane, ADR 0341 D10)`]
1245
1257
  );
1258
+ // task 1004438: an approved page is the artist's moment (First Page Approved,
1259
+ // Ten Pages Approved, A Whole Surface). Reconciled here as well as at ship, so
1260
+ // a re-sweep that pays the round outside the ship still reaches the shelf.
1261
+ if (rowCount > 0) await craftAchievements().reconcileAfterCraftCredit(client, t.artist_id, { taskId });
1246
1262
  return rowCount > 0
1247
1263
  ? { credited: priced.effective, reason: 'paid', artistId: t.artist_id, delta: priced.effective }
1248
1264
  : { credited: 0, reason: 'already_paid', artistId: t.artist_id, delta: priced.effective };
@@ -55,6 +55,9 @@ module.exports = {
55
55
  // fires on EVERY ship path (incl. confirmTask / grader_bypass, which never
56
56
  // evaluated achievements before, so most builders only ever got first-claim).
57
57
  reconcileBuilderAchievements: (...a) => achievements.reconcileBuilderAchievements(...a),
58
+ // task 1004438: the craft badges a migration cannot backfill ("A Whole Surface"
59
+ // needs the page inventory, a file) — the lifecycle's sweep runs this once per start.
60
+ backfillCraftAchievements: (...a) => achievements.backfillCraftAchievements(...a),
58
61
  // pure helpers + constants (display / tests)
59
62
  applyKindMultiplier: (...a) => credits.applyKindMultiplier(...a),
60
63
  computeSessionTokenReward: (...a) => credits.computeSessionTokenReward(...a),
@@ -1174,7 +1174,7 @@
1174
1174
  getJSON(`${API}/achievements`),
1175
1175
  getJSON(`${API}/me/recent-ships?limit=all`),
1176
1176
  getJSON(`${API}/versions`),
1177
- getJSON(`${API}/leaderboard`),
1177
+ getJSON(`${API}/public/leaderboard`), // task 1004437: the list with each builder's roles
1178
1178
  getJSON(`${API}/public/progress`), // task 1003464: "<version> is N percent shipped"
1179
1179
  ]);
1180
1180
 
@@ -79,7 +79,7 @@
79
79
  var now = g.stages.find(function (s) { return s.state === 'now'; });
80
80
  var sub = g.deciding
81
81
  ? 'Deciding the plan' + (g.day ? ' · day ' + g.day : '')
82
- : 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.day ? ' · day ' + g.day : '');
82
+ : 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.demo ? ' · demo' : g.day ? ' · day ' + g.day : '');
83
83
  return '<div class="genesis__head"><h2 id="genesis-h" class="scroll__h">Genesis</h2>'
84
84
  + '<p class="genesis__sub">' + esc(sub) + '</p></div>' + stepperHtml(g);
85
85
  }
@@ -157,7 +157,10 @@
157
157
  function stagesHtml(g) {
158
158
  return '<ol class="genesis__stages">' + g.stages.map(function (s) {
159
159
  var wakes = (s.wakes || []).map(function (id) { return ROOM[id] || id; });
160
- var due = s.state === 'now' && s.due ? ' · due ' + new Date(s.due).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
160
+ // A demo's stage is minutes long (task 1004506): say how long, and due at a time.
161
+ var due = s.minutes
162
+ ? ' · ' + s.minutes + ' min' + (s.state === 'now' && s.due ? ', due ' + new Date(s.due).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : '')
163
+ : s.state === 'now' && s.due ? ' · due ' + new Date(s.due).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
161
164
  return '<li class="genesis__stage is-' + s.state + '">'
162
165
  + '<span class="genesis__stage-n">0' + s.number + '</span>'
163
166
  + '<span class="genesis__stage-t"><b>' + esc(s.label) + '</b>'
@@ -225,12 +228,23 @@
225
228
  return document.documentElement.getAttribute('data-founding') === 'on';
226
229
  }
227
230
 
231
+ // A demo's genesis is expedited (task 1004506): its plan was settled from the demo's
232
+ // own picks before anyone saw it, so it never shows the decide form, only this line.
233
+ function demoHtml(g) {
234
+ var d = g.demo;
235
+ if (!d) return '';
236
+ var total = g.stages.reduce(function (n, s) { return n + (s.minutes || 0); }, 0);
237
+ return '<p class="scroll__lede">A demo’s genesis is short: about ' + esc(String(total)) + ' minutes, then the rest of your time is for building. '
238
+ + 'The plan is already set from your demo: Monarchy / BDFL, ' + esc(String(d.people)) + (d.people === 1 ? ' person, ' : ' people, ')
239
+ + esc(String(d.hours_each)) + (d.hours_each === 1 ? ' hour' : ' hours') + ' each. Finish each stage’s task, then approve it to move on.</p>';
240
+ }
241
+
228
242
  function render(host, state) {
229
243
  var g = state.genesis;
230
244
  if (!g) { host.hidden = true; return; }
231
245
  host.innerHTML = headHtml(g) + (g.deciding
232
246
  ? decideHtml(g, state.gov)
233
- : lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
247
+ : demoHtml(g) + lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
234
248
  host.hidden = false;
235
249
  }
236
250
 
@@ -578,6 +578,7 @@
578
578
  <span>Member since ${fmtDate(builder.created_at)}</span>
579
579
  <span title="${escapeHtml(karmaTooltip)}">${karmaTrend.replace(' in 30d', ' karma in 30d')}</span>
580
580
  ${rollPlace ? `<span title="Your place on the leaderboard (top ${rollPlace.total}), ranked by ${curLabel()}.">#${rollPlace.place} on the leaderboard</span>` : ''}
581
+ ${rolePlaceText(rollPlace) ? `<span title="Your place among the ${rollPlace.role.total} builders shown as ${ROLE_AS[rollPlace.role.craft]}, ranked by the ${curLabel()} that role earned.">${rolePlaceText(rollPlace)}</span>` : ''}
581
582
  </div>
582
583
  <div class="profile__disciplines">
583
584
  <span class="profile__disc-label">Preferred work:</span>
@@ -611,7 +612,7 @@
611
612
  setText('stat-karma-sub', karmaTrendText);
612
613
  const statRank = document.getElementById('stat-rank');
613
614
  if (statRank) statRank.innerHTML = builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : '—';
614
- setText('stat-rank-sub', rollPlace ? `#${rollPlace.place} on the leaderboard` : '');
615
+ setText('stat-rank-sub', rollPlace ? [`#${rollPlace.place} on the leaderboard`, rolePlaceText(rollPlace)].filter(Boolean).join(' · ') : '');
615
616
  const statsSection = document.getElementById('home-stats');
616
617
  if (statsSection && !ctx.newcomerGated) statsSection.hidden = false;
617
618
 
@@ -1274,7 +1275,28 @@
1274
1275
  (a, b) => (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0),
1275
1276
  );
1276
1277
  const idx = ranked.findIndex((r) => r.github_login === login);
1277
- return idx === -1 ? null : { place: idx + 1, total: ranked.length };
1278
+ if (idx === -1) return null;
1279
+ // task 1004437: the place inside the builder's MAIN role too, the roster's
1280
+ // role toggle read the same way (credits earned in that craft, among the
1281
+ // builders it is shown for), so an artist's card can say where they stand
1282
+ // as an artist and not only beside every engineer's session pay.
1283
+ const me = ranked[idx];
1284
+ const main = me.roles && me.roles.main;
1285
+ let role = null;
1286
+ if (main && me.roles.credits_by_craft) {
1287
+ const credit = (r) => Number(r.roles && r.roles.credits_by_craft && r.roles.credits_by_craft[main]) || 0;
1288
+ const peers = ranked.filter((r) => r.roles && Array.isArray(r.roles.shown) && r.roles.shown.includes(main));
1289
+ const order = peers.map((r, i) => ({ r, i })).sort((a, b) => (credit(b.r) - credit(a.r)) || (a.i - b.i));
1290
+ const at = order.findIndex((x) => x.r === me);
1291
+ if (at !== -1) role = { craft: main, place: at + 1, total: peers.length };
1292
+ }
1293
+ return { place: idx + 1, total: ranked.length, role };
1294
+ }
1295
+ const ROLE_AS = { engineer: 'an engineer', artist: 'an artist', ideator: 'an ideator' };
1296
+ // "#2 as an artist" — only when it says something the overall place does not.
1297
+ function rolePlaceText(p) {
1298
+ if (!p || !p.role || !ROLE_AS[p.role.craft] || p.role.place === p.place) return '';
1299
+ return `#${p.role.place} as ${ROLE_AS[p.role.craft]}`;
1278
1300
  }
1279
1301
 
1280
1302
  // task 1003459 — the Claim button on each Home "Next up" row, through the
@@ -0,0 +1,13 @@
1
+ {
2
+ "_": "The genesis home of a DEMO, born into genesis (task 1004506): 3 people, 4 hours each. The plan was settled at birth from the demo's picks, so there is no decide form: one line names the plan, and each stage reads in minutes (scope in progress, due at a time). /me says founding at scope (kit fixture me-founding-demo.json) and /founding/genesis answers founding-genesis-demo.json, built from the real server shaper. A real project's genesis is index-genesis.states.json and index-genesis-decide.states.json; a hall that is not founding is index.states.json.",
3
+ "page": "/builders/",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/", "fixtures": { "me": "me-founding-demo", "founding-genesis": "founding-genesis-demo" } },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard", "/api/(gds|bongos)/removal-proposals", "/api/(gds|bongos)/blockers"],
8
+ "_ignored": "removal-proposals and blockers are fixtures the kit stub does not carry; index.states.json's home render reports the same two 404s.",
9
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
10
+ "states": {
11
+ "demo": { "auth": true, "actions": [["wait", 800]], "expect": { "visible": ["#genesis-home", "#founding-band"] } }
12
+ }
13
+ }
@@ -22,6 +22,27 @@
22
22
  .dir-who__t { display: flex; flex-direction: column; gap: 1px; min-width: 132px; }
23
23
  .dir-name { font-weight: 500; color: var(--ink); overflow-wrap: anywhere; }
24
24
  .dir-login { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-faint); }
25
+ /* ---- the roles (task 1004437, the approved mock's leaderboard) -------------
26
+ The toggle: pill chips on the glass, the pressed one filled in ink (the mock's
27
+ .chip). The roles column: one pill per role shown, the main one a step
28
+ stronger (the mock's .role / .role--lead, the profile header's chips). */
29
+ .roster-roles { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 var(--sp-sm); }
30
+ .roster-role {
31
+ min-height: 34px; padding: 0 14px; border: 0; border-radius: var(--radius-pill);
32
+ background: var(--glass); color: var(--ink-soft);
33
+ font: inherit; font-size: 13px; cursor: pointer;
34
+ }
35
+ .roster-role:hover { color: var(--ink); background: var(--glass-strong); }
36
+ .roster-role[aria-pressed="true"] { background: var(--ink); color: var(--bg-card); }
37
+ .lb-roles { display: inline-flex; flex-wrap: wrap; gap: 4px; }
38
+ .lb-role {
39
+ display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px;
40
+ border-radius: var(--radius-pill); background: var(--glass);
41
+ font-size: 12.5px; color: var(--ink-soft); white-space: nowrap;
42
+ }
43
+ .lb-role--lead { background: var(--glass-strong); color: var(--ink); }
44
+ .lb-roles--none, .lb-none { color: var(--ink-faint); }
45
+
25
46
  /* The roster's place cell carries two facts: the place, and under it the
26
47
  builder's number in the same mono/faint voice the login uses (task
27
48
  1002837). Block display so the id never wraps up beside the place. */