@bongos/core 1.19.170 → 1.19.171

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.170",
6
- "core_contract": "1.19.170",
7
- "source_commit": "2fd8009b960bd910fe95b3169d504adc7fe309d1",
5
+ "core_version": "1.19.171",
6
+ "core_contract": "1.19.171",
7
+ "source_commit": "d102a0205fd62edcc81a804a8c439af4cde60606",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-16T18:45:10.150Z",
9
+ "built_at": "2026-08-16T19:20:36.950Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 305,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1268,
14
+ "functional_verbatim": 1269,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1594,
20
- "tree_sha256": "938326e2e51b3c2bf0a7ab516c8444005ec95028f247df10cc55bedae67b099f",
19
+ "file_count": 1595,
20
+ "tree_sha256": "1596294ffbb9fe146a18ced51823d4b074c23b5dd47601619bcd16752c5eaf04",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -3317,12 +3317,12 @@
3317
3317
  {
3318
3318
  "path": "modules/hall-ui/public/profile.html",
3319
3319
  "mode": "0000644",
3320
- "sha256": "cd3148ae6a22d7e7fff8e0b4a07aed2fe6d27b4e0b6e5b450bd2a63b3f078713"
3320
+ "sha256": "b8d5fb31d92f6d65e8f257d10c77f4ee0aa0ebec50d977d4c7d918ff2253da1e"
3321
3321
  },
3322
3322
  {
3323
3323
  "path": "modules/hall-ui/public/profile.js",
3324
3324
  "mode": "0000644",
3325
- "sha256": "be57f192368280ac1ae2b0b820f89daf59512f9f89d9ad23414c42cbf09a462e"
3325
+ "sha256": "a4cf0c4c2d088fd1fb84c41c93cc28e2acd2b656b0bc995cd46043881b3611fa"
3326
3326
  },
3327
3327
  {
3328
3328
  "path": "modules/hall-ui/public/ranks.html",
@@ -4212,12 +4212,12 @@
4212
4212
  {
4213
4213
  "path": "package-lock.json",
4214
4214
  "mode": "0000644",
4215
- "sha256": "0b596f97bbd28b6ddfda4a9265effcb0ea8711a4bb57948c35b80d7cf79c23d9"
4215
+ "sha256": "90e1fc07f655eb2874620b5a85f965e4f32cc20aeddb552dbc8885c4681150d9"
4216
4216
  },
4217
4217
  {
4218
4218
  "path": "package.json",
4219
4219
  "mode": "0000644",
4220
- "sha256": "ae9625129bd03cb29845f65e1be3abd91c48259d85a9c23095370e43a0a0e8aa"
4220
+ "sha256": "2b1408849261b93418e448820da8081a7ec83fa6b5c2a51b9e1aa5dca7456efb"
4221
4221
  },
4222
4222
  {
4223
4223
  "path": "public-docs/index.html",
@@ -5477,7 +5477,7 @@
5477
5477
  {
5478
5478
  "path": "scripts/hall-preview/server.js",
5479
5479
  "mode": "0000644",
5480
- "sha256": "b6550cee568d8e8082c30512979256872d71d0d7f1810d061f5dbffad8a76979"
5480
+ "sha256": "1cd66475d610b107b0f3b7f19688133e7c482b41b034e2cef6009d552df31cb2"
5481
5481
  },
5482
5482
  {
5483
5483
  "path": "scripts/migrate.sh",
@@ -5822,7 +5822,7 @@
5822
5822
  {
5823
5823
  "path": "src/module-api.js",
5824
5824
  "mode": "0000644",
5825
- "sha256": "28605499539ca63e000ac2fc52af1a3d75dbb2fd947846426619a189a2afd92b"
5825
+ "sha256": "17202c8572c75ca1af5cd2eee1c8dd1ba4b6ea4c5fd7cd757eaf97907d26a268"
5826
5826
  },
5827
5827
  {
5828
5828
  "path": "src/module-loader/catalog.js",
@@ -7389,6 +7389,11 @@
7389
7389
  "mode": "0000644",
7390
7390
  "sha256": "3b3045a6143d67c963516ed5703b5dabb9b8945a392118721e6a0a56258f1732"
7391
7391
  },
7392
+ {
7393
+ "path": "tests/profile_ui_cross_project.mjs",
7394
+ "mode": "0000644",
7395
+ "sha256": "6a7a223a678b56333befaed017429e5b9d8a81e4405efae72a23355902517d9d"
7396
+ },
7392
7397
  {
7393
7398
  "path": "tests/project_stats.mjs",
7394
7399
  "mode": "0000644",
@@ -40,6 +40,14 @@
40
40
  </div>
41
41
  </section>
42
42
 
43
+ <!-- Cross-project record — hub-only (ADR 0171). profile.js reveals this
44
+ only when the profile response carries a cross_project key; on a
45
+ vanilla instance the key is absent and the section never renders. -->
46
+ <section class="scroll" id="xp-scroll" aria-labelledby="xp-h" hidden>
47
+ <h2 id="xp-h" class="scroll__h">Cross-project record</h2>
48
+ <div id="xp-body"></div>
49
+ </section>
50
+
43
51
  <!-- Achievements — the builder's earned shelf. -->
44
52
  <section class="scroll scroll--bare" id="ach-scroll" aria-labelledby="ach-h">
45
53
  <h2 id="ach-h" class="scroll__h">Achievements</h2>
@@ -66,6 +74,10 @@
66
74
  </tbody>
67
75
  </table>
68
76
  </section>
77
+
78
+ <!-- Reserved slots — connections, guilds, and actions (profile-page
79
+ spec sections 6–8) mount here in later phases; keeping the order
80
+ fixed now means the page is not re-laid-out twice (Decision 5). -->
69
81
  </main>
70
82
 
71
83
  <footer class="app-foot" id="app-foot"></footer>
@@ -83,6 +95,6 @@
83
95
  <!-- shared frontend toolkit (window.OTB) — loaded before profile.js, which pulls from it. -->
84
96
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
85
97
  <script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
86
- <script src="/builders/profile.js?v=2026-08-04-reward1"></script>
98
+ <script src="/builders/profile.js?v=2026-08-16-r04"></script>
87
99
  </body>
88
100
  </html>
@@ -160,6 +160,82 @@
160
160
  }).join('');
161
161
  }
162
162
 
163
+ // ---- render: cross-project record (hub-only, ADR 0171 / task 1002280) ----
164
+ //
165
+ // The section reveals only when the response carries a cross_project KEY —
166
+ // present iff the hub port resolved; on a vanilla instance the key is absent
167
+ // and the section stays hidden (degrade, don't 500). A null value means "no
168
+ // public cross-project record" — hidden, provisional, or none, deliberately
169
+ // indistinguishable — so all three render the same private line and NOTHING
170
+ // derived from the rollup (no partial numbers; a count plus a hidden total is
171
+ // a leak by subtraction). The response is viewer-independent (ADR 0171 D4):
172
+ // self is detected client-side by comparing the /me id, and the self "toggle"
173
+ // is a link to the real control in Settings (R03). No caching assumptions —
174
+ // hub profile responses are no-store (ADR 0171 D5).
175
+
176
+ function xpProjectRow(p) {
177
+ const name = escapeHtml(p.name || 'Project');
178
+ // origin is third-party-reported; link only a well-formed https URL.
179
+ const origin = typeof p.origin === 'string' && /^https:\/\//.test(p.origin) ? p.origin : null;
180
+ const nameCell = origin ? `<a href="${escapeHtml(origin)}" rel="noopener">${name}</a>` : name;
181
+ // rank_held is THAT project's standing as it reported it — plain text, never
182
+ // the local rank badge or the instance's own rank labels (spec Decision 1).
183
+ const held = typeof p.rank_held === 'string' && p.rank_held
184
+ ? escapeHtml(p.rank_held.charAt(0).toUpperCase() + p.rank_held.slice(1))
185
+ : '—';
186
+ return `<tr><td>${nameCell}</td><td>${held}</td></tr>`;
187
+ }
188
+
189
+ function renderCrossProject(data, selfId) {
190
+ const section = $('#xp-scroll');
191
+ const body = $('#xp-body');
192
+ if (!section || !body) return;
193
+ if (!data || !('cross_project' in data)) {
194
+ section.hidden = true;
195
+ return;
196
+ }
197
+ // ids may serialize as strings (pg bigint) — compare canonicalized.
198
+ const isSelf = selfId != null && data.builder && String(data.builder.id) === String(selfId);
199
+ const manage = isSelf
200
+ ? `<p class="scroll__lede">This is you — <a href="/settings#cross-project">manage its visibility in Settings → Cross-project record</a>.</p>`
201
+ : '';
202
+ const cp = data.cross_project;
203
+ if (!cp) {
204
+ body.innerHTML = `<p class="scroll__lede">This builder keeps their track record private.</p>${manage}`;
205
+ section.hidden = false;
206
+ return;
207
+ }
208
+ const t = cp.totals || {};
209
+ const tiles =
210
+ `<div class="stat-row">` +
211
+ tile('Projects', fmtNum(t.projects)) +
212
+ tile('Works shipped', fmtNum(t.works_shipped)) +
213
+ tile('Credits', fmtNum(t.credits)) +
214
+ tile('Karma', fmtNum(t.karma)) +
215
+ `</div>`;
216
+ const discs = Array.isArray(cp.disciplines) ? cp.disciplines.filter((d) => typeof d === 'string') : [];
217
+ const metaBits = [];
218
+ if (discs.length) metaBits.push(`Disciplines: ${discs.join(', ')}`);
219
+ if (cp.last_active) metaBits.push(`Last active ${fmtDate(cp.last_active)}`);
220
+ const meta = metaBits.length ? `<p class="scroll__lede">${escapeHtml(metaBits.join(' · '))}</p>` : '';
221
+ const named = Array.isArray(cp.projects) ? cp.projects : [];
222
+ const moreCount = Number(cp.more_projects) || 0;
223
+ const projectsTable = named.length
224
+ ? `<table class="ledger">
225
+ <thead><tr><th>Project</th><th title="That project's own standing, as reported by that project.">Standing there</th></tr></thead>
226
+ <tbody>${named.map(xpProjectRow).join('')}</tbody>
227
+ </table>`
228
+ : '';
229
+ const more = moreCount > 0
230
+ ? `<p class="scroll__lede">+ ${fmtNum(moreCount)} more project${moreCount === 1 ? '' : 's'} not publicly listed.</p>`
231
+ : '';
232
+ const none = !named.length && !moreCount
233
+ ? `<p class="scroll__lede">No cross-project activity reported yet.</p>`
234
+ : '';
235
+ body.innerHTML = tiles + meta + projectsTable + more + none + manage;
236
+ section.hidden = false;
237
+ }
238
+
163
239
  // ---- states -------------------------------------------------------------
164
240
 
165
241
  function showMessage(text) {
@@ -167,13 +243,14 @@
167
243
  if (sub) sub.textContent = text;
168
244
  }
169
245
 
170
- function renderProfile(data) {
246
+ function renderProfile(data, selfId) {
171
247
  const builder = data.builder || {};
172
248
  const name = builder.display_name || builder.github_login || 'Builder';
173
249
  const shipped = Number(data.shipped_count) || 0;
174
250
  showMessage(`${name} — ${rankLabel(normalizeRank(builder.rank))} · ${fmtNum(shipped)} work${shipped === 1 ? '' : 's'} shipped`);
175
251
  renderHead(builder);
176
252
  renderNumbers(data.shipped_count, data.achievements);
253
+ renderCrossProject(data, selfId);
177
254
  renderAchievements(data.achievements);
178
255
  renderWorks(data.recent_ships);
179
256
  }
@@ -197,23 +274,39 @@
197
274
  const ach = $('#ach-grid'); if (ach) ach.innerHTML = '';
198
275
  const works = $('#works-table tbody'); if (works) works.innerHTML = '';
199
276
  const worksLede = $('#works-lede'); if (worksLede) worksLede.hidden = true;
277
+ const xp = $('#xp-scroll'); if (xp) xp.hidden = true;
200
278
  }
201
279
 
202
- async function fetchProfile(id) {
280
+ async function fetchProfile(id, selfId) {
203
281
  const res = await api.request('GET', `${API}/builders/${encodeURIComponent(id)}/profile`);
204
282
  if (res.status === 404) {
205
283
  showMessage('No builder found with that id.');
206
284
  return;
207
285
  }
208
286
  if (!res.ok) throw new Error(`profile → ${res.status}`);
209
- renderProfile(res.data || {});
287
+ renderProfile(res.data || {}, selfId);
288
+ }
289
+
290
+ // The viewer's own builder id, or null when signed out / unresolvable. Used
291
+ // ONLY for client-side self-detection (ADR 0171 D4 — the profile response is
292
+ // viewer-independent); a failure here just means no self link renders.
293
+ async function resolveSelfId() {
294
+ try {
295
+ const me = await api.request('GET', `${API}/me`);
296
+ if (!me.ok) return null;
297
+ const builder = (me.data && me.data.builder) ? me.data.builder : me.data;
298
+ return (builder && builder.id) || null;
299
+ } catch (err) {
300
+ console.error('[profile] /me self-detect failed', err);
301
+ return null;
302
+ }
210
303
  }
211
304
 
212
305
  async function load() {
213
306
  const idParam = new URLSearchParams(location.search).get('id');
214
307
  try {
215
308
  if (idParam) {
216
- await fetchProfile(idParam);
309
+ await fetchProfile(idParam, await resolveSelfId());
217
310
  return;
218
311
  }
219
312
  // No id — soft-resolve the viewer's OWN id from /me (no hard redirect; a
@@ -230,7 +323,7 @@
230
323
  renderEmpty();
231
324
  return;
232
325
  }
233
- await fetchProfile(selfId);
326
+ await fetchProfile(selfId, selfId);
234
327
  } catch (err) {
235
328
  console.error('[profile] failed to load', err);
236
329
  showMessage('Could not load this profile. Return to the hall and try again.');
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.170",
3
+ "version": "1.19.171",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.170",
9
+ "version": "1.19.171",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.170",
3
+ "version": "1.19.171",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -81,6 +81,7 @@ const FIXTURE_FALLBACKS = [
81
81
  [/^blockers\/\d+$/, 'blockers__detail'],
82
82
  [/^analytics\/builder\/\d+$/, 'analytics__builder'],
83
83
  [/^builders\/\d+\/active-claims$/, 'builders__active-claims'],
84
+ [/^builders\/\d+\/profile$/, 'builders__profile'],
84
85
  ];
85
86
 
86
87
  function loadFixture(name) {
package/src/module-api.js CHANGED
@@ -767,8 +767,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
767
767
  // landed since 1.19.168 with no explicit bump. run 31964702865. (task 1002620)
768
768
  // 1.19.170 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
769
769
  // landed since 1.19.169 with no explicit bump. run 31965483417. (task 1002620)
770
+ // 1.19.171 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
771
+ // landed since 1.19.170 with no explicit bump. run 31967255167. (task 1002620)
770
772
  // ---------------------------------------------------------------------------
771
- const CORE_VERSION = '1.19.170'; // CI auto-patch carrier (ADR 0161); see changelog above
773
+ const CORE_VERSION = '1.19.171'; // CI auto-patch carrier (ADR 0161); see changelog above
772
774
 
773
775
  // A namespaced logger so a module's log lines are attributable + consistent.
774
776
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,182 @@
1
+ // tests/profile_ui_cross_project.mjs — the profile page's cross-project section
2
+ // (task 1002280, R04 · goal 1000045, ADR 0171).
3
+ //
4
+ // EXECUTES the real modules/hall-ui/public/profile.js in a minimal DOM stub
5
+ // (the hall_signout.mjs pattern) against canned API responses, and asserts the
6
+ // consent-sensitive render contract:
7
+ //
8
+ // - no `cross_project` key in the response (vanilla instance) → the section
9
+ // stays hidden entirely;
10
+ // - `cross_project: null` (hidden / provisional / none — indistinguishable by
11
+ // design) → the "keeps their track record private" line renders and NOTHING
12
+ // derived from the rollup does (no tiles, no digits — a count plus a hidden
13
+ // total is a leak by subtraction);
14
+ // - the self hide-toggle is client-side only (ADR 0171 D4): the Settings link
15
+ // renders iff the /me id matches the profile id, on both the ?id= and the
16
+ // own-profile load paths;
17
+ // - the full object renders totals tiles, named project rows (https origins
18
+ // linked, anything else plain text), `rank_held` as plain text — never the
19
+ // local rank-badge component (spec Decision 1) — and the counted-never-named
20
+ // "+N more" line.
21
+ //
22
+ // Pure vm + file-read (no DB/network) — runs in the DB-free unit gate.
23
+ import assert from 'node:assert/strict';
24
+ import { test } from 'node:test';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import vm from 'node:vm';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
32
+ const HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.html'), 'utf8');
33
+
34
+ // ---- fixtures -------------------------------------------------------------
35
+
36
+ const BUILDER = {
37
+ id: 7, github_login: 'pheidias', display_name: 'Pheidias', avatar_url: null,
38
+ rank: 'metic', total_credits: 120, karma: 3, created_at: '2026-01-01T00:00:00.000Z',
39
+ preferred_disciplines: ['engineer'],
40
+ };
41
+
42
+ const CP_FULL = {
43
+ totals: { projects: 3, credits: 150, works_shipped: 7, karma: 4 },
44
+ disciplines: ['engineer', 'ui'],
45
+ last_active: '2026-08-01T00:00:00.000Z',
46
+ projects: [
47
+ { name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic' },
48
+ { name: 'Odd Origin', origin: 'http://insecure.example', rank_held: null },
49
+ ],
50
+ more_projects: 1,
51
+ };
52
+
53
+ const baseProfile = () => ({ builder: { ...BUILDER }, shipped_count: 1, recent_ships: [], achievements: [] });
54
+
55
+ // ---- harness --------------------------------------------------------------
56
+
57
+ // Boot profile.js with canned /me + profile responses; returns the memoised
58
+ // element map after the async load() settles.
59
+ async function boot({ search = '', me, profile } = {}) {
60
+ const els = new Map();
61
+ const makeEl = () => ({
62
+ hidden: false, textContent: '', _html: '',
63
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
64
+ });
65
+ const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
66
+ // Mirror the markup: the section ships with the `hidden` attribute.
67
+ $('#xp-scroll').hidden = true;
68
+
69
+ const escapeHtml = (s) => String(s)
70
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
71
+ const api = {
72
+ async request(_method, url) {
73
+ if (url === '/api/bongos/me') return me ?? { status: 401, ok: false, data: null };
74
+ if (/\/builders\/[^/]+\/profile$/.test(url)) return profile ?? { status: 404, ok: false, data: null };
75
+ return { status: 404, ok: false, data: null };
76
+ },
77
+ };
78
+ const windowObj = {
79
+ __BRANDING__: {},
80
+ BongosClient: { createClient: () => api },
81
+ OTB: {
82
+ $, escapeHtml,
83
+ fmtNum: (n) => String(Number(n) || 0),
84
+ fmtDate: (d) => String(d).slice(0, 10),
85
+ rankLabel: (r) => r,
86
+ toast() {},
87
+ },
88
+ };
89
+ const sandbox = {
90
+ window: windowObj, location: { search }, URLSearchParams, console,
91
+ };
92
+ sandbox.globalThis = sandbox;
93
+ vm.createContext(sandbox);
94
+ vm.runInContext(SRC, sandbox, { filename: 'profile.js' });
95
+ // load() is fire-and-forget; let its await chain settle.
96
+ for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r));
97
+ return { els, $ };
98
+ }
99
+
100
+ const meOk = (id) => ({ status: 200, ok: true, data: { builder: { id } } });
101
+ const profileOk = (data) => ({ status: 200, ok: true, data });
102
+
103
+ // ---- the markup ships the section hidden ----------------------------------
104
+
105
+ test('profile.html carries the hidden cross-project section + reserved slots', () => {
106
+ assert.match(HTML, /<section[^>]*id="xp-scroll"[^>]*hidden>/, 'the section must ship hidden — it reveals only when the response carries a cross_project key');
107
+ assert.match(HTML, /Reserved slots[\s\S]*connections, guilds, and actions/, 'the spec Decision 5 slot reservation must stay in the layout');
108
+ });
109
+
110
+ // ---- vanilla instance: key absent → section absent -------------------------
111
+
112
+ test('no cross_project key (vanilla instance) → the section stays hidden', async () => {
113
+ const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk(baseProfile()) });
114
+ assert.equal($('#xp-scroll').hidden, true);
115
+ assert.equal($('#xp-body').innerHTML, '');
116
+ });
117
+
118
+ // ---- cross_project: null → private line, nothing derived -------------------
119
+
120
+ test('cross_project null, stranger view → private line only, no numbers, no self link', async () => {
121
+ const data = { ...baseProfile(), cross_project: null };
122
+ const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk(data) });
123
+ const xp = $('#xp-scroll');
124
+ const html = $('#xp-body').innerHTML;
125
+ assert.equal(xp.hidden, false, 'on the hub the section reveals even when the record is private');
126
+ assert.match(html, /keeps their track record private/);
127
+ assert.ok(!html.includes('stat-tile'), 'no tiles in the private state');
128
+ assert.ok(!/\d/.test(html), 'no digits at all — no partial numbers (leak by subtraction)');
129
+ assert.ok(!html.includes('/settings#cross-project'), 'no self link for a stranger');
130
+ });
131
+
132
+ test('cross_project null, self view via ?id= → private line + the Settings link', async () => {
133
+ const data = { ...baseProfile(), cross_project: null };
134
+ // /me id as a STRING (pg bigint serialization) against the numeric profile id
135
+ // — self-detection must be serialization-proof.
136
+ const { $ } = await boot({ search: '?id=7', me: meOk('7'), profile: profileOk(data) });
137
+ const html = $('#xp-body').innerHTML;
138
+ assert.match(html, /keeps their track record private/);
139
+ assert.ok(html.includes('/settings#cross-project'), 'the self hide-toggle links to Settings → Cross-project record');
140
+ });
141
+
142
+ test('self view with no ?id= (own-profile path) also gets the Settings link', async () => {
143
+ const data = { ...baseProfile(), cross_project: null };
144
+ const { $ } = await boot({ search: '', me: meOk(7), profile: profileOk(data) });
145
+ assert.ok($('#xp-body').innerHTML.includes('/settings#cross-project'));
146
+ });
147
+
148
+ // ---- the full record -------------------------------------------------------
149
+
150
+ test('full cross_project → tiles, named rows, plain-text standing, +N more', async () => {
151
+ const data = { ...baseProfile(), cross_project: CP_FULL };
152
+ const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk(data) });
153
+ const xp = $('#xp-scroll');
154
+ const html = $('#xp-body').innerHTML;
155
+ assert.equal(xp.hidden, false);
156
+ assert.equal((html.match(/stat-tile__num/g) || []).length, 4, 'projects / works shipped / credits / karma tiles');
157
+ assert.match(html, /Public Proj/);
158
+ assert.ok(html.includes('href="https://pub.example"'), 'a well-formed https origin links out');
159
+ assert.match(html, /Odd Origin/);
160
+ assert.ok(!html.includes('href="http://insecure.example"'), 'a non-https origin renders as plain text');
161
+ assert.match(html, /Metic/, 'rank_held renders capitalized as that project\'s own standing');
162
+ assert.ok(!html.includes('rank-badge'), 'never the local rank-badge component for another project\'s rank (spec Decision 1)');
163
+ assert.match(html, /\+ 1 more project/, 'non-public projects are counted, never named');
164
+ assert.match(html, /Disciplines: engineer, ui/);
165
+ assert.match(html, /Last active 2026-08-01/);
166
+ assert.ok(!html.includes('/settings#cross-project'), 'no self link for a stranger');
167
+ });
168
+
169
+ test('zero-activity record → the empty line, no project table', async () => {
170
+ const cp = { ...CP_FULL, projects: [], more_projects: 0 };
171
+ const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk({ ...baseProfile(), cross_project: cp }) });
172
+ const html = $('#xp-body').innerHTML;
173
+ assert.match(html, /No cross-project activity reported yet\./);
174
+ assert.ok(!html.includes('<table'), 'no empty ledger');
175
+ });
176
+
177
+ // ---- signed-out empty state ------------------------------------------------
178
+
179
+ test('signed-out visitor with no ?id= → empty state keeps the section hidden', async () => {
180
+ const { $ } = await boot({ search: '', me: { status: 401, ok: false, data: null } });
181
+ assert.equal($('#xp-scroll').hidden, true);
182
+ });