@bongos/core 1.20.77 → 1.20.78

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 (52) hide show
  1. package/.bongos-core.json +79 -54
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
  8. package/docs/adr/0360-bongos-follows-every-candidate-every-other-project-follows-releases.md +66 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +70 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +22 -14
  13. package/docs/copy-registry.json +125 -41
  14. package/docs/module-api-changelog.md +2 -0
  15. package/docs/page-inventory.json +5 -1
  16. package/docs/page-readings.json +150 -137
  17. package/docs/recipes/upgrading-the-core.md +11 -2
  18. package/modules/copy-desk/artist-stats.js +15 -1
  19. package/modules/copy-desk/module.json +1 -1
  20. package/modules/copy-desk/page-status.js +41 -0
  21. package/modules/copy-desk/routes/copy-desk-artist.js +44 -0
  22. package/modules/copy-desk/tests/copy_no_cms.mjs +11 -2
  23. package/modules/hall-ui/public/deploy.states.json +1 -1
  24. package/modules/hall-ui/public/profile-crafts.js +231 -0
  25. package/modules/hall-ui/public/profile.css +60 -0
  26. package/modules/hall-ui/public/profile.html +31 -12
  27. package/modules/hall-ui/public/profile.js +180 -75
  28. package/modules/hall-ui/public/profile.states.json +34 -0
  29. package/modules/hall-ui/public/sky-draw.js +66 -39
  30. package/modules/hall-ui/public/sky-interaction.js +1 -1
  31. package/modules/hall-ui/public/sky-panel.js +1 -1
  32. package/modules/hall-ui/public/thinking.css +52 -24
  33. package/modules/hall-ui/records/profile-roles.md +18 -0
  34. package/modules/npm-release/public/work.js +72 -0
  35. package/modules/npm-release/work.js +67 -1
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/release-notes.json +18 -0
  39. package/scripts/gds/update-channel.js +68 -1
  40. package/scripts/gds/update-sweep.js +100 -4
  41. package/scripts/migrate.sh +6 -2
  42. package/src/module-api.js +1 -1
  43. package/tests/hall_sky.mjs +77 -0
  44. package/tests/helpers.mjs +32 -7
  45. package/tests/migrate_applied_check.mjs +94 -0
  46. package/tests/npm_release_page.mjs +67 -0
  47. package/tests/npm_release_work.mjs +82 -0
  48. package/tests/profile_activity.mjs +23 -15
  49. package/tests/profile_role_headline.mjs +4 -4
  50. package/tests/profile_role_tabs.mjs +163 -0
  51. package/tests/update_channel.mjs +80 -1
  52. package/tests/update_subscription_engine.mjs +99 -0
@@ -0,0 +1,44 @@
1
+ // modules/copy-desk/routes/copy-desk-artist.js — one artist's approved pages,
2
+ // for the profile's Artist tab (task 1004435 / WA6.RP05, goal 1000095).
3
+ //
4
+ // GET /copy-desk/artists/:id/pages?limit=&offset= → { rows, total, page }
5
+ //
6
+ // Each row is a round of theirs that shipped: the page, its surface, the day it
7
+ // landed, the changed-line count and the first changed line before and after.
8
+ // Apart from routes/copy-desk.js because that file sits at the 1,500-line
9
+ // ratchet (the artist-stats.js precedent).
10
+ //
11
+ // SIGNED IN. The owner's decision (a), 2026-09-30: "no one should be able to
12
+ // open up the hood of data without being signed in." The counts stay public on
13
+ // the profile route; the pages and their words are a builder's read.
14
+
15
+ 'use strict';
16
+
17
+ const express = require('express');
18
+ const api = require('../../../src/module-api');
19
+ const { readArtistPages } = require('../artist-stats');
20
+
21
+ const log = api.logger('copy-desk');
22
+
23
+ module.exports = function buildCopyDeskArtistRouter() {
24
+ const router = express.Router();
25
+
26
+ // rank: builder — any signed-in builder may read any artist's approved pages,
27
+ // as the profile's other Activity tabs are; a stranger gets the sign-in line.
28
+ router.get('/copy-desk/artists/:id/pages', api.requireBuilder, async (req, res) => {
29
+ try {
30
+ const id = String(req.params.id || '');
31
+ if (!/^[1-9]\d{0,18}$/.test(id)) return res.fail('invalid_builder_id', 400);
32
+ const { limit, offset } = api.parsePagination(req, { defaultLimit: 10, maxLimit: 50 });
33
+ const out = await readArtistPages(id, { limit, offset });
34
+ if (!out) return res.fail('page_reads_unavailable', 503);
35
+ res.set('Cache-Control', 'no-store');
36
+ res.json({ rows: out.rows, total: out.total, inventory_known: out.inventory_known, page: api.pageMeta(limit, offset) });
37
+ } catch (err) {
38
+ log.error({ err }, 'GET /copy-desk/artists/:id/pages failed');
39
+ if (!res.headersSent) res.fail('artist_pages_failed', 500);
40
+ }
41
+ });
42
+
43
+ return router;
44
+ };
@@ -67,12 +67,21 @@ const stripComments = (src) => src
67
67
  // inventory and readings). They are held to the same two rules as the rest.
68
68
  // TW09 (task 1004320) added docx.js, the Word round trip over Buffers.
69
69
  // TW12 (task 1004323) added page-decisions.js, the Approval queue's route helpers.
70
- const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'page-decisions.js', 'routes/copy-desk.js'];
70
+ // Task 1004435 added routes/copy-desk-artist.js (one artist's approved pages, a
71
+ // read) and artist-stats.js's readArtistPages under it.
72
+ const ROUTE_FILES = fs.readdirSync(path.join(MODULE_DIR, 'routes')).filter((f) => f.endsWith('.js')).sort().map((f) => `routes/${f}`);
73
+ const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'page-decisions.js', 'artist-stats.js', ...ROUTE_FILES];
71
74
 
72
75
  console.log('copy-desk: NO LIVE CMS');
73
76
 
77
+ await test('the route files are exactly the two the module declares', () => {
78
+ assert.deepEqual(ROUTE_FILES, ['routes/copy-desk-artist.js', 'routes/copy-desk.js'],
79
+ 'a new route file is a new place a write could hide: add it here, and to the write-surface scan below');
80
+ });
81
+
74
82
  await test('the write surface is exactly the verbs the module defines', () => {
75
- const src = stripComments(read('routes/copy-desk.js'));
83
+ // EVERY route file, so a write cannot hide in a second router.
84
+ const src = ROUTE_FILES.map((f) => stripComments(read(f))).join('\n');
76
85
  const found = [...src.matchAll(/router\.(post|put|patch|delete)\(\s*'([^']+)'/g)]
77
86
  .map((m) => `${m[1].toUpperCase()} ${m[2]}`)
78
87
  .sort();
@@ -7,7 +7,7 @@
7
7
  "ignoreRequests": ["/api/(gds|bongos)/"],
8
8
  "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b403\\b", "\\b404\\b", "Unauthorized", "Forbidden", "Not Found", "load failed"],
9
9
  "states": {
10
- "work": { "auth": true, "actions": [["wait", 1200]], "expect": { "visible": ["#dp-body"] } },
10
+ "work": { "auth": true, "actions": [["wait", 1200]], "expect": { "visible": ["#dp-body", "#nr-work", "#nr-work [data-upgrades]", "#nr-versions"] } },
11
11
  "out": { "auth": false, "actions": [["wait", 600]] }
12
12
  },
13
13
  "reducedMotion": { "auth": true, "actions": [["wait", 1200]] }
@@ -0,0 +1,231 @@
1
+ // modules/hall-ui/public/profile-crafts.js — what the profile's Ideator and
2
+ // Artist tabs draw (task 1004435 / WA6.RP05, goal 1000095, criterion
3
+ // wa6-roles-read-true-in-public).
4
+ //
5
+ // WHY. The Ideator tab listed tasks whose discipline happened to be 'ideator',
6
+ // as if an ideator's record were engineering work; the Artist tab listed only
7
+ // artist-labelled tasks the person had shipped themselves. Neither is that
8
+ // craft's record. The owner-approved mock (docs/design/mocks/profile-roles/,
9
+ // the pixel contract) replaces them:
10
+ //
11
+ // IDEATOR the compact public trail of the room (ideator-surface spec decision
12
+ // 19): every thought in the sky's five states, laid out by the day it
13
+ // was filed. SIZE (diameter: credits across every idea stream) and
14
+ // BRIGHTNESS (how much of the work it caused is still alive) are
15
+ // separate channels, and every drawn thing is also a row in the ledger
16
+ // under it (the sky's accessibility rule). It folds in the old "Ideas
17
+ // filed, and what became of them" trail rather than keeping two.
18
+ // ARTIST the pages they rewrote in Tweak Mode that were approved: the page,
19
+ // its surface, the day, the changed-line count and a small
20
+ // before/after (the round's first changed line, beside the approval
21
+ // renders when the round has them).
22
+ //
23
+ // MARKUP ONLY, like profile-roles.js and profile-charts.js: no document, no
24
+ // fetch. profile.js fetches and wires the hover; a test requires this file and
25
+ // renders the real markup against the real fixtures. The five states' words
26
+ // come from OTBIdeaObjects (idea-objects.js), which modules/ideas/CLAUDE.md
27
+ // makes the one vocabulary every surface uses.
28
+ //
29
+ // The marks are circles and lines, never a path built from data: the hall's
30
+ // chart rule (tests/hall_audit.mjs §6b) leaves series geometry to the kit, and a
31
+ // scatter of thoughts by day is not a series.
32
+
33
+ (function () {
34
+ 'use strict';
35
+
36
+ function esc(s) {
37
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
38
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
39
+ }[c]));
40
+ }
41
+ function objects() {
42
+ return (typeof window !== 'undefined' && window.OTBIdeaObjects) || null;
43
+ }
44
+ function stateWord(st) {
45
+ const o = objects();
46
+ return o ? o.objectFor(st).label : String(st || '');
47
+ }
48
+ const ORDER = ['star', 'protostar', 'satellite', 'remnant', 'cold-body'];
49
+ // The sky's vertical bands, one per state, so the trail reads as the room does:
50
+ // the burning thoughts high, the declined ones low (the mock's bands).
51
+ const BAND = { star: 0.34, remnant: 0.4, protostar: 0.6, satellite: 0.7, 'cold-body': 0.82 };
52
+
53
+ function stateOf(row) {
54
+ const s = row && row.state && row.state.state;
55
+ return ORDER.includes(s) ? s : 'protostar';
56
+ }
57
+ // A thought's diameter from its compressed size (the sky's own scale, 0..1);
58
+ // a thought with no size draws its state's fixed glyph instead.
59
+ function sized(row) {
60
+ const sc = row && row.size && Number(row.size.scale);
61
+ return Number.isFinite(sc) && sc > 0 ? sc : null;
62
+ }
63
+ function lit(row) {
64
+ const b = row && row.state && row.state.brightness;
65
+ return Number.isFinite(Number(b)) && b !== null ? Number(b) : 1;
66
+ }
67
+
68
+ // One mark per state. `i` ties it to its ledger row; -1 is a legend glyph.
69
+ function markSvg(st, cx, cy, { scale = null, brightness = 1, i = -1, title = '' } = {}) {
70
+ const data = i >= 0 ? ` data-i="${i}" tabindex="-1"` : '';
71
+ const t = title ? `<title>${esc(title)}</title>` : '';
72
+ const r = Math.max(3.2, (7 + 20 * (scale || 0)) / 2);
73
+ const f = (n) => Number(n).toFixed(1);
74
+ if (st === 'star') {
75
+ const op = (0.3 + 0.7 * Math.max(0, Math.min(1, brightness))).toFixed(2);
76
+ return `<circle class="pt-m pt-star" cx="${f(cx)}" cy="${f(cy)}" r="${f(scale == null ? 5 : r)}" fill-opacity="${op}"${data}>${t}</circle>`;
77
+ }
78
+ if (st === 'remnant') return `<circle class="pt-m pt-remnant" cx="${f(cx)}" cy="${f(cy)}" r="${f(scale == null ? 5.5 : r * 1.1)}"${data}>${t}</circle>`;
79
+ if (st === 'satellite') {
80
+ return `<g class="pt-m"${data}>${t}<circle class="pt-ring" cx="${f(cx)}" cy="${f(cy)}" r="6.5"/><circle class="pt-soft" cx="${f(cx)}" cy="${f(cy)}" r="2.6"/></g>`;
81
+ }
82
+ if (st === 'cold-body') return `<circle class="pt-m pt-cold" cx="${f(cx)}" cy="${f(cy)}" r="3"${data}>${t}</circle>`;
83
+ return `<g class="pt-m"${data}>${t}<circle class="pt-ring" cx="${f(cx)}" cy="${f(cy)}" r="7"/><circle class="pt-soft" cx="${f(cx)}" cy="${f(cy)}" r="3.2"/></g>`;
84
+ }
85
+ function glyphHtml(st) {
86
+ return `<svg class="pt-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false">${markSvg(st, 8, 8)}</svg>`;
87
+ }
88
+
89
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
90
+ function dayOf(v) {
91
+ const t = Date.parse(v);
92
+ return Number.isFinite(t) ? t : null;
93
+ }
94
+ // A stable, small vertical offset per thought, so two filed the same week do
95
+ // not sit on one another, and the picture does not shuffle on every load.
96
+ function jitter(id) {
97
+ let h = 0;
98
+ for (const c of String(id)) h = (h * 31 + c.charCodeAt(0)) % 9973;
99
+ return ((h % 11) - 5) / 60;
100
+ }
101
+
102
+ // trailSvgHtml(rows, { width, height }) — the trail itself. `width` is the
103
+ // figure's real width, so one user unit is one pixel and a thought's diameter
104
+ // is the same on every screen (the mock's rule).
105
+ function trailSvgHtml(rows, { width = 1000, height = 230 } = {}) {
106
+ const list = (rows || []).map((r, i) => ({ r, i, t: dayOf(r.captured_at) })).filter((x) => x.t != null);
107
+ const W = Math.max(280, Math.round(width));
108
+ const H = height;
109
+ const L = 20;
110
+ const R = 20;
111
+ if (!list.length) return '';
112
+ let t0 = Math.min(...list.map((x) => x.t));
113
+ let t1 = Math.max(...list.map((x) => x.t));
114
+ const DAY = 864e5;
115
+ // A window of at least a month, so a single week of thoughts is not smeared
116
+ // edge to edge as if it were a year.
117
+ if (t1 - t0 < 30 * DAY) { const mid = (t0 + t1) / 2; t0 = mid - 15 * DAY; t1 = mid + 15 * DAY; }
118
+ const x = (t) => L + (W - L - R) * (t - t0) / (t1 - t0);
119
+ const y = (st, id) => 14 + (H - 54) * (BAND[st] + jitter(id));
120
+ let grid = '';
121
+ const d0 = new Date(t0);
122
+ for (let m = new Date(Date.UTC(d0.getUTCFullYear(), d0.getUTCMonth() + 1, 1)); m.getTime() <= t1; m = new Date(Date.UTC(m.getUTCFullYear(), m.getUTCMonth() + 1, 1))) {
123
+ const gx = x(m.getTime()).toFixed(1);
124
+ grid += `<line class="pt-yr" x1="${gx}" x2="${gx}" y1="10" y2="${H - 26}"/>`
125
+ + `<text class="pt-mo" x="${(Number(gx) + 6).toFixed(1)}" y="${H - 10}">${MONTHS[m.getUTCMonth()]}</text>`;
126
+ }
127
+ const pos = new Map(list.map((p) => [String(p.r.id), [x(p.t), y(stateOf(p.r), p.r.id)]]));
128
+ let tethers = '';
129
+ for (const p of list) {
130
+ const host = p.r.merged_into_idea_id != null ? pos.get(String(p.r.merged_into_idea_id)) : null;
131
+ if (stateOf(p.r) === 'satellite' && host) {
132
+ const [ax, ay] = pos.get(String(p.r.id));
133
+ tethers += `<line class="pt-tether" x1="${ax.toFixed(1)}" y1="${ay.toFixed(1)}" x2="${host[0].toFixed(1)}" y2="${host[1].toFixed(1)}"/>`;
134
+ }
135
+ }
136
+ const marks = list.map((p) => {
137
+ const [cx, cy] = pos.get(String(p.r.id));
138
+ const st = stateOf(p.r);
139
+ return markSvg(st, cx, cy, { scale: sized(p.r), brightness: lit(p.r), i: p.i, title: `${p.r.title || `Idea ${p.r.id}`}, ${stateWord(st)}` });
140
+ }).join('');
141
+ const label = `${list.length} ${list.length === 1 ? 'thought' : 'thoughts'} filed, by the day each was filed. Each is also a row in the table below.`;
142
+ return `<svg class="pt-svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${esc(label)}">${grid}${tethers}${marks}</svg>`;
143
+ }
144
+
145
+ function legendHtml() {
146
+ return `<ul class="pt-legend">${ORDER.map((st) => `<li>${glyphHtml(st)}${esc(stateWord(st))}</li>`).join('')}</ul>`
147
+ + '<p class="pt-channels">Size is how many credits a thought has earned, across every way ideas are paid. '
148
+ + 'Brightness is how much of the work it caused is still alive. They are separate: a burned-out thought keeps its size.</p>';
149
+ }
150
+
151
+ // The size cell: the true figure, never the drawing's compressed scale; a ~
152
+ // when part of it is a historical estimate; "no size" for a thought that never
153
+ // earned (a waiting, folded-in or declined one).
154
+ function sizeCell(row) {
155
+ const s = row && row.size;
156
+ if (!s || typeof s.credits !== 'number' || s.credits <= 0) return '<span class="pt-dim">no size</span>';
157
+ const est = s.historical === true && Number(s.estimated) > 0;
158
+ return `${esc(String(s.credits))} cr${est ? `<span class="pt-est" title="${esc(`${s.estimated} of it estimated from work that shipped before ideas were paid`)}"> ~</span>` : ''}`;
159
+ }
160
+ function aliveCell(row) {
161
+ const st = stateOf(row);
162
+ const s = row && row.state;
163
+ if (st === 'remnant') return '0%';
164
+ if (st !== 'star' || !s || s.hasWork !== true) return '';
165
+ if (s.attributable !== true || s.brightness == null) return '<span class="pt-dim">unknown</span>';
166
+ return `${Math.round(Number(s.brightness) * 100)}%`;
167
+ }
168
+
169
+ // One ledger row per drawn thought. `outcomeHtml` is profile.js's existing
170
+ // "what became of it" (the old trail's cell, folded in whole: the task it
171
+ // became, the descent under it, the triage note).
172
+ function thoughtRowHtml(row, i, { outcomeHtml, ideaHref }) {
173
+ const st = stateOf(row);
174
+ const title = esc(row.title || `Idea ${row.id}`);
175
+ return `<tr id="pt-row-${i}" data-i="${i}" tabindex="0">`
176
+ + `<td><a href="${ideaHref(row.id)}">${title}</a></td>`
177
+ + `<td><span class="pt-sw">${glyphHtml(st)}${esc(stateWord(st))}</span></td>`
178
+ + `<td class="ledger__r">${sizeCell(row)}</td>`
179
+ + `<td class="ledger__r">${aliveCell(row)}</td>`
180
+ + `<td>${outcomeHtml(row)}</td>`
181
+ + '</tr>';
182
+ }
183
+
184
+ function thoughtsLede(first, own) {
185
+ return own
186
+ ? `Every thought ${first} has filed, where it went, and how much of what it caused is still alive.`
187
+ : `The thoughts ${first} has filed. Their own sky is theirs; this is the public trail of it.`;
188
+ }
189
+
190
+ // ---- the artist ---------------------------------------------------------
191
+
192
+ function shortDate(v) {
193
+ const t = dayOf(v);
194
+ if (t == null) return '';
195
+ const d = new Date(t);
196
+ return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
197
+ }
198
+
199
+ // One approved page. `visuals` is the round's approval renders by slot, when
200
+ // the page fetched them; `thumb` is OTBVisual.shipVisualThumbHtml, which only
201
+ // ever draws a server-minted visual URL.
202
+ function pageRowHtml(p, { visuals = null, thumb = null } = {}) {
203
+ const lines = Number(p.line_count) || 0;
204
+ const meta = [p.surface_label, p.approved_at ? `approved ${shortDate(p.approved_at)}` : null].filter(Boolean).join(' · ');
205
+ const side = (which, word, text) => {
206
+ const v = visuals && visuals[`${which}-desktop-light`];
207
+ const img = v && thumb ? thumb({ visual_url: v.visual_url, visual_alt: v.visual_alt }) : '';
208
+ const body = which === 'before'
209
+ ? `<span class="pg-ba__was">${esc(text)}</span>`
210
+ : `<span class="pg-ba__now">${esc(text)}</span>`;
211
+ return `<div class="pg-ba__side"><span class="pg-ba__lbl">${word}</span>${img ? `<span class="pg-ba__img">${img}</span>` : ''}${text ? body : ''}</div>`;
212
+ };
213
+ const ba = p.first || (visuals && (visuals['before-desktop-light'] || visuals['after-desktop-light']))
214
+ ? `<div class="pg-ba">${side('before', 'before', p.first ? p.first.before : '')}${side('after', 'after', p.first ? p.first.after : '')}</div>`
215
+ : '';
216
+ return '<li class="pg">'
217
+ + `<div><div class="pg__t">${esc(p.page_title || p.page_id)}</div><div class="pg__meta">${esc(meta)}</div></div>`
218
+ + `<div class="pg__n">${lines} ${lines === 1 ? 'line' : 'lines'} changed</div>`
219
+ + ba
220
+ + '</li>';
221
+ }
222
+
223
+ function pagesLede(first, total) {
224
+ if (!total) return `${first} has no approved pages yet. Pages they rewrite in Tweak Mode will list here once they are approved.`;
225
+ return `Pages ${first} rewrote in Tweak Mode that were approved and shipped.`;
226
+ }
227
+
228
+ const api = { trailSvgHtml, legendHtml, thoughtRowHtml, thoughtsLede, pageRowHtml, pagesLede, glyphHtml, stateOf };
229
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
230
+ if (typeof window !== 'undefined') window.OTBProfileCrafts = api;
231
+ })();
@@ -193,3 +193,63 @@
193
193
  .pchart-range__btn + .pchart-range__btn { border-left: var(--line) solid var(--glass-edge); }
194
194
  .pchart-range__btn:hover { color: var(--ink); background: var(--glass); }
195
195
  .pchart-range__btn.is-active { background: var(--accent-tint); color: var(--accent-ink); }
196
+
197
+ /* ---- the ideator's trail (task 1004435) ------------------------------------
198
+ The room's public trail, on this page's own ground: the sky's five marks by
199
+ the day each thought was filed (the approved mock's .trail and .m-*). Size is
200
+ a mark's diameter and brightness a star's opacity; the words are the sky's.
201
+ A mark and its ledger row light together (.is-hot), the sky's rule. */
202
+ #activity-ideator-panel {
203
+ --pt-soft: color-mix(in srgb, var(--ink) 52%, transparent);
204
+ --pt-faint: color-mix(in srgb, var(--ink) 22%, transparent);
205
+ --pt-cold: color-mix(in srgb, var(--ink) 8%, var(--bg-card));
206
+ }
207
+ .pt-trail { border-radius: var(--radius-sm); background: var(--bg-frame); overflow: hidden; }
208
+ .pt-trail[hidden] { display: none; }
209
+ .pt-svg { display: block; width: 100%; height: auto; }
210
+ .pt-yr { stroke: var(--rule-soft); stroke-width: 1; }
211
+ .pt-mo { fill: var(--ink-faint); font-size: 11px; font-family: var(--font-mono); }
212
+ .pt-m { transition: transform .2s ease-out; transform-box: fill-box; transform-origin: center; cursor: pointer; }
213
+ .pt-m.is-hot { transform: scale(1.35); }
214
+ .pt-star { fill: var(--accent); }
215
+ .pt-soft { fill: var(--pt-soft); }
216
+ .pt-ring { fill: none; stroke: var(--pt-faint); stroke-width: 1; }
217
+ .pt-tether { stroke: var(--pt-faint); stroke-width: 1; }
218
+ .pt-remnant { fill: transparent; stroke: var(--pt-soft); stroke-width: 1.2; }
219
+ .pt-cold { fill: var(--pt-cold); stroke: var(--pt-faint); stroke-width: 1; }
220
+ .pt-legend { display: flex; flex-wrap: wrap; gap: 8px 18px; margin: 12px 0 0; padding: 0; list-style: none; font-size: 12.5px; color: var(--ink-soft); }
221
+ .pt-legend li { display: flex; align-items: center; gap: 7px; }
222
+ .pt-glyph { display: block; width: 16px; height: 16px; flex: none; }
223
+ .pt-channels { margin: 6px 0 var(--sp-sm); font-size: 12.5px; color: var(--ink-faint); max-width: var(--measure); }
224
+ .pt-sw { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
225
+ .pt-sw .pt-glyph { width: 14px; height: 14px; }
226
+ .pt-dim, .pt-est { color: var(--ink-faint); }
227
+ .pt-foot { margin: var(--sp-sm) 0 0; font-size: 12.5px; color: var(--ink-faint); }
228
+ .pt-foot[hidden] { display: none; }
229
+ #activity-ideas .ledger tr.is-hot td { background: var(--glass); }
230
+ #activity-ideas .ledger td:first-child, #activity-ideas .ledger td:last-child { min-width: 13rem; }
231
+ #activity-ideas .ledger .ledger__r { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
232
+
233
+ /* ---- the artist's approved pages (task 1004435) ----------------------------
234
+ Each page a row: its name and where it lives, the changed-line count, and a
235
+ small before/after (the first changed line, beside the approval renders when
236
+ the round has them). The mock's .pg and .ba, on the hall's tokens. */
237
+ .pgs { display: grid; margin: 0; padding: 0; list-style: none; }
238
+ .pg { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 18px; padding: 16px 0; border-top: var(--line) solid var(--rule-soft); }
239
+ .pg:first-child { border-top: 0; padding-top: 0; }
240
+ .pg__t { font-size: 16px; font-weight: 400; }
241
+ .pg__meta { font-size: 12.5px; color: var(--ink-faint); }
242
+ .pg__n { text-align: right; font-size: 13px; color: var(--ink-soft); font-variant-numeric: tabular-nums; white-space: nowrap; }
243
+ .pg-ba { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; }
244
+ .pg-ba__side { padding: 10px 12px; border-radius: var(--radius-sm); background: var(--bg-frame); font-size: 13.5px; }
245
+ .pg-ba__lbl { display: block; margin-bottom: 3px; font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-faint); }
246
+ .pg-ba__side:has(.pg-ba__img) { display: grid; grid-template-columns: 112px minmax(0, 1fr); column-gap: 12px; align-items: start; }
247
+ .pg-ba__side:has(.pg-ba__img) .pg-ba__lbl { grid-column: 1 / -1; }
248
+ .pg-ba__img, .pg-ba__img a { display: block; }
249
+ .pg-ba__img .ship-visual-thumb { width: 112px; height: 70px; }
250
+ .pg-ba__was, .pg-ba__now { justify-self: start; align-self: start; }
251
+ .pg-ba__was { color: var(--ink-faint); text-decoration: line-through; text-decoration-color: var(--rule); }
252
+ .pg-ba__now { color: var(--ink); background: var(--accent-tint); padding: 0 2px; border-radius: 3px; -webkit-box-decoration-break: clone; box-decoration-break: clone; }
253
+ @media (max-width: 640px) {
254
+ .pg-ba { grid-template-columns: minmax(0, 1fr); }
255
+ }
@@ -140,22 +140,37 @@
140
140
  <div id="activity-body">
141
141
  <div class="profile__placeholder" id="activity-placeholder">…</div>
142
142
 
143
- <div id="activity-tasks-panel" hidden>
144
- <h3 class="profile-act__h">Works completed</h3>
145
- <div id="activity-tasks"></div>
143
+ <!-- One tab per role the person has (task 1004435): their main role
144
+ and every other role that has earned credits, as the header's
145
+ chips are. Each tab is its craft's own record. -->
146
+ <div id="activity-engineer-panel" hidden>
147
+ <div id="activity-tasks-panel">
148
+ <h3 class="profile-act__h">Works completed</h3>
149
+ <div id="activity-tasks"></div>
150
+ </div>
151
+
152
+ <div id="activity-goals-panel">
153
+ <h3 class="profile-act__h">Goals created</h3>
154
+ <div id="activity-goals"></div>
155
+ </div>
146
156
  </div>
147
157
 
148
- <div id="activity-goals-panel" hidden>
149
- <h3 class="profile-act__h">Goals created</h3>
150
- <div id="activity-goals"></div>
158
+ <!-- The ideator's trail: the room's public trail (the sky's five
159
+ states, size and brightness), every drawn thought also a row.
160
+ It folds in the old "Ideas filed" list. -->
161
+ <div id="activity-ideator-panel" hidden>
162
+ <p class="scroll__lede" id="trail-lede"></p>
163
+ <div class="pt-trail" id="trail-figure"></div>
164
+ <div id="trail-legend"></div>
165
+ <div id="activity-ideas"></div>
166
+ <p class="pt-foot" id="trail-foot" hidden></p>
151
167
  </div>
152
168
 
153
- <!-- The idea trail. Revealed only in the craft views where filing an
154
- idea is the work being shown (all + ideator) — see profile.js. -->
155
- <div id="activity-ideas-panel" hidden>
156
- <h3 class="profile-act__h">Ideas filed, and what became of them</h3>
157
- <p class="scroll__lede">Every idea this builder captured — promoted, discarded, merged, or still open.</p>
158
- <div id="activity-ideas"></div>
169
+ <!-- The artist's approved pages, each with a small before/after. -->
170
+ <div id="activity-artist-panel" hidden>
171
+ <p class="scroll__lede" id="pages-lede"></p>
172
+ <ul class="pgs" id="activity-pages"></ul>
173
+ <div id="pages-more"></div>
159
174
  </div>
160
175
  </div>
161
176
  </section>
@@ -218,6 +233,10 @@
218
233
  <!-- What the header and By the numbers say, per craft (task 1004434). Pure, and
219
234
  loaded BEFORE profile.js, which draws what it returns. -->
220
235
  <script src="/builders/profile-roles.js?v=2026-10-01-roles"></script>
236
+ <!-- The Ideator and Artist tabs (task 1004435): the five states' words, then
237
+ what the tabs draw. Both loaded BEFORE profile.js. -->
238
+ <script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
239
+ <script src="/builders/profile-crafts.js?v=2026-10-01-crafts"></script>
221
240
  <script src="/builders/profile.js?v=2026-10-01-roles"></script>
222
241
  </body>
223
242
  </html>