@bongos/core 1.19.517 → 1.19.518

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.
@@ -1471,5 +1471,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1471
1471
  landed since 1.19.515 with no explicit bump. run 33798954769. (task 1002620)
1472
1472
  1.19.517 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1473
1473
  landed since 1.19.516 with no explicit bump. run 33799755382. (task 1002620)
1474
+ 1.19.518 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1475
+ landed since 1.19.517 with no explicit bump. run 33801792364. (task 1002620)
1474
1476
  ---------------------------------------------------------------------------
1475
1477
  ```
@@ -191,7 +191,33 @@ function boardVotesNeed(ctx) {
191
191
  };
192
192
  }
193
193
 
194
- const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed];
194
+ // task 1003178 (C08, goal 1000075): an open mingle INTRODUCTION waiting on you.
195
+ // Third sibling of the two collab needs above, here for the same reason and with
196
+ // the same posture — an introduction nobody surfaces is merely stored, and the
197
+ // needs roster is how a collab feature reaches the whole hall instead of sitting
198
+ // on a page of its own (which is the thing C08's criterion refuses).
199
+ //
200
+ // 'info' and NEVER a demand, and the wording carries that: mingle is declinable
201
+ // without penalty (C07, ADR 0187 §5), so copy that pressured the reader would BE
202
+ // the penalty the decision forbids. The same argument the recommendation need
203
+ // makes, applied to a social invitation, where it matters more.
204
+ function mingleIntroductionsNeed(ctx) {
205
+ const n = Number(ctx.openMingleIntroductions) || 0;
206
+ if (n <= 0) return null;
207
+ const subject = n === 1 ? 'An introduction is' : `${n} introductions are`;
208
+ return {
209
+ id: 'mingle_introductions',
210
+ title: n === 1 ? 'Someone from another craft was introduced to you' : 'You have introductions waiting',
211
+ what: 'Mingle pairs builders across crafts — you opted in, and a pairing is waiting on your answer.',
212
+ state: 'action_needed',
213
+ severity: 'info',
214
+ message: `${subject} waiting on you. Saying no costs nothing at all — no rank, no karma, no streak, `
215
+ + 'and it does not change how often you are paired again.',
216
+ action: { label: n === 1 ? 'See the introduction' : 'See the introductions', href: '/#/collab' },
217
+ };
218
+ }
219
+
220
+ const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed];
195
221
 
196
222
  // ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
197
223
  //
@@ -269,7 +295,7 @@ module.exports = {
269
295
  computeNeeds,
270
296
  // exported for tests / direct reuse
271
297
  artKeyNeed,
272
- helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed,
298
+ helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed,
273
299
  foundingGraceActive,
274
300
  FOUNDING_GRACE_DAYS,
275
301
  SETTINGS_PATH,
@@ -46,6 +46,25 @@
46
46
  </div>
47
47
  </section>
48
48
 
49
+ <!-- SOCIAL MODE ONLY (task 1003178 / C08). Not a page and not a nav item:
50
+ this panel is part of Collab, and it appears because the hall is in
51
+ social mode — the shell stamps data-social="on" on the document root
52
+ from /me, and collab.js reveals this section from that. With social
53
+ off the page is exactly what it was, which is the point: the hall
54
+ changes, it does not gain a destination. -->
55
+ <section class="scroll" id="cb-mingle-section" aria-labelledby="cb-mingle-h" hidden>
56
+ <h2 class="scroll__h" id="cb-mingle-h">Introductions</h2>
57
+ <p class="scroll__lede" id="cb-mingle-note">
58
+ Mingle pairs you with someone from a different craft. Saying no costs nothing
59
+ at all — no rank, no karma, no streak, and it does not change how often you
60
+ are paired again.
61
+ </p>
62
+ <ul class="ov-rows" id="cb-mingle"></ul>
63
+ <div class="empty-state" id="cb-mingle-empty" hidden>
64
+ <p>No introductions right now. You will see one here when a pairing round runs.</p>
65
+ </div>
66
+ </section>
67
+
49
68
  <section class="scroll" aria-labelledby="cb-mine-h">
50
69
  <h2 class="scroll__h" id="cb-mine-h">Your asks</h2>
51
70
  <p class="scroll__lede">
@@ -143,6 +143,78 @@
143
143
  ? `Matching your craft: ${crafts.join(', ')}.`
144
144
  : 'You have not set a craft, so only requests addressed to you by name reach you.';
145
145
  }
146
+
147
+ // The social half. Best-effort and LAST: an introductions read that fails
148
+ // must never take the help requests down with it — those are the page.
149
+ try { await loadMingle(); } catch (_) { /* the panel stays as it was */ }
150
+ }
151
+
152
+ // ---- social mode: introductions (task 1003178 / C08) ----------------------
153
+ //
154
+ // READ THE MODE OFF THE DOCUMENT, never re-derive it. The shell stamps
155
+ // data-social="on" from /me's resolved pair of switches (C07: the project
156
+ // enabled mingle AND this builder opted in). Asking the server again here would
157
+ // be a second answer to one question, and a page that decided for itself
158
+ // whether social was on could disagree with the rest of the hall.
159
+ const socialOn = () => document.documentElement.dataset.social === 'on';
160
+
161
+ // The other person in the pair — the row is written from the VIEWER's side, so
162
+ // it names who they were introduced to rather than echoing both ids back.
163
+ function otherOf(pair, meId) {
164
+ const lo = String(pair.builder_lo), hi = String(pair.builder_hi);
165
+ return String(meId) === lo
166
+ ? { id: hi, login: pair.hi_login, name: pair.hi_name, craft: pair.craft_hi, myCraft: pair.craft_lo }
167
+ : { id: lo, login: pair.lo_login, name: pair.lo_name, craft: pair.craft_lo, myCraft: pair.craft_hi };
168
+ }
169
+
170
+ function mingleRow(pair, meId) {
171
+ const o = otherOf(pair, meId);
172
+ const who = escapeHtml(o.name || o.login || `builder ${o.id}`);
173
+ return `<li class="ov-row">
174
+ <div class="ov-row__main">
175
+ <a class="ov-row__title" href="/builders/profile?id=${escapeHtml(String(o.id))}">${who}</a>
176
+ <p class="ov-row__sub">A ${escapeHtml(String(o.craft))} — you are a ${escapeHtml(String(o.myCraft))}. Mingle pairs across crafts.</p>
177
+ </div>
178
+ <div class="ov-row__right">
179
+ <div class="ov-row__actions">
180
+ <button class="btn-accent" data-mingle="accepted" data-id="${escapeHtml(String(pair.id))}">Say yes</button>
181
+ <button class="btn-ghost" data-mingle="declined" data-id="${escapeHtml(String(pair.id))}">Not now</button>
182
+ </div>
183
+ </div>
184
+ </li>`;
185
+ }
186
+
187
+ async function loadMingle() {
188
+ const section = $('#cb-mingle-section');
189
+ if (!section) return;
190
+ // Off is off: the section stays hidden and nothing is fetched. A page that
191
+ // fetched anyway would make "social is off" cost a request on every load.
192
+ if (!socialOn()) { section.hidden = true; return; }
193
+ section.hidden = false;
194
+
195
+ const r = await api.request('GET', '/mingle');
196
+ const pairs = (r.ok && r.data && r.data.open_pairs) || [];
197
+ const meId = (r.ok && r.data && r.data.you) || null;
198
+ paint('#cb-mingle', '#cb-mingle-empty', pairs.map((p) => mingleRow(p, meId)).join(''), pairs.length);
199
+ }
200
+
201
+ async function decideMingle(id, decision) {
202
+ if (settling) return;
203
+ settling = true;
204
+ try {
205
+ const r = await api.request('POST', `/mingle/pairs/${id}/decide`, { decision });
206
+ if (!r.ok) {
207
+ toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not answer that introduction.');
208
+ return;
209
+ }
210
+ // Neutral either way. "Declined" must not read as a failure — declining is
211
+ // free, and a toast that sounded disappointed would be the penalty C07's
212
+ // decision forbids, delivered by the UI instead of the schema.
213
+ toast(decision === 'accepted' ? 'Said yes — say hello.' : 'Not now. Nothing changes for you.');
214
+ await load();
215
+ } finally {
216
+ settling = false;
217
+ }
146
218
  }
147
219
 
148
220
  async function settle(id, status) {
@@ -162,6 +234,12 @@
162
234
  }
163
235
 
164
236
  document.addEventListener('click', (ev) => {
237
+ const mingleBtn = ev.target && ev.target.closest ? ev.target.closest('[data-mingle]') : null;
238
+ if (mingleBtn) {
239
+ ev.preventDefault();
240
+ decideMingle(Number(mingleBtn.getAttribute('data-id')), mingleBtn.getAttribute('data-mingle'));
241
+ return;
242
+ }
165
243
  const btn = ev.target && ev.target.closest ? ev.target.closest('[data-settle]') : null;
166
244
  if (!btn) return;
167
245
  ev.preventDefault();
@@ -205,6 +205,12 @@
205
205
  rank: normalizeRank(builder?.rank),
206
206
  graduated,
207
207
  showWork: shipStepCurrent,
208
+ // task 1003178 (C08): the index owns its own /me, so it passes the social
209
+ // mode through too — otherwise the hall's busiest page would be the one
210
+ // page the mode never reached. Read off ctx.lastMeState (the live getter),
211
+ // not a local: renderNav takes (builder, onboarding) and is called from
212
+ // five sites, so there is no `me` in scope here.
213
+ social: (ctx.lastMeState && ctx.lastMeState.social) || null,
208
214
  builder,
209
215
  });
210
216
 
@@ -522,7 +522,32 @@
522
522
  }
523
523
  }
524
524
 
525
- // applyAccess({ authed, rank, graduated, showWork }) — settle nav visibility.
525
+ // SOCIAL MODE (task 1003178 / C08, goal 1000075). The owner's directive is that
526
+ // social is "scoped into the ENTIRE builders hall, not a single social tab", and
527
+ // the criterion says outright that a /social route with the same hall around it
528
+ // does NOT satisfy it. So social is a MODE stamped on the document root, beside
529
+ // data-theme and for the same reason: every hall page loads this shell, so one
530
+ // attribute lets any surface — a stylesheet, a page script, a widget — read the
531
+ // mode without a route, a nav item, or a second fetch.
532
+ //
533
+ // OFF is the default and the fallback. `/me.social` is null when lifecycle is
534
+ // disabled or the read failed, and null is OFF: a mode that cannot be read is
535
+ // not on. It is only ON when BOTH of C07's switches are (the project enabled
536
+ // mingle AND this builder opted in) — the server resolves that pair, this never
537
+ // re-derives it.
538
+ //
539
+ // Written as an attribute rather than kept in `access` on purpose: CSS can hit
540
+ // it (`:root[data-social="on"] …`), which is what lets a surface change how it
541
+ // READS without every page script learning about social.
542
+ function applySocial(social) {
543
+ var on = !!(social && social.enabled === true);
544
+ var root = document.documentElement;
545
+ if (on) root.dataset.social = 'on';
546
+ else delete root.dataset.social;
547
+ }
548
+
549
+ // applyAccess({ authed, rank, graduated, showWork, social }) — settle nav
550
+ // visibility and the social mode.
526
551
  // Called by builders.js on the index (it owns /me there) and by the shell's own
527
552
  // probe elsewhere. Cosmetic only; the server enforces (ADR 0016).
528
553
  function applyAccess(state) {
@@ -531,6 +556,7 @@
531
556
  if ('rank' in state) access.rank = state.rank ? String(state.rank).toLowerCase() : null;
532
557
  if ('graduated' in state) access.graduated = !!state.graduated;
533
558
  if ('showWork' in state) access.showWork = !!state.showWork;
559
+ if ('social' in state) applySocial(state.social);
534
560
  if (state.builder) renderUser(state.builder);
535
561
  }
536
562
  document.querySelectorAll('#app-sidebar [data-gate]').forEach(function (el) {
@@ -744,6 +770,9 @@
744
770
  rank: me.builder.rank,
745
771
  graduated: !!(me.onboarding && me.onboarding.graduated),
746
772
  showWork: showWork,
773
+ // task 1003178 (C08): the mode rides the identity every page already
774
+ // fetches, so no surface needs a route or a second call to know.
775
+ social: me.social || null,
747
776
  builder: me.builder,
748
777
  });
749
778
  }).catch(function () { /* cosmetic only */ });
@@ -22,6 +22,7 @@ const goalSuggest = require('./goal-suggest');
22
22
  const helpRequests = require('./help-requests');
23
23
  const taskRecommendations = require('./task-recommendations');
24
24
  const activity = require('./activity');
25
+ const mingle = require('./mingle');
25
26
 
26
27
  module.exports = {
27
28
  // --- retired doorway leaks (ADR 0093 §3) ---------------------------------
@@ -52,6 +53,13 @@ module.exports = {
52
53
  // builder, exposed the same way and for the same reason — a recommendation
53
54
  // nobody surfaces is merely stored.
54
55
  openTaskRecommendationsFor: (...a) => taskRecommendations.countOpenForRecipient(...a),
56
+ // task 1003178 (C08): social MODE, and the count of open introductions. The
57
+ // mode is the pair of switches C07 resolved (project enabled AND this builder
58
+ // opted in) — the kernel's /me carries it so every hall surface can read the
59
+ // mode WITHOUT importing this module, which is what makes social a mode rather
60
+ // than a page. The count feeds the needs roster, the same slot C01 and C03 use.
61
+ socialModeFor: (...a) => mingle.mingleStateFor(...a),
62
+ openMingleIntroductionsFor: (...a) => mingle.countOpenPairsFor(...a),
55
63
  getTask: (...a) => db.getTask(...a),
56
64
  // findTaskBySourceRef: the create-vector idempotency read (task 1003114 / R03
57
65
  // of goal 1000074). copy-desk turns an artist's copy proposal into a task and
@@ -306,6 +306,22 @@ async function openPairsFor(builderId, deps = {}) {
306
306
  return rows;
307
307
  }
308
308
 
309
+ // How many open introductions are waiting on this builder — the count the hall's
310
+ // needs roster renders (task 1003178 / C08). A COUNT rather than the rows,
311
+ // because /me is the session-open hot path and the roster only needs a number;
312
+ // the rows are `openPairsFor`, read by the surface that shows them.
313
+ async function countOpenPairsFor(builderId, deps = {}) {
314
+ const activePool = deps.pool || pool;
315
+ const { rows } = await activePool.query(
316
+ `SELECT count(*)::int AS n
317
+ FROM lifecycle_mingle_pairs
318
+ WHERE status = 'proposed'
319
+ AND (builder_lo = $1 OR builder_hi = $1)`,
320
+ [builderId]
321
+ );
322
+ return rows[0] ? rows[0].n : 0;
323
+ }
324
+
309
325
  // Accept or decline an introduction you are part of.
310
326
  //
311
327
  // ONE ROW, ONE TABLE. There is nothing else to write, and that absence IS the
@@ -333,5 +349,5 @@ module.exports = {
333
349
  parseEnabled, projectEnabled, setProjectEnabled,
334
350
  optInState, setOptIn, mingleStateFor,
335
351
  eligibleBuilders, isCrossCraft, planPairs, pairKey, openPairKeys,
336
- runMingleRound, openPairsFor, decidePair,
352
+ runMingleRound, openPairsFor, countOpenPairsFor, decidePair,
337
353
  };
@@ -55,6 +55,11 @@ module.exports = function buildMingleRouter() {
55
55
  return res.json({
56
56
  mingle: state,
57
57
  open_pairs: pairs,
58
+ // task 1003178 (C08): WHICH SIDE OF THE PAIR THE CALLER IS. A pair row
59
+ // carries builder_lo/builder_hi with no marker for "you", so a surface
60
+ // rendering it from the viewer's side would otherwise need a second /me
61
+ // fetch just to find out which id to name. The route already knows.
62
+ you: String(req.builder.id),
58
63
  // Served VERBATIM from the module. The criterion asks for cross-craft to be
59
64
  // chosen deliberately and RECORDED; this is where it is read, and a
60
65
  // restatement here could drift from the matcher that actually runs.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.517",
3
+ "version": "1.19.518",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.517",
9
+ "version": "1.19.518",
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.517",
3
+ "version": "1.19.518",
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",
@@ -105,7 +105,7 @@ module.exports = function buildMeRouter() {
105
105
  builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
106
106
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
107
107
  behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
108
- taskRecommendationCount, boardVotesPending,
108
+ taskRecommendationCount, boardVotesPending, socialMode, mingleIntroductionCount,
109
109
  ] = await Promise.all([
110
110
  db.getBuilderById(req.builder.id),
111
111
  lc().getActiveClaimsForBuilder(req.builder.id),
@@ -205,6 +205,29 @@ module.exports = function buildMeRouter() {
205
205
  return await government.pendingBoardVotesFor(req.builder.id);
206
206
  } catch (_) { return null; }
207
207
  })(),
208
+ // task 1003178 (C08): SOCIAL MODE — the pair of switches C07 resolved
209
+ // (the project enabled mingle AND this builder opted in). It rides /me
210
+ // because the criterion's whole point is that social changes the HALL
211
+ // rather than adding a destination: every page already loads /me, so the
212
+ // shell can stamp the mode on the document and any surface can read it.
213
+ // Same fan-out slot and FAIL-OPEN posture as the reads above — null, not a
214
+ // throw, and the shell treats null as OFF (a mode that cannot be read is
215
+ // not on).
216
+ (async () => {
217
+ try {
218
+ const lifecycle = lc();
219
+ if (!lifecycle || !lifecycle.socialModeFor) return null;
220
+ return await lifecycle.socialModeFor(req.builder.id);
221
+ } catch (_) { return null; }
222
+ })(),
223
+ // The introductions waiting on this builder, for the needs roster.
224
+ (async () => {
225
+ try {
226
+ const lifecycle = lc();
227
+ if (!lifecycle || !lifecycle.openMingleIntroductionsFor) return 0;
228
+ return await lifecycle.openMingleIntroductionsFor(req.builder.id);
229
+ } catch (_) { return 0; }
230
+ })(),
208
231
  ]);
209
232
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
210
233
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
@@ -257,6 +280,19 @@ module.exports = function buildMeRouter() {
257
280
  // Resolved by economy (never re-derived); null if economy is disabled.
258
281
  reward: rewardPolicySafe(),
259
282
  est_vs_actual: estVsActual,
283
+ // task 1003178 (C08, goal 1000075): SOCIAL MODE. The owner's directive is
284
+ // that social is "scoped into the ENTIRE builders hall, not a single social
285
+ // tab", and this key is the mechanism: every hall page already loads /me,
286
+ // so the shell stamps the mode on the document and any surface reads it
287
+ // from there. A /social ROUTE is what the criterion explicitly does not
288
+ // accept, so there is deliberately no route to add — the mode travels with
289
+ // the identity the whole hall is already fetching.
290
+ //
291
+ // Shape: { enabled, project_enabled, opted_in, opted_in_at }, both switches
292
+ // reported separately so a surface can say WHICH half is off (C07). null
293
+ // when lifecycle is disabled or the read failed, and the shell treats null
294
+ // as OFF — a mode that cannot be read is not on.
295
+ social: socialMode,
260
296
  unlocked,
261
297
  locked_with_progress: lockedWithProgress,
262
298
  streak: {
@@ -354,6 +390,8 @@ module.exports = function buildMeRouter() {
354
390
  // this builder, read through the same `lifecycle` port so core never
355
391
  // imports the module (ADR 0093).
356
392
  openTaskRecommendations: taskRecommendationCount,
393
+ // task 1003178 (C08): open mingle introductions waiting on this builder.
394
+ openMingleIntroductions: mingleIntroductionCount,
357
395
  }) : [],
358
396
  };
359
397
  // Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.517'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.518'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/mingle.mjs CHANGED
@@ -283,6 +283,32 @@ test('a declined pair may be introduced again — a decline is not a permanent s
283
283
  assert.equal(mingle.planPairs(eligible, { excludePairs: new Set() }).length, 1);
284
284
  });
285
285
 
286
+ test('the roster COUNT sees both sides of a pair, and only open ones', async () => {
287
+ // task 1003178 (C08) added countOpenPairsFor for the needs roster, and the
288
+ // grader was right that it shipped untested while every other DB function here
289
+ // is driven against the recording pool. A count is exactly the kind of query
290
+ // that goes wrong quietly: miss a side of the pair and half the hall never
291
+ // learns it has an introduction waiting.
292
+ const pool = {
293
+ queries: [],
294
+ async query(sql, params) {
295
+ this.queries.push({ sql, params });
296
+ return { rows: [{ n: 3 }] };
297
+ },
298
+ };
299
+ const n = await mingle.countOpenPairsFor(42, { pool });
300
+ assert.equal(n, 3);
301
+ const { sql, params } = pool.queries[0];
302
+ assert.match(sql, /builder_lo = \$1 OR builder_hi = \$1/, 'either side of the pair counts');
303
+ assert.match(sql, /status = 'proposed'/, 'a settled introduction is not waiting on anyone');
304
+ assert.deepEqual(params, [42]);
305
+ });
306
+
307
+ test('the count answers 0 rather than undefined on an empty read', async () => {
308
+ const pool = { async query() { return { rows: [] }; } };
309
+ assert.equal(await mingle.countOpenPairsFor(42, { pool }), 0);
310
+ });
311
+
286
312
  test('the exclude set is built from OPEN pairs only, never from declines', async () => {
287
313
  const pool = recordingPool({ openPairs: [['1', '2']] });
288
314
  await mingle.openPairKeys({ pool });
@@ -0,0 +1,181 @@
1
+ // tests/social_mode.mjs — with social on, the HALL changes; it does not gain a
2
+ // tab (task 1003178 / C08, goal 1000075; criterion collab-hall-becomes-a-mode).
3
+ //
4
+ // The criterion is unusual in that it states its own DISQUALIFIER: "a /social
5
+ // route with the same hall around it does NOT satisfy it". So the first block
6
+ // below tests the absence — no route, no nav item, no page file — and the rest
7
+ // tests that existing surfaces read differently instead.
8
+ //
9
+ // The owner's directive, verbatim in the goal: social "is certainly something
10
+ // that should be scoped into the ENTIRE builders hall, not a single social tab".
11
+ import { strict as assert } from 'node:assert';
12
+ import { test } from 'node:test';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
21
+
22
+ const SHELL = read('modules', 'hall-ui', 'public', 'shell.js');
23
+ const HALL_RENDER = read('modules', 'hall-ui', 'public', 'hall-render.js');
24
+ const COLLAB_JS = read('modules', 'hall-ui', 'public', 'collab.js');
25
+ const COLLAB_HTML = read('modules', 'hall-ui', 'public', 'collab.html');
26
+ const ME = read('src', 'bongos', 'routes', 'me.js');
27
+ const NEEDS = read('modules', 'builder-settings', 'builder-needs.js');
28
+ const needs = require(path.join(ROOT, 'modules', 'builder-settings', 'builder-needs.js'));
29
+
30
+ // ── 1. THE DISQUALIFIER: social is not a destination ────────────────────────
31
+
32
+ test('THE DISQUALIFIER: there is no /social page in the hall', () => {
33
+ const hall = fs.readdirSync(path.join(ROOT, 'modules', 'hall-ui', 'public'));
34
+ for (const f of hall) {
35
+ assert.ok(!/^social\.(html|js|css)$/.test(f),
36
+ `${f} exists — a social PAGE is exactly what this criterion refuses`);
37
+ }
38
+ });
39
+
40
+ test('THE DISQUALIFIER: no /social route is served, and no page gate names one', () => {
41
+ const serve = read('src', 'bongos', 'serve-internal.js');
42
+ assert.ok(!/\/social\b/.test(serve), 'serve-internal routes nothing at /social');
43
+ const oracle = read('tests', 'hall_page_gate_map.mjs');
44
+ assert.ok(!/'\/builders\/social'/.test(oracle), 'the page-gate oracle has no /social row');
45
+ });
46
+
47
+ test('THE DISQUALIFIER: social adds no nav item', () => {
48
+ // A nav entry would BE the tab. The whole mechanism is an attribute on the
49
+ // document, so there is nothing to link to.
50
+ //
51
+ // The end marker is NAV_COLLAPSE_KEY, which follows the model. An earlier
52
+ // draft sliced to `function icon(` — which is DEFINED ABOVE NAV, so the slice
53
+ // returned an empty string and the assertion passed over nothing. It only
54
+ // surfaced because adding a real Social tab as a mutation did not red it.
55
+ const start = SHELL.indexOf('var NAV = [');
56
+ const end = SHELL.indexOf('var NAV_COLLAPSE_KEY');
57
+ assert.ok(start > -1 && end > start, 'the NAV model was actually located');
58
+ const nav = SHELL.slice(start, end);
59
+ assert.ok(nav.includes("id: 'roster'"), 'and the slice really holds the nav items');
60
+ assert.ok(!/social/i.test(nav), 'the nav model must not mention social');
61
+ });
62
+
63
+ // ── 2. SOCIAL IS A MODE, STAMPED ON THE DOCUMENT ────────────────────────────
64
+
65
+ test('the shell stamps the mode on the document root, beside data-theme', () => {
66
+ // An attribute on <html> is what lets ANY surface — a stylesheet, a page
67
+ // script, a widget — read the mode without a route or a second fetch. That is
68
+ // the structural difference between a mode and a page.
69
+ assert.match(SHELL, /function applySocial\(social\)/);
70
+ assert.match(SHELL, /root\.dataset\.social = 'on'/);
71
+ assert.match(SHELL, /delete root\.dataset\.social/);
72
+ });
73
+
74
+ test('OFF is the default and the fallback — null is not on', () => {
75
+ // /me.social is null when lifecycle is disabled or the read failed. A mode
76
+ // that cannot be read is not on.
77
+ const fn = SHELL.slice(SHELL.indexOf('function applySocial'));
78
+ const body = fn.slice(0, fn.indexOf('\n }'));
79
+ assert.match(body, /social && social\.enabled === true/,
80
+ 'only an explicit enabled:true turns the mode on');
81
+ });
82
+
83
+ test('BOTH switches decide it, and the client never re-derives them', () => {
84
+ // C07 resolved the pair (project enabled AND builder opted in) server-side.
85
+ // A client that recomputed the mode could disagree with the rest of the hall.
86
+ assert.ok(!/opted_in|project_enabled/.test(SHELL),
87
+ 'the shell reads `enabled`, never the two switches it came from');
88
+ assert.match(ME, /socialModeFor/);
89
+ assert.match(ME, /social: socialMode/);
90
+ });
91
+
92
+ test('the mode rides /me, which every hall page already fetches', () => {
93
+ // This is why no new route is needed — and why one would be the wrong shape.
94
+ assert.match(SHELL, /social: me\.social \|\| null/);
95
+ assert.match(HALL_RENDER, /social: \(ctx\.lastMeState && ctx\.lastMeState\.social\) \|\| null/);
96
+ });
97
+
98
+ test('the index passes the mode too — the busiest page is not the one it misses', () => {
99
+ // renderNav takes (builder, onboarding) and is called from five sites, so the
100
+ // value must come off the live ctx getter rather than a local `me`.
101
+ const fn = HALL_RENDER.slice(HALL_RENDER.indexOf('function renderNav('));
102
+ const body = fn.slice(0, fn.indexOf('\n }'));
103
+ assert.ok(!/\bme\?\./.test(body), 'renderNav has no `me` in scope — reading one would throw');
104
+ });
105
+
106
+ test('a social read can never break a profile', () => {
107
+ // /me is the session-open hot path. Same fail-open posture as the C01/C03
108
+ // reads it sits beside.
109
+ const slot = ME.slice(ME.indexOf('socialModeFor') - 600, ME.indexOf('socialModeFor') + 200);
110
+ assert.match(slot, /catch \(_\) \{ return null; \}/);
111
+ });
112
+
113
+ // ── 3. EXISTING SURFACES READ DIFFERENTLY ───────────────────────────────────
114
+
115
+ test('HOME changes: an introduction becomes a need in the roster', () => {
116
+ // The needs roster is how a collab feature reaches the whole hall — it is
117
+ // where C01 and C03 already live, and it renders on Home's Standing card and
118
+ // in the CLI nudge without either learning about mingle.
119
+ const roster = needs.computeNeeds({ openMingleIntroductions: 2 }).items;
120
+ const row = roster.find((n) => n.id === 'mingle_introductions');
121
+ assert.ok(row, 'the need is produced');
122
+ assert.equal(row.state, 'action_needed');
123
+ assert.match(row.action.href, /\/#\/collab/, 'it points at an EXISTING surface, not a new one');
124
+ });
125
+
126
+ test('no introductions means no row — the roster is not padded', () => {
127
+ assert.equal(needs.computeNeeds({ openMingleIntroductions: 0 }).items.find((n) => n.id === 'mingle_introductions'), undefined);
128
+ assert.equal(needs.computeNeeds({}).items.find((n) => n.id === 'mingle_introductions'), undefined);
129
+ });
130
+
131
+ test('the invitation never pressures — declining is free, so the copy says so', () => {
132
+ // Mingle is declinable without penalty (C07, ADR 0187 §5). Wording that
133
+ // pressured the reader would BE the penalty the decision forbids — the same
134
+ // argument the recommendation need makes, and it matters more for a social
135
+ // invitation than for a task.
136
+ const row = needs.computeNeeds({ openMingleIntroductions: 1 }).items;
137
+ const n = row.find((x) => x.id === 'mingle_introductions');
138
+ assert.equal(n.severity, 'info', 'info, never a demand');
139
+ assert.match(n.message, /costs nothing/);
140
+ assert.match(n.message, /does not change how often you are paired/,
141
+ 'and it names the no-future-weight half, which is the part a reader would doubt');
142
+ });
143
+
144
+ test('COLLAB changes: the introductions panel is revealed BY THE MODE', () => {
145
+ // Not by a rank, not by a route, not by a second fetch — by the document
146
+ // attribute the shell stamped. That is what "the surface reads differently"
147
+ // means in practice.
148
+ assert.match(COLLAB_JS, /document\.documentElement\.dataset\.social === 'on'/);
149
+ assert.match(COLLAB_HTML, /id="cb-mingle-section"[^>]*hidden/);
150
+ });
151
+
152
+ test('with social OFF the page is exactly what it was, and costs nothing extra', () => {
153
+ // A page that fetched anyway would make "social is off" cost a request on
154
+ // every load, and a panel that rendered empty would still be a visible change.
155
+ const fn = COLLAB_JS.slice(COLLAB_JS.indexOf('async function loadMingle'));
156
+ const body = fn.slice(0, fn.indexOf('\n }'));
157
+ const guard = body.indexOf('if (!socialOn())');
158
+ assert.ok(guard > -1, 'the mode is checked');
159
+ assert.ok(guard < body.indexOf("api.request('GET', '/mingle')"),
160
+ 'and it is checked BEFORE anything is fetched');
161
+ });
162
+
163
+ test('the introductions read is best-effort — it cannot take the page down', () => {
164
+ // Help requests ARE the Collab page; the social half is additive.
165
+ assert.match(COLLAB_JS, /try \{ await loadMingle\(\); \} catch \(_\)/);
166
+ });
167
+
168
+ test('the row is written from the VIEWER\'s side, without a second /me', () => {
169
+ // A pair carries builder_lo/builder_hi with no marker for "you", so the route
170
+ // says which side the caller is rather than making the page fetch to find out.
171
+ const routes = read('modules', 'lifecycle', 'routes', 'mingle.js');
172
+ assert.match(routes, /you: String\(req\.builder\.id\)/);
173
+ assert.match(COLLAB_JS, /r\.data\.you/);
174
+ assert.match(COLLAB_JS, /function otherOf\(pair, meId\)/);
175
+ });
176
+
177
+ test('answering an introduction reads neutrally either way', () => {
178
+ // "Declined" must not read as a failure. A disappointed toast would be the
179
+ // penalty C07 forbids, delivered by the UI instead of the schema.
180
+ assert.match(COLLAB_JS, /Not now\. Nothing changes for you\./);
181
+ });