@bongos/core 1.19.426 → 1.19.427
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 +35 -20
- package/docs/adr/0212-connections-on-the-profile-surface-and-addressing.md +61 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +13 -5
- package/docs/copy-registry.json +89 -7
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/profile-connections.js +304 -0
- package/modules/hall-ui/public/profile.html +16 -1
- package/modules/hall-ui/public/profile.js +5 -0
- package/modules/hall-ui/public/style.css +38 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/profile_connections_ui.mjs +874 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// modules/hall-ui/public/profile-connections.js — section 6 of the builder
|
|
2
|
+
// profile page: connections, degree labels, and the connect button (R20,
|
|
3
|
+
// task 1002296 / goal 1000045 criterion C6; ADR 0202, ADR 0207).
|
|
4
|
+
//
|
|
5
|
+
// WHAT IT RENDERS, and to whom:
|
|
6
|
+
// * on YOUR OWN profile — the pending requests in both directions (incoming
|
|
7
|
+
// ones you Accept or Decline, outgoing ones you Withdraw) and your accepted
|
|
8
|
+
// connections, each 1st degree by definition, each removable.
|
|
9
|
+
// * on ANOTHER builder's profile — the viewer-relative degree label, the
|
|
10
|
+
// mutual connections it was computed from, and the connect affordance.
|
|
11
|
+
//
|
|
12
|
+
// HUB-ONLY, AND IT DEGRADES TO NOTHING. Connections are account-to-account on
|
|
13
|
+
// the platform hub (modules/platform-identity); on an instance without that
|
|
14
|
+
// module every route here is absent, every call 404s, and the section never
|
|
15
|
+
// reveals — the same "reveal only when the data is there" contract the
|
|
16
|
+
// cross-project section already keeps on this page.
|
|
17
|
+
//
|
|
18
|
+
// FOUR THINGS THIS FILE MUST NOT DO. Each is a privacy decision the API made
|
|
19
|
+
// deliberately, and the UI is the last place it can be undone:
|
|
20
|
+
//
|
|
21
|
+
// 1. `degree: null` is OUT OF NETWORK, never "not connected" (ADR 0202
|
|
22
|
+
// Consequences). Past the fan-out caps or past three hops the API stops
|
|
23
|
+
// searching and says so honestly; rendering that as the absence of a
|
|
24
|
+
// relationship states something the query never established.
|
|
25
|
+
// 2. The uncertainty copy beside an out-of-network label is CONSTANT. The
|
|
26
|
+
// API's `truncated` describes the VIEWER's own expansion; the target's
|
|
27
|
+
// 1-hop truncation is deliberately unreported (ADR 0207 L2) because
|
|
28
|
+
// reporting it leaks a bit about a third party's degree. Copy that varied
|
|
29
|
+
// with `truncated` would rebuild that side channel in words.
|
|
30
|
+
// 3. A sent request claims exactly `201 { status: 'pending' }` and nothing
|
|
31
|
+
// more (ADR 0207 D6) — no id, no timestamp, no "they will be notified",
|
|
32
|
+
// and no remark when a re-ask inside the 90-day decline cooldown is
|
|
33
|
+
// silently swallowed (ADR 0207 L1). "Request sent" is the whole claim.
|
|
34
|
+
// 4. `seen_at` never reaches the screen. It was stripped from outgoing rows
|
|
35
|
+
// so a requester cannot learn when their request was read; rendering it on
|
|
36
|
+
// the incoming side re-creates that signal the moment a screenshot passes
|
|
37
|
+
// between the two people.
|
|
38
|
+
//
|
|
39
|
+
// COUNTS ARE ARRAY LENGTHS. Every number here is the length of the list the
|
|
40
|
+
// viewer was actually handed — never a total carried beside a filtered list,
|
|
41
|
+
// which is a leak by subtraction (ADR 0171 D4 / ADR 0202 D4).
|
|
42
|
+
//
|
|
43
|
+
// NOTHING HERE IS AUTHORITY (ADR 0016): a connection grants no rank, no credits
|
|
44
|
+
// and no admission. Nothing rendered here is rollup-derived either, which is
|
|
45
|
+
// why `hide_stats` does not suppress it — hiding your stats hides the numbers
|
|
46
|
+
// beside an edge, not the edge itself (ADR 0202 D4 §3).
|
|
47
|
+
//
|
|
48
|
+
// ADDRESSING. The connections API names a counterparty by HANDLE; this page
|
|
49
|
+
// knows only a builder's github_login. It hands that login to
|
|
50
|
+
// GET /connections/degree/:handle, which resolves the current handle or the
|
|
51
|
+
// single previous-handle rename slot and echoes the CANONICAL handle back —
|
|
52
|
+
// and that echoed handle, never the login, is what every write here sends. A
|
|
53
|
+
// login resolving to neither answers 404 and the section stays hidden, the same
|
|
54
|
+
// fail-closed answer a private or provisional account gets. The handle-addressed
|
|
55
|
+
// hub page (/u/<handle>) will not need this hop at all.
|
|
56
|
+
|
|
57
|
+
(() => {
|
|
58
|
+
'use strict';
|
|
59
|
+
|
|
60
|
+
const API = '/api/bongos';
|
|
61
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
62
|
+
const { escapeHtml, $, fmtDate } = window.OTB;
|
|
63
|
+
const toast = (msg) => window.OTB.toast(msg, { duration: 2600, clear: true });
|
|
64
|
+
|
|
65
|
+
// The only degrees that have a name. Anything else — null, or a number the
|
|
66
|
+
// API never promised — falls through to "out of network" rather than being
|
|
67
|
+
// invented a label (note 1 above).
|
|
68
|
+
const DEGREE_SHORT = { 0: 'You', 1: '1st', 2: '2nd', 3: '3rd' };
|
|
69
|
+
const DEGREE_LINE = {
|
|
70
|
+
0: 'This is your own profile.',
|
|
71
|
+
1: 'You are connected.',
|
|
72
|
+
2: 'You know someone they know.',
|
|
73
|
+
3: 'Three connections away in your network.',
|
|
74
|
+
};
|
|
75
|
+
const OUT_OF_NETWORK = 'Out of network';
|
|
76
|
+
// CONSTANT by decision — it must read the same whether or not the response
|
|
77
|
+
// said `truncated` (note 2 above).
|
|
78
|
+
const OUT_OF_NETWORK_WHY = 'No path to this builder through the connections you can see. '
|
|
79
|
+
+ 'There may be none, or it may run through someone whose account is not visible to you.';
|
|
80
|
+
// The whole claim a 201 supports, in markup, used by both the first render
|
|
81
|
+
// and the click that earns it.
|
|
82
|
+
const SENT_HTML = '<p class="conn-act"><button class="btn-ghost" type="button" disabled>Request sent</button></p>';
|
|
83
|
+
|
|
84
|
+
const known = (degree) => Object.prototype.hasOwnProperty.call(DEGREE_SHORT, degree);
|
|
85
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
86
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
87
|
+
|
|
88
|
+
// The last render's inputs, so an accept/decline/remove can repaint the
|
|
89
|
+
// section from the server rather than patching the DOM it just changed.
|
|
90
|
+
let last = null;
|
|
91
|
+
|
|
92
|
+
// ---- shared cells ---------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
// One counterparty, rendered from the only three columns the API projects
|
|
95
|
+
// for one (handle, display_name, avatar_url) — nothing rollup-derived, so
|
|
96
|
+
// there is nothing here for hide_stats to suppress.
|
|
97
|
+
function personHtml(row) {
|
|
98
|
+
const name = escapeHtml(row.display_name || row.handle || 'Builder');
|
|
99
|
+
const avatar = row.avatar_url
|
|
100
|
+
? `<img class="conn-person__avatar" src="${escapeHtml(row.avatar_url)}" alt="">`
|
|
101
|
+
: '<span class="conn-person__avatar" aria-hidden="true"></span>';
|
|
102
|
+
const handle = row.handle ? `<span class="conn-person__handle">@${escapeHtml(row.handle)}</span>` : '';
|
|
103
|
+
return `<span class="conn-person">${avatar}<span class="conn-person__name">${name}</span>${handle}</span>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function btn(act, id, label) {
|
|
107
|
+
return `<button class="btn-ghost" type="button" data-act="${escapeHtml(act)}" data-id="${escapeHtml(id)}">${escapeHtml(label)}</button>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- your own profile -----------------------------------------------------
|
|
111
|
+
|
|
112
|
+
// Every row is an accepted connection of yours, so its degree is 1 by
|
|
113
|
+
// definition — no per-row query, and no chance of a fan-out cap making the
|
|
114
|
+
// label wrong (ADR 0207 D2: a cap may make an answer less specific, never
|
|
115
|
+
// wrong). `connected_at` is the edge's own timestamp, not a rollup number.
|
|
116
|
+
function connectionRow(row) {
|
|
117
|
+
return `<tr>
|
|
118
|
+
<td>${personHtml(row)}</td>
|
|
119
|
+
<td><span class="conn-degree conn-degree--1">1st</span></td>
|
|
120
|
+
<td class="ledger__date">${row.connected_at ? escapeHtml(fmtDate(row.connected_at)) : '—'}</td>
|
|
121
|
+
<td>${btn('remove', row.id, 'Remove')}</td>
|
|
122
|
+
</tr>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function requestsHtml(incoming, outgoing) {
|
|
126
|
+
const inbox = incoming.map((r) => `<li class="conn-req">${personHtml(r)}`
|
|
127
|
+
+ (r.note ? `<p class="conn-req__note">${escapeHtml(r.note)}</p>` : '')
|
|
128
|
+
+ `<span class="conn-req__acts">${btn('accept', r.id, 'Accept')}${btn('decline', r.id, 'Decline')}</span></li>`).join('');
|
|
129
|
+
const outbox = outgoing.map((r) => `<li class="conn-req">${personHtml(r)}`
|
|
130
|
+
+ '<span class="conn-req__state">Request sent</span>'
|
|
131
|
+
+ `<span class="conn-req__acts">${btn('withdraw', r.id, 'Withdraw')}</span></li>`).join('');
|
|
132
|
+
return (incoming.length ? `<h3 class="conn-sub">${escapeHtml(plural(incoming.length, 'builder'))} asking to connect</h3><ul class="conn-reqs">${inbox}</ul>` : '')
|
|
133
|
+
+ (outgoing.length ? `<h3 class="conn-sub">${escapeHtml(plural(outgoing.length, 'request'))} you have sent</h3><ul class="conn-reqs">${outbox}</ul>` : '');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Your own connections + both request queues. Returns false when the hub
|
|
137
|
+
// routes are absent or the session is gone, so the caller leaves the section
|
|
138
|
+
// hidden rather than rendering an empty shell.
|
|
139
|
+
async function renderOwn(body) {
|
|
140
|
+
const [list, reqs] = await Promise.all([
|
|
141
|
+
api.request('GET', `${API}/connections`),
|
|
142
|
+
api.request('GET', `${API}/connections/requests`),
|
|
143
|
+
]);
|
|
144
|
+
if (!list.ok || !reqs.ok) return false;
|
|
145
|
+
const connections = arr(list.data && list.data.connections);
|
|
146
|
+
const incoming = arr(reqs.data && reqs.data.incoming);
|
|
147
|
+
const outgoing = arr(reqs.data && reqs.data.outgoing);
|
|
148
|
+
body.innerHTML = requestsHtml(incoming, outgoing)
|
|
149
|
+
+ `<p class="scroll__lede">${escapeHtml(plural(connections.length, 'connection'))}.</p>`
|
|
150
|
+
+ '<div id="conn-ledger"></div>';
|
|
151
|
+
const mount = $('#conn-ledger');
|
|
152
|
+
if (mount) {
|
|
153
|
+
const pager = window.OTBKit.makePager('conn', 'profile.connections.perPage');
|
|
154
|
+
window.OTBKit.makeLedger({
|
|
155
|
+
mount,
|
|
156
|
+
columns: ['Builder', 'Degree', { label: 'Connected', class: 'ledger__date' }, ''],
|
|
157
|
+
rowHtml: connectionRow,
|
|
158
|
+
emptyMsg: 'No connections yet.',
|
|
159
|
+
pager,
|
|
160
|
+
}).render(connections);
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- someone else's profile -----------------------------------------------
|
|
166
|
+
|
|
167
|
+
function degreeHtml(degree, mutuals) {
|
|
168
|
+
const chip = `<span class="conn-degree conn-degree--${known(degree) ? degree : 'out'}">`
|
|
169
|
+
+ `${escapeHtml(known(degree) ? DEGREE_SHORT[degree] : OUT_OF_NETWORK)}</span>`;
|
|
170
|
+
const line = known(degree) ? DEGREE_LINE[degree] : OUT_OF_NETWORK_WHY;
|
|
171
|
+
// The count is the length of the names beside it — never a total the
|
|
172
|
+
// response carried, which would disclose a filtered mutual by subtraction.
|
|
173
|
+
const mutual = mutuals.length
|
|
174
|
+
? `<p class="scroll__lede">${escapeHtml(plural(mutuals.length, 'connection'))} in common: `
|
|
175
|
+
+ `${mutuals.map((m) => escapeHtml(`@${m.handle || 'unknown'}`)).join(', ')}</p>`
|
|
176
|
+
: '';
|
|
177
|
+
return `<p class="conn-degree-line">${chip} ${escapeHtml(line)}</p>${mutual}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Which pending request, if any, already stands with this handle — read from
|
|
181
|
+
// the caller's OWN inbox and outbox, the only place the API says anything
|
|
182
|
+
// about one. An empty outbox is not evidence a request was refused: a re-ask
|
|
183
|
+
// inside the decline cooldown writes nothing (ADR 0207 L1), so absence means
|
|
184
|
+
// "nothing is pending" and the UI draws no further conclusion from it.
|
|
185
|
+
async function pendingWith(handle) {
|
|
186
|
+
const res = await api.request('GET', `${API}/connections/requests`);
|
|
187
|
+
if (!res.ok) return null;
|
|
188
|
+
const same = (r) => r && typeof r.handle === 'string' && r.handle.toLowerCase() === handle.toLowerCase();
|
|
189
|
+
const incoming = arr(res.data && res.data.incoming).find(same);
|
|
190
|
+
if (incoming) return { dir: 'incoming', id: incoming.id };
|
|
191
|
+
const outgoing = arr(res.data && res.data.outgoing).find(same);
|
|
192
|
+
return outgoing ? { dir: 'outgoing', id: outgoing.id } : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function actionHtml(degree, handle, pending) {
|
|
196
|
+
if (degree === 0) return ''; // your own account reached by ?id= — nothing to ask
|
|
197
|
+
if (degree === 1) return '<p class="conn-act"><span class="conn-state">Connected</span></p>';
|
|
198
|
+
if (pending && pending.dir === 'incoming') {
|
|
199
|
+
return '<p class="conn-act"><span class="conn-state">They have asked to connect.</span>'
|
|
200
|
+
+ `${btn('accept', pending.id, 'Accept')}${btn('decline', pending.id, 'Decline')}</p>`;
|
|
201
|
+
}
|
|
202
|
+
if (pending && pending.dir === 'outgoing') return SENT_HTML;
|
|
203
|
+
return `<p class="conn-act"><button class="btn-ghost" type="button" data-act="connect" data-handle="${escapeHtml(handle)}">Connect</button></p>`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The viewer-relative view. A 404 here is the deliberately indistinguishable
|
|
207
|
+
// set — unknown handle, provisional account, private stranger — and all of
|
|
208
|
+
// them mean the same thing to this page: there is nothing to offer, so the
|
|
209
|
+
// section stays hidden and no connect button is drawn.
|
|
210
|
+
async function renderOther(body, login) {
|
|
211
|
+
if (!login) return false;
|
|
212
|
+
const res = await api.request('GET', `${API}/connections/degree/${encodeURIComponent(login)}`);
|
|
213
|
+
if (res.status === 403) {
|
|
214
|
+
// About the VIEWER, not the target: their own account has not been
|
|
215
|
+
// activated at the hub yet, so they cannot take part at all.
|
|
216
|
+
body.innerHTML = '<p class="scroll__lede">Sign in to the platform once to activate your profile before connecting.</p>';
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
if (!res.ok) return false;
|
|
220
|
+
const data = res.data || {};
|
|
221
|
+
const handle = typeof data.handle === 'string' ? data.handle : null;
|
|
222
|
+
if (!handle) return false;
|
|
223
|
+
const mutuals = arr(data.mutuals);
|
|
224
|
+
body.innerHTML = degreeHtml(data.degree, mutuals) + actionHtml(data.degree, handle, await pendingWith(handle));
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- actions --------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
async function connect(handle) {
|
|
231
|
+
const res = await api.request('POST', `${API}/connections`, { body: { handle } });
|
|
232
|
+
// 201 { status: 'pending' } is byte-identical for a request that was written
|
|
233
|
+
// and one swallowed by the cooldown. "Request sent" claims exactly that.
|
|
234
|
+
if (res.status === 201) return { html: SENT_HTML, message: 'Request sent.' };
|
|
235
|
+
if (res.status === 409) return { message: 'There is already a request or a connection between you.' };
|
|
236
|
+
if (res.status === 403) return { message: 'Activate your platform profile before connecting.' };
|
|
237
|
+
// 404 is absent / provisional / private, indistinguishable on purpose.
|
|
238
|
+
if (res.status === 404) return { message: 'That builder cannot be connected with right now.' };
|
|
239
|
+
return { message: 'Could not send the request.' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ROW_ACTIONS = {
|
|
243
|
+
accept: { call: (id) => api.request('POST', `${API}/connections/${encodeURIComponent(id)}/accept`), message: 'Connected.' },
|
|
244
|
+
decline: { call: (id) => api.request('POST', `${API}/connections/${encodeURIComponent(id)}/decline`), message: 'Declined.' },
|
|
245
|
+
remove: { call: (id) => api.request('DELETE', `${API}/connections/${encodeURIComponent(id)}`), message: 'Connection removed.' },
|
|
246
|
+
withdraw: { call: (id) => api.request('DELETE', `${API}/connections/${encodeURIComponent(id)}`), message: 'Request withdrawn.' },
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
async function onClick(ev) {
|
|
250
|
+
const el = ev.target && ev.target.closest ? ev.target.closest('button[data-act]') : null;
|
|
251
|
+
if (!el) return;
|
|
252
|
+
const act = el.dataset.act;
|
|
253
|
+
el.disabled = true;
|
|
254
|
+
try {
|
|
255
|
+
if (act === 'connect') {
|
|
256
|
+
const out = await connect(el.dataset.handle);
|
|
257
|
+
// Replace only the action line: a 201 tells us nothing about the rest
|
|
258
|
+
// of the page, so nothing else is restated.
|
|
259
|
+
if (out.html) { const body = $('#conn-body'); if (body) body.innerHTML = body.innerHTML.replace(/<p class="conn-act">[\s\S]*?<\/p>/, out.html); }
|
|
260
|
+
toast(out.message);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const spec = ROW_ACTIONS[act];
|
|
264
|
+
if (!spec) return;
|
|
265
|
+
const res = await spec.call(el.dataset.id);
|
|
266
|
+
if (!res.ok && res.status !== 204) { toast('That request is no longer available.'); }
|
|
267
|
+
else toast(spec.message);
|
|
268
|
+
if (last) await render(last.data, last.selfId);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.error('[profile-connections] action failed', err);
|
|
271
|
+
toast('Something went wrong. Try again.');
|
|
272
|
+
} finally {
|
|
273
|
+
el.disabled = false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- entry ----------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
// Called by profile.js once the profile response has landed. Reveals the
|
|
280
|
+
// section only when the hub answered with something to show; any other
|
|
281
|
+
// outcome — module absent, signed out, target not addressable — leaves the
|
|
282
|
+
// markup's `hidden` in place.
|
|
283
|
+
async function render(data, selfId) {
|
|
284
|
+
const section = $('#conn-scroll');
|
|
285
|
+
const body = $('#conn-body');
|
|
286
|
+
if (!section || !body) return;
|
|
287
|
+
last = { data, selfId };
|
|
288
|
+
const builder = (data && data.builder) || {};
|
|
289
|
+
const isSelf = selfId != null && String(builder.id) === String(selfId);
|
|
290
|
+
try {
|
|
291
|
+
const shown = isSelf ? await renderOwn(body) : await renderOther(body, builder.github_login);
|
|
292
|
+
section.hidden = !shown;
|
|
293
|
+
if (shown && body.addEventListener && !body.dataset.connWired) {
|
|
294
|
+
body.dataset.connWired = '1';
|
|
295
|
+
body.addEventListener('click', onClick);
|
|
296
|
+
}
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.error('[profile-connections] failed to load', err);
|
|
299
|
+
section.hidden = true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
window.OTBConnections = { render };
|
|
304
|
+
})();
|
|
@@ -107,9 +107,20 @@
|
|
|
107
107
|
</div>
|
|
108
108
|
</section>
|
|
109
109
|
|
|
110
|
+
<!-- Connections — section 6 of the profile-page spec (R20, task
|
|
111
|
+
1002296; ADR 0202). Hub-only, like the cross-project record:
|
|
112
|
+
profile-connections.js reveals it only when the hub's connections
|
|
113
|
+
API answers, so on an instance without platform-identity the
|
|
114
|
+
routes are absent and the section never renders. -->
|
|
115
|
+
<section class="scroll" id="conn-scroll" aria-labelledby="conn-h" hidden>
|
|
116
|
+
<h2 id="conn-h" class="scroll__h">Connections</h2>
|
|
117
|
+
<div id="conn-body"></div>
|
|
118
|
+
</section>
|
|
119
|
+
|
|
110
120
|
<!-- Reserved slots — connections, guilds, and actions (profile-page
|
|
111
121
|
spec sections 6–8) mount here in later phases; keeping the order
|
|
112
|
-
fixed now means the page is not re-laid-out twice (Decision 5).
|
|
122
|
+
fixed now means the page is not re-laid-out twice (Decision 5).
|
|
123
|
+
Section 6 landed above (task 1002296); 7 and 8 are still to come. -->
|
|
113
124
|
</main>
|
|
114
125
|
|
|
115
126
|
<footer class="app-foot" id="app-foot"></footer>
|
|
@@ -130,6 +141,10 @@
|
|
|
130
141
|
<!-- hall-kit (window.OTBKit) — profile.js renders ship-time task visuals through
|
|
131
142
|
its shared thumbnail helper (task 1003109), so it must load first. -->
|
|
132
143
|
<script src="/builders/hall-kit.js?v=2026-08-27-activity"></script>
|
|
144
|
+
<!-- Connections (section 6). Loaded BEFORE profile.js so window.OTBConnections
|
|
145
|
+
exists by the time renderProfile runs; profile.js guards on it anyway, so a
|
|
146
|
+
page that omits this script simply has no connections section. -->
|
|
147
|
+
<script src="/builders/profile-connections.js?v=2026-08-29-r20"></script>
|
|
133
148
|
<script src="/builders/profile.js?v=2026-08-27-activity"></script>
|
|
134
149
|
</body>
|
|
135
150
|
</html>
|
|
@@ -534,6 +534,10 @@
|
|
|
534
534
|
// The activity log needs a session (private goals + the idea trail), so it
|
|
535
535
|
// keys off whether /me resolved — the same signal the self-detect uses.
|
|
536
536
|
renderActivity(builder.id, selfId != null);
|
|
537
|
+
// Connections (section 6, task 1002296) — a separate hub-only script that
|
|
538
|
+
// reveals its own section or leaves it hidden. Optional by design: a page
|
|
539
|
+
// that does not load it simply has no connections section.
|
|
540
|
+
if (window.OTBConnections) window.OTBConnections.render(data, selfId);
|
|
537
541
|
}
|
|
538
542
|
|
|
539
543
|
// Render the friendly "no builder chosen" empty state for a signed-out visitor
|
|
@@ -557,6 +561,7 @@
|
|
|
557
561
|
const worksLede = $('#works-lede'); if (worksLede) worksLede.hidden = true;
|
|
558
562
|
const xp = $('#xp-scroll'); if (xp) xp.hidden = true;
|
|
559
563
|
const act = $('#activity-scroll'); if (act) act.hidden = true;
|
|
564
|
+
const conn = $('#conn-scroll'); if (conn) conn.hidden = true;
|
|
560
565
|
}
|
|
561
566
|
|
|
562
567
|
async function fetchProfile(id, selfId) {
|
|
@@ -1107,6 +1107,44 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
1107
1107
|
|
|
1108
1108
|
.disc-pref { margin-right: 4px; }
|
|
1109
1109
|
|
|
1110
|
+
/* ---------- connections (profile section 6, task 1002296) ---------- */
|
|
1111
|
+
|
|
1112
|
+
.conn-person { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
|
|
1113
|
+
.conn-person__avatar {
|
|
1114
|
+
width: 22px; height: 22px; border-radius: 50%; flex: none;
|
|
1115
|
+
background: var(--surface-2); border: 1px solid var(--border); object-fit: cover;
|
|
1116
|
+
}
|
|
1117
|
+
.conn-person__name { font-weight: 500; }
|
|
1118
|
+
.conn-person__handle { color: var(--ink-faint); font-size: 12.5px; }
|
|
1119
|
+
|
|
1120
|
+
/* The degree chip. `--out` is the null answer — deliberately the quietest of
|
|
1121
|
+
the four, because "out of network" is an absence of knowledge, not a rank. */
|
|
1122
|
+
/* nowrap + flex:none — "Out of network" is the longest label and must stay one
|
|
1123
|
+
pill; wrapped, it reads as two chips. */
|
|
1124
|
+
.conn-degree {
|
|
1125
|
+
display: inline-block; flex: none; white-space: nowrap;
|
|
1126
|
+
padding: 1px 7px; border-radius: 999px;
|
|
1127
|
+
font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em;
|
|
1128
|
+
border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--ink-soft);
|
|
1129
|
+
}
|
|
1130
|
+
.conn-degree--1 { background: var(--ok-weak); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 30%, transparent); }
|
|
1131
|
+
.conn-degree--2 { background: var(--info-weak); color: var(--info); border-color: color-mix(in srgb, var(--info) 30%, transparent); }
|
|
1132
|
+
.conn-degree--3 { background: var(--surface-2); color: var(--ink-soft); }
|
|
1133
|
+
.conn-degree--out { background: none; color: var(--ink-faint); border-style: dashed; }
|
|
1134
|
+
|
|
1135
|
+
.conn-degree-line { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin: 0 0 6px; font-size: 13.5px; color: var(--ink-soft); }
|
|
1136
|
+
.conn-act { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 10px 0 0; }
|
|
1137
|
+
.conn-state { font-size: 13px; color: var(--ink-faint); }
|
|
1138
|
+
.conn-sub { margin: 14px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-faint); }
|
|
1139
|
+
.conn-reqs { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
|
1140
|
+
.conn-req {
|
|
1141
|
+
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
1142
|
+
padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-1);
|
|
1143
|
+
}
|
|
1144
|
+
.conn-req__note { flex: 1 1 100%; margin: 0; font-size: 13px; color: var(--ink-soft); }
|
|
1145
|
+
.conn-req__state { font-size: 13px; color: var(--ink-faint); }
|
|
1146
|
+
.conn-req__acts { margin-left: auto; display: flex; gap: 6px; }
|
|
1147
|
+
|
|
1110
1148
|
@media (max-width: 640px) {
|
|
1111
1149
|
.profile { grid-template-columns: 48px minmax(0, 1fr); }
|
|
1112
1150
|
.profile__avatar { width: 48px; height: 48px; }
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.427",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.427",
|
|
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.
|
|
3
|
+
"version": "1.19.427",
|
|
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",
|
|
@@ -116,6 +116,9 @@ const FIXTURE_FALLBACKS = [
|
|
|
116
116
|
// craft and the page window are query string, which fixture matching ignores.
|
|
117
117
|
[/^builders\/\d+\/activity$/, (q) => (q.section === 'goals' ? 'builders__activity-goals' : 'builders__activity-tasks')],
|
|
118
118
|
[/^inbox\/by-builder\/\d+$/, 'inbox__by-builder'],
|
|
119
|
+
// the profile's connections section (task 1002296) — one degree fixture serves
|
|
120
|
+
// any handle, since the handle is the address and the label is the payload.
|
|
121
|
+
[/^connections\/degree\/[^/]+$/, 'connections__degree'],
|
|
119
122
|
];
|
|
120
123
|
|
|
121
124
|
function loadFixture(name) {
|
package/src/module-api.js
CHANGED
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.427'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
37
37
|
|
|
38
38
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
39
39
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|