@bongos/core 1.19.487 → 1.19.489

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.
@@ -35,103 +35,147 @@
35
35
  </div>
36
36
 
37
37
  <main id="hall-main">
38
- <!-- Standing — avatar, name, rank badge, member-since. Filled by profile.js. -->
39
- <section class="scroll" id="standing-scroll" aria-labelledby="standing-h">
40
- <h2 id="standing-h" class="sr-only">Standing</h2>
41
- <div class="profile profile--ident" id="profile-head">
42
- <div class="profile__placeholder">…</div>
43
- </div>
44
- </section>
38
+ <!-- Three screens behind a section rail (task 1003489): the settings
39
+ idiom, OTBKit.makeSectionRail in switch mode. profile.js wires the
40
+ rail and the #key deep links; profile.css shows one group at a
41
+ time (.profile-groups[data-active]) and a section's own [hidden]
42
+ still wins, so the hub-only reveals below are untouched. -->
43
+ <div class="profile-layout" id="profile-layout">
44
+ <nav class="section-rail" id="profile-rail" aria-label="Profile sections"></nav>
45
45
 
46
- <!-- By the numbers — credits, karma, works shipped. -->
47
- <section class="scroll" id="numbers-scroll" aria-labelledby="numbers-h">
48
- <h2 id="numbers-h" class="scroll__h">By the numbers</h2>
49
- <div class="stat-row" id="numbers-body">
50
- <div class="stat-tile"><span class="stat-tile__label">Loading…</span></div>
51
- </div>
52
- </section>
53
-
54
- <!-- Cross-project record — hub-only (ADR 0171). profile.js reveals this
55
- only when the profile response carries a cross_project key; on a
56
- vanilla instance the key is absent and the section never renders. -->
57
- <section class="scroll" id="xp-scroll" aria-labelledby="xp-h" hidden>
58
- <h2 id="xp-h" class="scroll__h">Cross-project record</h2>
59
- <div id="xp-body"></div>
60
- </section>
61
-
62
- <!-- Achievements — the builder's earned shelf. -->
63
- <section class="scroll scroll--bare" id="ach-scroll" aria-labelledby="ach-h">
64
- <h2 id="ach-h" class="scroll__h">Achievements</h2>
65
- <div class="ach-grid" id="ach-grid">
66
- <div class="profile__placeholder">…</div>
67
- </div>
68
- </section>
69
-
70
- <!-- Recent works — the last few shipped tasks. -->
71
- <section class="scroll" id="works-scroll" aria-labelledby="works-h">
72
- <h2 id="works-h" class="scroll__h">Recent works</h2>
73
- <p class="scroll__lede" id="works-lede">The most recent works this builder has shipped.</p>
74
- <table class="ledger" id="works-table">
75
- <thead>
76
- <tr>
77
- <th class="ledger__num">#</th>
78
- <th>Work</th>
79
- <th class="ledger__drachmae">Reward</th>
80
- <th class="ledger__date">Shipped</th>
81
- </tr>
82
- </thead>
83
- <tbody>
84
- <tr><td colspan="4">…</td></tr>
85
- </tbody>
86
- </table>
87
- </section>
88
-
89
- <!-- Activity, by craft — task 1003281, goal 1000072 / criterion
90
- legibility-activity-by-role. A view per role over the same record:
91
- the tasks this builder completed, the goals they created, and (in
92
- the ideator view) every idea they filed with what became of it.
93
- All three ledgers page server-side; profile.js fills them. -->
94
- <section class="scroll" id="activity-scroll" aria-labelledby="activity-h">
95
- <h2 id="activity-h" class="scroll__h">Activity, by craft</h2>
96
- <p class="scroll__lede" id="activity-lede">What this builder has done, seen one craft at a time.</p>
97
- <div id="activity-tabs"></div>
98
- <div id="activity-body">
99
- <div class="profile__placeholder" id="activity-placeholder">…</div>
100
-
101
- <div id="activity-tasks-panel" hidden>
102
- <h3 class="profile-act__h">Works completed</h3>
103
- <div id="activity-tasks"></div>
104
- </div>
105
-
106
- <div id="activity-goals-panel" hidden>
107
- <h3 class="profile-act__h">Goals created</h3>
108
- <div id="activity-goals"></div>
109
- </div>
110
-
111
- <!-- The idea trail. Revealed only in the craft views where filing an
112
- idea is the work being shown (all + ideator) — see profile.js. -->
113
- <div id="activity-ideas-panel" hidden>
114
- <h3 class="profile-act__h">Ideas filed, and what became of them</h3>
115
- <p class="scroll__lede">Every idea this builder captured — promoted, discarded, merged, or still open.</p>
116
- <div id="activity-ideas"></div>
117
- </div>
46
+ <div class="profile-groups" id="profile-groups" data-active="overview">
47
+
48
+ <!-- ============ overview: the portfolio, one screen ============ -->
49
+
50
+ <!-- Standing — avatar, name, rank badge, member-since. Filled by profile.js. -->
51
+ <section class="scroll" id="standing-scroll" aria-labelledby="standing-h" data-group="overview">
52
+ <h2 id="standing-h" class="sr-only">Standing</h2>
53
+ <div class="profile profile--ident" id="profile-head">
54
+ <div class="profile__placeholder">…</div>
55
+ </div>
56
+ </section>
57
+
58
+ <!-- By the numbers — credits, karma, works shipped. -->
59
+ <section class="scroll" id="numbers-scroll" aria-labelledby="numbers-h" data-group="overview">
60
+ <h2 id="numbers-h" class="scroll__h">By the numbers</h2>
61
+ <div class="stat-row" id="numbers-body">
62
+ <div class="stat-tile"><span class="stat-tile__label">Loading…</span></div>
63
+ </div>
64
+ </section>
65
+
66
+ <!-- Over time — the figures above, seen as a record (task 1003489).
67
+ profile-charts.js fills the grid: works shipped and drachmae
68
+ earned as cumulative step-lines, the craft mix as bars, and on
69
+ the viewer's own profile their pace by week. Every chart has a
70
+ table twin; signed out, the grid is one line saying why. -->
71
+ <section class="scroll" id="charts-scroll" aria-labelledby="charts-h" data-group="overview">
72
+ <h2 id="charts-h" class="scroll__h">Over time</h2>
73
+ <!-- The lookback control (task 1003489, owner request) — one filter
74
+ row above the grid, scoping every chart (dataviz "date range
75
+ first"). profile-charts.js fills it and re-draws on change. -->
76
+ <div id="charts-controls"></div>
77
+ <div class="pcharts" id="charts-grid">
78
+ <p class="pchart__note">…</p>
79
+ </div>
80
+ </section>
81
+
82
+ <!-- Achievements — the builder's earned shelf. -->
83
+ <section class="scroll scroll--bare" id="ach-scroll" aria-labelledby="ach-h" data-group="overview">
84
+ <h2 id="ach-h" class="scroll__h">Achievements</h2>
85
+ <div class="ach-grid" id="ach-grid">
86
+ <div class="profile__placeholder">…</div>
87
+ </div>
88
+ </section>
89
+
90
+ <!-- Cross-project record — hub-only (ADR 0171). profile.js reveals this
91
+ only when the profile response carries a cross_project key; on a
92
+ vanilla instance the key is absent and the section never renders. -->
93
+ <section class="scroll" id="xp-scroll" aria-labelledby="xp-h" data-group="overview" hidden>
94
+ <h2 id="xp-h" class="scroll__h">Cross-project record</h2>
95
+ <div id="xp-body"></div>
96
+ </section>
97
+
98
+ <!-- ============ works: the full shipped-works ledger ============ -->
99
+
100
+ <!-- Works — the full, server-paged ledger of everything this builder
101
+ shipped (the owner's confirmed key; the old Overview "recent
102
+ works" strip folded in here). A signed-in viewer gets the paged
103
+ record; signed out, the page's public recent_ships stand in with
104
+ a sign-in line. Activity (next key) is the SAME works seen one
105
+ craft at a time, with goals and the idea trail. -->
106
+ <section class="scroll" id="works-scroll" aria-labelledby="works-h" data-group="works">
107
+ <h2 id="works-h" class="scroll__h">Works</h2>
108
+ <p class="scroll__lede" id="works-lede">Everything this builder has shipped. Seen one craft at a time, with goals and ideas, under <a href="#activity">activity</a>.</p>
109
+ <table class="ledger" id="works-table">
110
+ <thead>
111
+ <tr>
112
+ <th class="ledger__num">#</th>
113
+ <th>Work</th>
114
+ <th class="ledger__drachmae">Reward</th>
115
+ <th class="ledger__date">Shipped</th>
116
+ </tr>
117
+ </thead>
118
+ <tbody>
119
+ <tr><td colspan="4">…</td></tr>
120
+ </tbody>
121
+ </table>
122
+ <div id="works-pager"></div>
123
+ </section>
124
+
125
+ <!-- ============ activity: the record, by craft ============ -->
126
+
127
+ <!-- Activity, by craft — task 1003281, goal 1000072 / criterion
128
+ legibility-activity-by-role. A view per role over the same record:
129
+ the tasks this builder completed, the goals they created, and (in
130
+ the ideator view) every idea they filed with what became of it.
131
+ All three ledgers page server-side; profile.js fills them. -->
132
+ <section class="scroll" id="activity-scroll" aria-labelledby="activity-h" data-group="activity">
133
+ <h2 id="activity-h" class="scroll__h">Activity, by craft</h2>
134
+ <p class="scroll__lede" id="activity-lede">What this builder has done, seen one craft at a time.</p>
135
+ <div id="activity-tabs"></div>
136
+ <div id="activity-body">
137
+ <div class="profile__placeholder" id="activity-placeholder">…</div>
138
+
139
+ <div id="activity-tasks-panel" hidden>
140
+ <h3 class="profile-act__h">Works completed</h3>
141
+ <div id="activity-tasks"></div>
142
+ </div>
143
+
144
+ <div id="activity-goals-panel" hidden>
145
+ <h3 class="profile-act__h">Goals created</h3>
146
+ <div id="activity-goals"></div>
147
+ </div>
148
+
149
+ <!-- The idea trail. Revealed only in the craft views where filing an
150
+ idea is the work being shown (all + ideator) — see profile.js. -->
151
+ <div id="activity-ideas-panel" hidden>
152
+ <h3 class="profile-act__h">Ideas filed, and what became of them</h3>
153
+ <p class="scroll__lede">Every idea this builder captured — promoted, discarded, merged, or still open.</p>
154
+ <div id="activity-ideas"></div>
155
+ </div>
156
+ </div>
157
+ </section>
158
+
159
+ <!-- ============ connections: hub-only ============ -->
160
+
161
+ <!-- Connections — section 6 of the profile-page spec (R20, task
162
+ 1002296; ADR 0202). Hub-only, like the cross-project record:
163
+ profile-connections.js reveals it only when the hub's connections
164
+ API answers, so on an instance without platform-identity the
165
+ routes are absent and the section never renders — and its rail
166
+ key with it (profile.js watches this section's hidden attribute). -->
167
+ <section class="scroll" id="conn-scroll" aria-labelledby="conn-h" data-group="connections" hidden>
168
+ <h2 id="conn-h" class="scroll__h">Connections</h2>
169
+ <div id="conn-body"></div>
170
+ </section>
171
+
172
+ <!-- Reserved slots — connections, guilds, and actions (profile-page
173
+ spec sections 6–8) mount here in later phases; keeping the order
174
+ fixed now means the page is not re-laid-out twice (Decision 5).
175
+ Section 6 landed above (task 1002296) as the connections key;
176
+ 7 and 8 are still to come, each as its own rail key. -->
118
177
  </div>
119
- </section>
120
-
121
- <!-- Connections — section 6 of the profile-page spec (R20, task
122
- 1002296; ADR 0202). Hub-only, like the cross-project record:
123
- profile-connections.js reveals it only when the hub's connections
124
- API answers, so on an instance without platform-identity the
125
- routes are absent and the section never renders. -->
126
- <section class="scroll" id="conn-scroll" aria-labelledby="conn-h" hidden>
127
- <h2 id="conn-h" class="scroll__h">Connections</h2>
128
- <div id="conn-body"></div>
129
- </section>
130
-
131
- <!-- Reserved slots — connections, guilds, and actions (profile-page
132
- spec sections 6–8) mount here in later phases; keeping the order
133
- fixed now means the page is not re-laid-out twice (Decision 5).
134
- Section 6 landed above (task 1002296); 7 and 8 are still to come. -->
178
+ </div>
135
179
  </main>
136
180
 
137
181
  <footer class="app-foot" id="app-foot"></footer>
@@ -150,7 +194,8 @@
150
194
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
151
195
  <script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
152
196
  <!-- hall-kit (window.OTBKit) — profile.js renders ship-time task visuals through
153
- its shared thumbnail helper (task 1003109), so it must load first. -->
197
+ its shared thumbnail helper (task 1003109) and builds the section rail with
198
+ it (task 1003489), so it must load first. -->
154
199
  <script src="/builders/hall-kit.js?v=2026-08-27-activity"></script>
155
200
  <!-- Connections (section 6). Loaded BEFORE profile.js so window.OTBConnections
156
201
  exists by the time renderProfile runs; profile.js guards on it anyway, so a
@@ -160,6 +205,10 @@
160
205
  window.OTBCanonical exists when renderProfile runs; profile.js guards on it,
161
206
  so a page that omits this script simply declares no canonical. -->
162
207
  <script src="/builders/profile-canonical.js?v=2026-08-29-p08"></script>
163
- <script src="/builders/profile.js?v=2026-08-27-activity"></script>
208
+ <!-- The charts (task 1003489). Loaded BEFORE profile.js so window.OTBProfileCharts
209
+ exists when renderProfile runs; profile.js guards on it, so a page that
210
+ omits this script simply has no charts section content. -->
211
+ <script src="/builders/profile-charts.js?v=2026-09-02-charts"></script>
212
+ <script src="/builders/profile.js?v=2026-09-02-rail"></script>
164
213
  </body>
165
214
  </html>
@@ -1,6 +1,13 @@
1
1
  // modules/hall-ui/public/profile.js — the viewable builder profile page
2
2
  // (/builders/profile, task 1467, goal 25 "bx-identity").
3
3
  //
4
+ // Since task 1003489 the page is three screens behind a section rail (the
5
+ // settings idiom, OTBKit.makeSectionRail in switch mode): overview — identity,
6
+ // figures, the charts (profile-charts.js), shelf, latest works, cross-project;
7
+ // activity — the craft ledgers and the idea trail; connections — hub-only,
8
+ // its rail key following the section's own reveal. Deep links are the key
9
+ // (/profile?id=95#activity); the "#/…" overlay namespace passes through.
10
+ //
4
11
  // A PUBLIC, shareable portfolio for one builder: rank, works shipped, standing
5
12
  // (drachmae + karma), achievements, and recent works. Reads the public route
6
13
  // GET /api/bongos/builders/:id/profile — every field it shows is already
@@ -125,45 +132,93 @@
125
132
  }).join('');
126
133
  }
127
134
 
128
- // ---- render: recent works -----------------------------------------------
129
-
130
- function renderWorks(ships) {
135
+ // ---- render: the Works ledger -------------------------------------------
136
+ //
137
+ // The owner's confirmed composition (task 1003489) made Works its own rail
138
+ // key: the full, server-paged ledger of everything this builder shipped, with
139
+ // the old Overview "recent works" strip folded in. A signed-in viewer pages
140
+ // the whole record from the activity route (any viewer, any builder); signed
141
+ // out, the page's PUBLIC recent_ships stand in with a sign-in line, so a
142
+ // stranger still sees the last few. Activity (the next key) is the SAME works
143
+ // seen one craft at a time, with goals and the idea trail — a different
144
+ // question, which is why it is a second key and not a duplicate.
145
+ const worksReward = (s) => window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward;
146
+ function worksRowHtml(s) {
147
+ const summary = s.value_summary
148
+ ? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(s.value_summary)}</span>`
149
+ : '';
150
+ const visual = window.OTBKit && window.OTBKit.shipVisualThumbHtml ? window.OTBKit.shipVisualThumbHtml(s) : '';
151
+ return `<tr>
152
+ <td class="ledger__num">#${escapeHtml(String(s.id))}</td>
153
+ <td><a href="${taskHref(s.id)}">${escapeHtml(s.title || `Task ${s.id}`)}</a>${summary}${visual ? `<span style="display:block;margin-top:6px">${visual}</span>` : ''}</td>
154
+ <td class="ledger__drachmae">${fmtNum(worksReward(s))}</td>
155
+ <td class="ledger__date">${fmtDate(s.shipped_at)}</td>
156
+ </tr>`;
157
+ }
158
+ let worksBuilt = false;
159
+ function buildWorksLedger(builderId, signedIn, recentShips) {
131
160
  const tbody = $('#works-table tbody');
132
- const lede = $('#works-lede');
133
161
  if (!tbody) return;
134
- // Every row here is a shipped task, so one header serves them all — but on a
135
- // cost-plus-only instance the figure is still an unpaid estimate, so the
136
- // label must keep its "Est." qualifier (ADR 0146).
162
+ // One header serves every row, but the figure stays an estimate on a
163
+ // cost-plus instance, so the label keeps its "Est." qualifier (ADR 0146).
137
164
  const rewardTh = $('#works-table thead .ledger__drachmae');
138
165
  if (rewardTh) {
139
166
  rewardTh.textContent = window.OTBBranding?.taskCreditLabel?.({ status: 'shipped' }, { caps: true })
140
167
  ?? (curLabel(true) + ' Est.');
141
168
  }
142
- const list = Array.isArray(ships) ? ships : [];
143
- if (!list.length) {
144
- tbody.innerHTML = `<tr><td colspan="4" class="ledger__empty">Nothing shipped yet.</td></tr>`;
145
- if (lede) lede.hidden = true;
169
+ const empty = (msg) => { tbody.innerHTML = `<tr><td colspan="4" class="ledger__empty">${escapeHtml(msg)}</td></tr>`; };
170
+ const pagerMount = $('#works-pager');
171
+
172
+ // Signed out: no activity route, so the public recent_ships ARE the ledger,
173
+ // with a line pointing at the full record behind sign-in.
174
+ if (!signedIn) {
175
+ if (pagerMount) pagerMount.innerHTML = '';
176
+ const list = Array.isArray(recentShips) ? recentShips : [];
177
+ if (!list.length) { empty('Nothing shipped yet.'); return; }
178
+ tbody.innerHTML = list.map(worksRowHtml).join('')
179
+ + `<tr><td colspan="4" class="ledger__empty"><a href="${API}/auth/web/start">Sign in</a> to see the full record.</td></tr>`;
146
180
  return;
147
181
  }
148
- tbody.innerHTML = list.map((s) => {
149
- const reward = window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward;
150
- const summary = s.value_summary
151
- ? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(s.value_summary)}</span>`
152
- : '';
153
- // task 1003109: the optional ship-time visual, as a thumbnail beside the
154
- // summary it restates. '' when the task has none — which is most of them,
155
- // and never reads as an omission.
156
- const visual = window.OTBKit && window.OTBKit.shipVisualThumbHtml
157
- ? window.OTBKit.shipVisualThumbHtml(s)
158
- : '';
159
- return `
160
- <tr>
161
- <td class="ledger__num">#${escapeHtml(String(s.id))}</td>
162
- <td><a href="/task/${encodeURIComponent(s.id)}">${escapeHtml(s.title || `Task ${s.id}`)}</a>${summary}${visual ? `<span style="display:block;margin-top:6px">${visual}</span>` : ''}</td>
163
- <td class="ledger__drachmae">${fmtNum(reward)}</td>
164
- <td class="ledger__date">${fmtDate(s.shipped_at)}</td>
165
- </tr>`;
166
- }).join('');
182
+
183
+ if (worksBuilt) return;
184
+ worksBuilt = true;
185
+ const kit = window.OTBKit;
186
+ // Without the kit's pager helpers a single unpaged page beats nothing (the
187
+ // page still works if hall-kit failed to load, or an older kit lacks the
188
+ // server-pager helpers), the same graceful-degrade the charts take.
189
+ if (!kit || typeof kit.pagerBarHtml !== 'function' || !pagerMount) {
190
+ (async () => {
191
+ try {
192
+ const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/activity?section=tasks&limit=20&offset=0`);
193
+ const rows = (res.ok && res.data.rows) || [];
194
+ if (!rows.length) { empty('Nothing shipped yet.'); return; }
195
+ tbody.innerHTML = rows.map(worksRowHtml).join('');
196
+ } catch (err) { console.error('[profile] works ledger failed', err); empty('Could not load the works.'); }
197
+ })();
198
+ return;
199
+ }
200
+ pagerMount.innerHTML = kit.pagerBarHtml('works', { label: 'works' });
201
+ const pager = kit.makePager('works', 'otb.profile.worksPerPage', { defaultPerPage: 10, minPerPage: 10 });
202
+ let inFlight = 0;
203
+ async function load() {
204
+ const seq = ++inFlight;
205
+ try {
206
+ const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/activity`
207
+ + `?section=tasks&limit=${pager.perPage}&offset=${kit.pageOffset(pager)}`);
208
+ if (seq !== inFlight) return;
209
+ if (!res.ok) throw new Error(`works → ${res.status}`);
210
+ const rows = res.data.rows || [];
211
+ const total = Number(res.data.total) || 0;
212
+ if (kit.renderServerPage(pager, total, rows.length)) { load(); return; }
213
+ tbody.innerHTML = rows.length ? rows.map(worksRowHtml).join('') : `<tr><td colspan="4" class="ledger__empty">Nothing shipped yet.</td></tr>`;
214
+ } catch (err) {
215
+ if (seq !== inFlight) return;
216
+ console.error('[profile] works ledger failed', err);
217
+ empty('Could not load the works.');
218
+ }
219
+ }
220
+ kit.wirePager(pager, load);
221
+ load();
167
222
  }
168
223
 
169
224
  // ---- render: cross-project record (hub-only, ADR 0171 / task 1002280) ----
@@ -514,6 +569,76 @@
514
569
  activity.show(activity.active());
515
570
  }
516
571
 
572
+ // ---- the section rail (task 1003489) ------------------------------------------
573
+ //
574
+ // Settings' idiom, borrowed whole (settings.js wireRail): one group of
575
+ // sections shows at a time behind a left rail in switch mode; the sheet does
576
+ // the showing (.profile-groups[data-active]) and a section's own [hidden]
577
+ // still wins, so the cross-project and connections reveal logic is
578
+ // untouched. The connections KEY follows the section: the rail lists it only
579
+ // while #conn-scroll is revealed, so an instance without the hub never shows
580
+ // a key that opens an empty screen.
581
+ const RAIL_ITEMS = [
582
+ { id: 'overview', label: 'Overview' },
583
+ { id: 'works', label: 'Works' },
584
+ { id: 'activity', label: 'Activity' },
585
+ { id: 'connections', label: 'Connections' },
586
+ ];
587
+ const RAIL_GROUPS = RAIL_ITEMS.map((i) => i.id);
588
+ let rail = null;
589
+
590
+ function railItems() {
591
+ const conn = $('#conn-scroll');
592
+ return RAIL_ITEMS.filter((i) => i.id !== 'connections' || (conn && !conn.hidden));
593
+ }
594
+
595
+ function activateGroup(group, opts) {
596
+ if (!RAIL_GROUPS.includes(group)) return;
597
+ const groups = $('#profile-groups');
598
+ if (!groups) return;
599
+ groups.dataset.active = group;
600
+ rail?.activate(group);
601
+ if (!opts || opts.scroll !== false) window.scrollTo({ top: 0 });
602
+ }
603
+
604
+ // The hash is a key (#activity) or nothing of ours; "#/…" is the overlay
605
+ // namespace and passes through. A key whose section is hidden (connections
606
+ // without the hub) lands on the overview rather than an empty screen.
607
+ function applyHash() {
608
+ const id = String(window.location.hash || '').replace(/^#/, '');
609
+ if (!id || id.startsWith('/')) return;
610
+ const open = railItems().some((i) => i.id === id);
611
+ activateGroup(open ? id : 'overview', { scroll: false });
612
+ }
613
+
614
+ function wireRail() {
615
+ const mount = $('#profile-rail');
616
+ if (!mount || !window.OTBKit || !window.OTBKit.makeSectionRail) return;
617
+ rail = window.OTBKit.makeSectionRail({
618
+ mount,
619
+ items: railItems(),
620
+ mode: 'switch',
621
+ onSwitch: (key) => {
622
+ activateGroup(key);
623
+ // Reflect the key in the URL so the screen is shareable; replaceState
624
+ // keeps Back for leaving the page, not retracing rail clicks.
625
+ history.replaceState(null, '', window.location.pathname + window.location.search + `#${key}`);
626
+ },
627
+ });
628
+ rail.activate('overview');
629
+ applyHash();
630
+ window.addEventListener('hashchange', applyHash);
631
+ // profile-connections.js reveals #conn-scroll on its own schedule (a hub
632
+ // read); the key appears when the section does.
633
+ const conn = $('#conn-scroll');
634
+ if (conn && typeof MutationObserver === 'function') {
635
+ new MutationObserver(() => {
636
+ rail.refresh(railItems());
637
+ rail.activate($('#profile-groups')?.dataset.active || 'overview');
638
+ }).observe(conn, { attributes: true, attributeFilter: ['hidden'] });
639
+ }
640
+ }
641
+
517
642
  // ---- states -------------------------------------------------------------
518
643
 
519
644
  function showMessage(text) {
@@ -528,9 +653,12 @@
528
653
  showMessage(`${name} — ${rankLabel(normalizeRank(builder.rank))} · ${fmtNum(shipped)} work${shipped === 1 ? '' : 's'} shipped`);
529
654
  renderHead(builder);
530
655
  renderNumbers(data.shipped_count, data.achievements);
656
+ // The charts (task 1003489) read the activity route, so they need the same
657
+ // session the ledgers do; on the viewer's own profile they add the pace.
658
+ if (window.OTBProfileCharts) window.OTBProfileCharts.render({ api, API, builderId: builder.id, selfId, signedIn: selfId != null });
531
659
  renderCrossProject(data, selfId);
532
660
  renderAchievements(data.achievements);
533
- renderWorks(data.recent_ships);
661
+ buildWorksLedger(builder.id, selfId != null, data.recent_ships);
534
662
  // The activity log needs a session (private goals + the idea trail), so it
535
663
  // keys off whether /me resolved — the same signal the self-detect uses.
536
664
  renderActivity(builder.id, selfId != null);
@@ -567,6 +695,9 @@
567
695
  const xp = $('#xp-scroll'); if (xp) xp.hidden = true;
568
696
  const act = $('#activity-scroll'); if (act) act.hidden = true;
569
697
  const conn = $('#conn-scroll'); if (conn) conn.hidden = true;
698
+ const charts = $('#charts-scroll'); if (charts) charts.hidden = true;
699
+ const railEl = $('#profile-rail'); if (railEl) railEl.hidden = true;
700
+ activateGroup('overview', { scroll: false });
570
701
  }
571
702
 
572
703
  async function fetchProfile(id, selfId) {
@@ -576,6 +707,7 @@
576
707
  // There is no builder, so there is no activity to log — hide the section
577
708
  // rather than leaving its loading dots on a page about nobody.
578
709
  const act = $('#activity-scroll'); if (act) act.hidden = true;
710
+ const charts = $('#charts-scroll'); if (charts) charts.hidden = true;
579
711
  return;
580
712
  }
581
713
  if (!res.ok) throw new Error(`profile → ${res.status}`);
@@ -598,6 +730,7 @@
598
730
  }
599
731
 
600
732
  async function load() {
733
+ wireRail();
601
734
  const idParam = new URLSearchParams(location.search).get('id');
602
735
  try {
603
736
  if (idParam) {