@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.
- package/.bongos-core.json +30 -25
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +2 -0
- package/docs/copy-inventory.md +117 -112
- package/docs/copy-registry.json +168 -123
- package/docs/module-api-changelog.md +2 -0
- package/modules/builder-settings/builder-needs.js +28 -2
- package/modules/hall-ui/public/collab.html +19 -0
- package/modules/hall-ui/public/collab.js +78 -0
- package/modules/hall-ui/public/hall-render.js +6 -0
- package/modules/hall-ui/public/shell.js +30 -1
- package/modules/lifecycle/lifecycle.js +8 -0
- package/modules/lifecycle/mingle.js +17 -1
- package/modules/lifecycle/routes/mingle.js +5 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/me.js +39 -1
- package/src/module-api.js +1 -1
- package/tests/mingle.mjs +26 -0
- package/tests/social_mode.mjs +181 -0
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -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.
|
|
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
|
+
});
|