@bongos/core 1.23.11 → 1.23.13
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 +41 -21
- package/docs/copy-inventory.md +70 -70
- package/docs/copy-registry.json +83 -83
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +7 -1
- package/docs/page-readings.json +80 -74
- package/modules/hall-ui/public/tweak-editor-lib.js +7 -6
- package/modules/hall-ui/records/tweak-editor.md +1 -1
- package/modules/public-landing/public/community-owner-two.states.json +16 -0
- package/modules/public-landing/public/community-owner.states.json +16 -0
- package/modules/public-landing/public/community.html +122 -10
- package/modules/ui-design/kit/fixtures/my-projects-owner-two.json +1 -0
- package/modules/ui-design/kit/fixtures/my-projects-owner.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/hall_tweak_editor.mjs +8 -11
- package/tests/hub_community_page.mjs +128 -6
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
their own. The one viewer-relative page is the recruiter list: drawn only from a 200 of
|
|
17
17
|
GET /scouting, whose door (a project owner, or project.curate) is the server's, never
|
|
18
18
|
this page's (scouting-authz.js). A 401 or 403 draws no row.
|
|
19
|
+
THE RECRUIT DOOR (task 1002292; spec D6, owner 2026-10-27): a project OWNER (GET
|
|
20
|
+
/my-projects `owned`) gets a Recruit link on each leaderboard and search row. The hub
|
|
21
|
+
cannot read a project's project.recruit, which lives on that project (ADR 0335 D1), so
|
|
22
|
+
ownership stands in. The link only opens the project's own hall invite box with the
|
|
23
|
+
login filled in (ADR 0353); the project decides. The rows themselves never change.
|
|
19
24
|
Hub-only, like /account: platform-server.js serves /community only where
|
|
20
25
|
platform-identity is on. noindex until the SEO goal decides (owner, 2026-10-27).
|
|
21
26
|
Server-stamped like the footer doors: {{…}} tokens filled by landingPage(). -->
|
|
@@ -69,19 +74,29 @@
|
|
|
69
74
|
.connAv{width:36px;height:36px;border-radius:50%;flex:none;object-fit:cover;background:color-mix(in srgb,var(--ink) 12%,transparent);}
|
|
70
75
|
.commRow .mineRow__name,.connWho .mineRow__name{overflow-wrap:anywhere;}
|
|
71
76
|
/* the two dropdowns (the window, the search's craft): the field's pill */
|
|
72
|
-
.commWhen select,.commFind select{flex:0 1 auto;min-height:44px;padding:0 14px;font:inherit;font-size:14.5px;color:var(--ink);
|
|
77
|
+
.commWhen select,.commFind select,.commFor select{flex:0 1 auto;min-height:44px;padding:0 14px;font:inherit;font-size:14.5px;color:var(--ink);
|
|
73
78
|
background:var(--bg-card);border:1px solid var(--ctl-line);border-radius:var(--radius-pill);cursor:pointer;}
|
|
74
|
-
.commWhen select:focus-visible,.commFind input:focus-visible,.commFind select:focus-visible{outline:2px solid var(--focus);outline-offset:3px;box-shadow:0 0 0 8px var(--bg);}
|
|
79
|
+
.commWhen select:focus-visible,.commFind input:focus-visible,.commFind select:focus-visible,.commFor select:focus-visible{outline:2px solid var(--focus);outline-offset:3px;box-shadow:0 0 0 8px var(--bg);}
|
|
75
80
|
#findStatus{margin-top:8px;}
|
|
81
|
+
/* the Recruit door, a project owner's only (task 1002292): what it opens, or which
|
|
82
|
+
project it recruits to; then a fifth column after the credits, where your own row
|
|
83
|
+
keeps an invisible twin so every row's credits line up */
|
|
84
|
+
.commFor{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin:6px 0 4px;max-width:62ch;font-size:13.5px;line-height:1.5;color:var(--ink-soft);}
|
|
85
|
+
#findFor{margin-top:12px;}
|
|
86
|
+
.commRows.canRecruit .commRow{grid-template-columns:3ch 36px minmax(0,1fr) auto auto;}
|
|
87
|
+
.commGap{visibility:hidden;}
|
|
76
88
|
/* the recruiter list, signed out: the account page's door */
|
|
77
89
|
.commDoor{padding:8px 0 10px;max-width:62ch;}
|
|
78
90
|
.commDoor p{margin:0 0 18px;font-size:15px;line-height:1.55;color:var(--ink-soft);}
|
|
79
91
|
.commDoor .gh{width:18px;height:18px;fill:currentColor;}
|
|
80
92
|
.commDoor .btn{text-transform:none;}
|
|
81
93
|
@media (max-width:480px){
|
|
82
|
-
.commRow{grid-template-columns:3ch 32px minmax(0,1fr) auto;gap:0 10px;}
|
|
94
|
+
.commRow,.commRows.canRecruit .commRow{grid-template-columns:3ch 32px minmax(0,1fr) auto;gap:0 10px;}
|
|
83
95
|
.commRow .connAv{width:32px;height:32px;}
|
|
84
96
|
.commNum{font-size:19px;}
|
|
97
|
+
/* no room for a fifth column: Recruit drops under the name, as a row's actions do */
|
|
98
|
+
.commRow .commRecruit{grid-column:3/-1;justify-self:start;margin-top:8px;}
|
|
99
|
+
.commGap{display:none;}
|
|
85
100
|
}
|
|
86
101
|
</style>
|
|
87
102
|
</head>
|
|
@@ -176,6 +191,7 @@
|
|
|
176
191
|
</div>
|
|
177
192
|
</div>
|
|
178
193
|
<p class="commNote" id="boardNote">{{Currency}} earned across every project, since each builder started.</p>
|
|
194
|
+
<div class="commFor" id="boardFor" hidden></div>
|
|
179
195
|
<p class="commMsg" id="boardLoading" role="status">Reading the leaderboard…</p>
|
|
180
196
|
<p class="projMsg err" id="boardFailed" role="alert" hidden></p>
|
|
181
197
|
<p class="commMsg" id="boardEmpty" hidden></p>
|
|
@@ -201,6 +217,7 @@
|
|
|
201
217
|
</select>
|
|
202
218
|
<button id="findBtn" type="submit" class="btn small">Search</button>
|
|
203
219
|
</form>
|
|
220
|
+
<div class="commFor" id="findFor" hidden></div>
|
|
204
221
|
<p class="projMsg" id="findStatus" role="status" aria-live="polite"></p>
|
|
205
222
|
<ul class="mineRows" id="findList"></ul>
|
|
206
223
|
</section>
|
|
@@ -259,8 +276,9 @@
|
|
|
259
276
|
var UNIT = $('boardList').getAttribute('data-unit') || 'credits';
|
|
260
277
|
|
|
261
278
|
/* THREE THINGS THESE PAGES MUST NOT DO (ADR 0217 D1, ADR 0337 D4.1, ADR 0255):
|
|
262
|
-
1. differ for a viewer anywhere but the recruiter list
|
|
263
|
-
search
|
|
279
|
+
1. differ for a viewer anywhere but the recruiter list and the Recruit door — the
|
|
280
|
+
leaderboard and the search rows come from viewer-independent reads, and a
|
|
281
|
+
project owner only gains a link on each (task 1002292, spec D6);
|
|
264
282
|
2. draw a recruiter row from anything but a 200 of GET /scouting, or draw more of a
|
|
265
283
|
name-only row than its name, face and login;
|
|
266
284
|
3. re-rank, re-filter or re-count what the server sent — rows are drawn in the
|
|
@@ -317,6 +335,36 @@
|
|
|
317
335
|
return arr(list).map(function (d) { return CRAFT_WORD[d] || String(d); }).join(' · ');
|
|
318
336
|
}
|
|
319
337
|
|
|
338
|
+
// ── the Recruit door: a project owner's link on each row (task 1002292) ─────
|
|
339
|
+
// Who sees it is GET /my-projects' `owned` (owner, 2026-10-27): the hub cannot read a
|
|
340
|
+
// project's project.recruit. Where it goes is invite-notices.js hallInviteUrl's shape,
|
|
341
|
+
// the project's hall invite box, which only fills the name in; the project decides.
|
|
342
|
+
var recruit = { to: [], pick: 0, me: '' };
|
|
343
|
+
function originOf(raw) {
|
|
344
|
+
try {
|
|
345
|
+
var u = new URL(String(raw));
|
|
346
|
+
return (u.protocol === 'https:' || u.protocol === 'http:') ? u.origin : '';
|
|
347
|
+
} catch (e) { return ''; }
|
|
348
|
+
}
|
|
349
|
+
// `keepPlace`: a board row keeps an invisible twin on your own row, so its credits line up
|
|
350
|
+
function recruitCell(r, keepPlace) {
|
|
351
|
+
var p = recruit.to[recruit.pick];
|
|
352
|
+
if (!p) return null;
|
|
353
|
+
var login = String(r.github_login || '');
|
|
354
|
+
if (!login || login.toLowerCase() === recruit.me) {
|
|
355
|
+
if (!keepPlace) return null;
|
|
356
|
+
var gap = el('span', 'btn small ghost commRecruit commGap', 'Recruit');
|
|
357
|
+
gap.setAttribute('aria-hidden', 'true');
|
|
358
|
+
return gap;
|
|
359
|
+
}
|
|
360
|
+
var a = el('a', 'btn small ghost commRecruit', 'Recruit');
|
|
361
|
+
a.setAttribute('href', p.origin + '/builders/watch?invite=' + encodeURIComponent(login));
|
|
362
|
+
a.setAttribute('target', '_blank');
|
|
363
|
+
a.setAttribute('rel', 'noopener');
|
|
364
|
+
a.setAttribute('aria-label', 'Recruit ' + (r.display_name || r.handle || login) + ' to ' + p.name + ' (opens in a new tab)');
|
|
365
|
+
return a;
|
|
366
|
+
}
|
|
367
|
+
|
|
320
368
|
// ── the leaderboard ─────────────────────────────────────────────────────────
|
|
321
369
|
var board = { window: 'all', craft: '' };
|
|
322
370
|
var boardSeq = 0;
|
|
@@ -338,6 +386,8 @@
|
|
|
338
386
|
credits.appendChild(el('span', 'commNum', num(r.credits).toLocaleString()));
|
|
339
387
|
credits.appendChild(el('span', 'commUnit', UNIT));
|
|
340
388
|
li.appendChild(credits);
|
|
389
|
+
var door = recruitCell(r, true);
|
|
390
|
+
if (door) li.appendChild(door);
|
|
341
391
|
return li;
|
|
342
392
|
}
|
|
343
393
|
function emptyText() {
|
|
@@ -345,8 +395,11 @@
|
|
|
345
395
|
if (board.window === 'all') return 'No ' + who + ' on the leaderboard yet.';
|
|
346
396
|
return 'No ' + who + ' earned ' + UNIT + ' in this window yet.';
|
|
347
397
|
}
|
|
398
|
+
var boardShown = null; // the last board drawn, redrawn when the Recruit door changes
|
|
348
399
|
function paintBoard(results) {
|
|
349
400
|
var list = $('boardList');
|
|
401
|
+
boardShown = results;
|
|
402
|
+
list.className = 'commRows' + (recruit.to.length ? ' canRecruit' : '');
|
|
350
403
|
list.textContent = '';
|
|
351
404
|
results.forEach(function (r) { if (r) list.appendChild(boardRow(r)); });
|
|
352
405
|
$('boardEmpty').textContent = list.children.length ? '' : emptyText();
|
|
@@ -366,6 +419,7 @@
|
|
|
366
419
|
if (seq !== boardSeq) return; // a later pick answered first; its rows win
|
|
367
420
|
$('boardLoading').hidden = true;
|
|
368
421
|
if (!r.ok) {
|
|
422
|
+
boardShown = null;
|
|
369
423
|
$('boardList').textContent = '';
|
|
370
424
|
$('boardEmpty').hidden = true;
|
|
371
425
|
$('boardFailed').textContent = failText(r.status, 'The leaderboard');
|
|
@@ -403,23 +457,35 @@
|
|
|
403
457
|
if (c) main.appendChild(el('p', 'mineRow__hint', c));
|
|
404
458
|
who.appendChild(main);
|
|
405
459
|
li.appendChild(who);
|
|
460
|
+
var door = recruitCell(r, false);
|
|
461
|
+
if (door) {
|
|
462
|
+
var acts = el('div', 'mineRow__actions');
|
|
463
|
+
acts.appendChild(door);
|
|
464
|
+
li.appendChild(acts);
|
|
465
|
+
}
|
|
406
466
|
return li;
|
|
407
467
|
}
|
|
468
|
+
var found = null; // the last results drawn, redrawn when the Recruit door changes
|
|
469
|
+
function paintFound(results) {
|
|
470
|
+
var list = $('findList');
|
|
471
|
+
found = results;
|
|
472
|
+
list.textContent = '';
|
|
473
|
+
if (results) results.forEach(function (p) { if (p) list.appendChild(findRow(p)); });
|
|
474
|
+
}
|
|
408
475
|
function find() {
|
|
409
476
|
var q = String($('findQ').value || '').trim();
|
|
410
477
|
var craft = $('findCraft').value || '';
|
|
411
478
|
var list = $('findList');
|
|
412
|
-
if (q.length < QUERY_MIN) {
|
|
413
|
-
if (q.length > QUERY_MAX) {
|
|
479
|
+
if (q.length < QUERY_MIN) { paintFound(null); say('Type at least ' + QUERY_MIN + ' letters of a name.', 'err'); return Promise.resolve(); }
|
|
480
|
+
if (q.length > QUERY_MAX) { paintFound(null); say('A search is at most ' + QUERY_MAX + ' characters.', 'err'); return Promise.resolve(); }
|
|
414
481
|
var btn = $('findBtn');
|
|
415
482
|
btn.disabled = true;
|
|
416
483
|
say('Searching…');
|
|
417
484
|
var path = '/community/search?q=' + encodeURIComponent(q) + (craft ? '&discipline=' + encodeURIComponent(craft) : '');
|
|
418
485
|
return read(path).then(function (r) {
|
|
419
486
|
btn.disabled = false;
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
arr(r.data.results).forEach(function (p) { if (p) list.appendChild(findRow(p)); });
|
|
487
|
+
if (!r.ok) { paintFound(null); say(failText(r.status, 'The search'), 'err'); return; }
|
|
488
|
+
paintFound(arr(r.data.results));
|
|
423
489
|
var n = list.children.length;
|
|
424
490
|
say(n ? plural(n, 'builder') + ' found.' : 'No builder by that name' + (craft ? ' in that role' : '') + '.');
|
|
425
491
|
});
|
|
@@ -449,6 +515,51 @@
|
|
|
449
515
|
});
|
|
450
516
|
}
|
|
451
517
|
|
|
518
|
+
// ── the Recruit door's line, and the rows redrawn with it ───────────────────
|
|
519
|
+
function repaintRows() {
|
|
520
|
+
if (boardShown) paintBoard(boardShown);
|
|
521
|
+
if (found) paintFound(found);
|
|
522
|
+
}
|
|
523
|
+
// One project: what Recruit opens. Several: which one it recruits to, drawn once, so a
|
|
524
|
+
// pick redraws only the rows and the choice keeps the focus.
|
|
525
|
+
function paintFor(box) {
|
|
526
|
+
box.textContent = '';
|
|
527
|
+
box.hidden = false;
|
|
528
|
+
if (recruit.to.length === 1) {
|
|
529
|
+
box.textContent = 'Recruit opens ' + recruit.to[0].name + '’s hall with their name filled in, and you send the invite there.';
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
var id = box.id + 'Pick';
|
|
533
|
+
var label = el('label', null, 'Recruit to');
|
|
534
|
+
label.setAttribute('for', id);
|
|
535
|
+
var pick = el('select', 'commForPick');
|
|
536
|
+
pick.id = id;
|
|
537
|
+
recruit.to.forEach(function (p, i) { var o = el('option', null, p.name); o.value = String(i); pick.appendChild(o); });
|
|
538
|
+
pick.value = String(recruit.pick);
|
|
539
|
+
pick.addEventListener('change', function () {
|
|
540
|
+
var i = Number(pick.value);
|
|
541
|
+
if (!recruit.to[i] || i === recruit.pick) return;
|
|
542
|
+
recruit.pick = i;
|
|
543
|
+
repaintRows();
|
|
544
|
+
});
|
|
545
|
+
box.appendChild(label);
|
|
546
|
+
box.appendChild(pick);
|
|
547
|
+
box.appendChild(el('span', null, 'Recruit opens its hall with their name filled in, and you send the invite there.'));
|
|
548
|
+
}
|
|
549
|
+
// A failed or empty read is no door at all: viewing never waits on it.
|
|
550
|
+
function loadOwned(me, box) {
|
|
551
|
+
return read('/my-projects').then(function (r) {
|
|
552
|
+
if (!r.ok) return;
|
|
553
|
+
recruit.me = String(me.github_login || '').toLowerCase();
|
|
554
|
+
recruit.to = arr(r.data.owned).map(function (p) {
|
|
555
|
+
return { name: String((p && (p.name || p.client_id)) || 'your project'), origin: originOf(p && p.origin) };
|
|
556
|
+
}).filter(function (p) { return p.origin; });
|
|
557
|
+
if (!recruit.to.length) return;
|
|
558
|
+
paintFor(box);
|
|
559
|
+
repaintRows();
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
452
563
|
// ── the sub-page the path names: shown, named, marked current, and read ──────
|
|
453
564
|
var VIEW = viewOf(window.location.pathname);
|
|
454
565
|
var view = VIEWS[VIEW];
|
|
@@ -461,6 +572,7 @@
|
|
|
461
572
|
$('signinTop').setAttribute('href', '/api/bongos/auth/web/start?return=' + view.path + '&repos=1');
|
|
462
573
|
if (VIEW === 'leaderboard') loadBoard();
|
|
463
574
|
if (VIEW === 'recruiters') bar.ready.then(function (me) { if (me) loadRecruit(); else showRecruit('recruitDoor'); });
|
|
575
|
+
else bar.ready.then(function (me) { if (me) loadOwned(me, $(VIEW === 'find' ? 'findFor' : 'boardFor')); });
|
|
464
576
|
})();
|
|
465
577
|
</script>
|
|
466
578
|
<script src="/assets/invite-badge.js"></script>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"_":"GET /my-projects for a builder who owns TWO projects (task 1002292): the community pages' Recruit door asks which project it recruits to.","owned":[{"client_id":"mercury","name":"Mercury","origin":"https://mercury.example.test","hall_origin":null,"membership_kind":"owner","joined_at":"2026-08-01T10:00:00.000Z","last_active":"2026-10-20T18:00:00.000Z"},{"client_id":"tidepool","name":"Tidepool","origin":"https://tidepool.example.test","hall_origin":null,"membership_kind":"owner","joined_at":"2026-09-02T10:00:00.000Z","last_active":"2026-10-24T18:00:00.000Z"}],"building":[],"applied":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"_":"GET /my-projects for a builder who owns ONE project (task 1002292): the community pages' Recruit door shows on every row and opens Mercury's hall invite box.","owned":[{"client_id":"mercury","name":"Mercury","origin":"https://mercury.example.test","hall_origin":null,"membership_kind":"owner","joined_at":"2026-08-01T10:00:00.000Z","last_active":"2026-10-20T18:00:00.000Z"}],"building":[{"client_id":"otb","name":"Example World","origin":"https://example.com","hall_origin":"https://builders.example.com","membership_kind":"member","joined_at":"2026-07-20T10:00:00.000Z","last_active":"2026-08-27T18:00:00.000Z"}],"applied":[]}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.13",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.23.
|
|
9
|
+
"version": "1.23.13",
|
|
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.23.
|
|
3
|
+
"version": "1.23.13",
|
|
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/release-notes.json
CHANGED
|
@@ -10453,5 +10453,17 @@
|
|
|
10453
10453
|
"id": "1004256",
|
|
10454
10454
|
"text": "Signed-in builders now find 'Builders you may know' in the Community menu; it opens their suggestions on the Connections page, which says so plainly when there's no one to suggest yet."
|
|
10455
10455
|
}
|
|
10456
|
+
],
|
|
10457
|
+
"1.23.12": [
|
|
10458
|
+
{
|
|
10459
|
+
"id": "1002292",
|
|
10460
|
+
"text": "Project owners can now press Recruit next to any builder on the community leaderboard or search, which opens their project's invite page with that builder's name already filled in."
|
|
10461
|
+
}
|
|
10462
|
+
],
|
|
10463
|
+
"1.23.13": [
|
|
10464
|
+
{
|
|
10465
|
+
"id": "1004644",
|
|
10466
|
+
"text": "The tweak editor's split view now shows the landing and status pages under the words, instead of opening a second window."
|
|
10467
|
+
}
|
|
10456
10468
|
]
|
|
10457
10469
|
}
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.23.
|
|
84
|
+
const CORE_VERSION = '1.23.13'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -675,11 +675,13 @@ try {
|
|
|
675
675
|
await test('split view can frame only its own host; the live site refuses to be framed from another', () => {
|
|
676
676
|
const here = { origin: 'https://builders.cloudbongos.com', href: 'https://builders.cloudbongos.com/builders/tweak-editor' };
|
|
677
677
|
assert.equal(lib.canFrame('https://builders.cloudbongos.com/studio', here), true);
|
|
678
|
-
assert.equal(lib.canFrame('https://cloudbongos.com/', here),
|
|
678
|
+
assert.equal(lib.canFrame('https://cloudbongos.com/', here), true, 'the apex is a sibling the proxy lets the builders host frame');
|
|
679
|
+
assert.equal(lib.canFrame('https://cloudbongos.com/', here, false), false, 'the apex is another origin when siblings are off');
|
|
679
680
|
assert.equal(lib.canFrame('not a url', { origin: '', href: '' }), false);
|
|
680
681
|
// task 1004393: the apex and status pages split in place only once the proxy allows it.
|
|
681
|
-
assert.equal(lib.FRAME_SIBLINGS,
|
|
682
|
-
assert.equal(lib.canFrame('https://status.cloudbongos.com/', here),
|
|
682
|
+
assert.equal(lib.FRAME_SIBLINGS, true, 'on: the proxy sends frame-ancestors for the builders host (task 1004644)');
|
|
683
|
+
assert.equal(lib.canFrame('https://status.cloudbongos.com/', here), true, 'the default follows the switch');
|
|
684
|
+
assert.equal(lib.canFrame('https://status.cloudbongos.com/', here, false), false, 'switched off, only the editor host frames');
|
|
683
685
|
assert.equal(lib.canFrame('https://cloudbongos.com/pricing', here, true), true, 'the apex, switched on');
|
|
684
686
|
assert.equal(lib.canFrame('https://status.cloudbongos.com/', here, true), true, 'the status host, switched on');
|
|
685
687
|
assert.equal(lib.canFrame('https://box.cloudbongos.com/', here, true), false, 'never another subdomain');
|
|
@@ -791,19 +793,14 @@ try {
|
|
|
791
793
|
} finally { PAGE.url = saved; }
|
|
792
794
|
});
|
|
793
795
|
|
|
794
|
-
await test('a page on the apex splits
|
|
796
|
+
await test('a page on the apex splits in place under the words, with no second window (task 1004644)', async () => {
|
|
795
797
|
const b = bootEditor({
|
|
796
798
|
answers: { 'GET /copy-desk/pages/landing:index/draft': ok(editorRead()) },
|
|
797
799
|
stored: { 'hall-tweak-open-as': 'split', 'hall-tweak-split': 'on' },
|
|
798
800
|
});
|
|
799
801
|
await b.settle();
|
|
800
|
-
assert.equal(b.opened.length, 0, 'no popup
|
|
801
|
-
assert.equal(b.el('tw-frame').hidden,
|
|
802
|
-
await b.click('tw-open');
|
|
803
|
-
assert.equal(b.opened.length, 1);
|
|
804
|
-
assert.deepEqual(b.opened[0].slice(0, 2), ['https://cloudbongos.com/', 'tw-live-page']);
|
|
805
|
-
assert.equal(b.opened[0][2], 'popup=yes,width=1600,height=500,left=0,top=500');
|
|
806
|
-
assert.equal(b.el('tw-frame').hidden, true);
|
|
802
|
+
assert.equal(b.opened.length, 0, 'no popup: the page is framed in place');
|
|
803
|
+
assert.equal(b.el('tw-frame').hidden, false, 'the apex page shows under the words');
|
|
807
804
|
});
|
|
808
805
|
|
|
809
806
|
// task 1004776: a guarded line shows what it must still say under it, and the
|
|
@@ -78,6 +78,7 @@ test('no top block: one visually hidden h1, then one section per sub-page, only
|
|
|
78
78
|
assert.match(MAIN, /<ol class="commRows" id="boardList" data-unit="\{\{currency\}\}" aria-labelledby="boardH"><\/ol>/, 'the unit is the instance’s own word, stamped by the server');
|
|
79
79
|
assert.match(MAIN, /<p class="projMsg err" id="boardFailed" role="alert" hidden><\/p>/);
|
|
80
80
|
assert.match(MAIN, /<form class="mineJoinRow commFind" id="findForm" role="search"/);
|
|
81
|
+
for (const id of ['boardFor', 'findFor']) assert.match(MAIN, new RegExp(`<div class="commFor" id="${id}" hidden></div>`), `${id}: the Recruit door’s line waits for GET /my-projects (task 1002292)`);
|
|
81
82
|
assert.match(MAIN, /<h2 id="recruitH">Open to recruiters<\/h2>/, 'the hide flag is "hidden", never "private" (ADR 0337)');
|
|
82
83
|
assert.match(MAIN, /<a class="btn" id="recruitSignin" href="\/api\/bongos\/auth\/web\/start\?return=\/community\/recruiters&repos=1">/);
|
|
83
84
|
for (const id of ['recruitDoor', 'recruitClosed', 'recruitFailed', 'recruitEmpty']) assert.match(MAIN, new RegExp(` id="${id}"[^>]*hidden`), `${id} waits for the route’s answer`);
|
|
@@ -100,11 +101,12 @@ test('the window dropdown and the query limits are the routes’ own (boundary-f
|
|
|
100
101
|
assert.match(MAIN, new RegExp(`<input id="findQ" type="search"[^>]* maxlength="${max}"`));
|
|
101
102
|
});
|
|
102
103
|
|
|
103
|
-
test('the script builds no markup from strings, reads only the
|
|
104
|
+
test('the script builds no markup from strings, reads only the four routes, and never re-ranks or reads the ranked tier', () => {
|
|
104
105
|
assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
|
|
105
|
-
// every path literal in the code: the API base, the
|
|
106
|
+
// every path literal in the code: the API base, the four reads, the three sub-pages, the
|
|
107
|
+
// sign-in door and the hall's invite box the Recruit door opens (task 1002292)
|
|
106
108
|
const paths = [...new Set([...CODE.matchAll(/'(\/[^'?]*)/g)].map((m) => m[1]))];
|
|
107
|
-
assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/community', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/scouting']);
|
|
109
|
+
assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/builders/watch', '/community', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/my-projects', '/scouting']);
|
|
108
110
|
assert.doesNotMatch(CODE, /method:/, 'it only reads');
|
|
109
111
|
assert.doesNotMatch(CODE, /'\/me\b/, 'who is signed in is the bar’s question');
|
|
110
112
|
assert.doesNotMatch(CODE, /\.sort\(|\.count\b|_count\b/, 'the server ranks; every count is a list’s length');
|
|
@@ -173,7 +175,9 @@ async function boot({ pathname = '/community', me = null, reads = READS() } = {}
|
|
|
173
175
|
requests.push({ url, credentials: init.credentials });
|
|
174
176
|
const a = reads[url.replace(/^\/api\/bongos/, '')];
|
|
175
177
|
if (!a || a.reject) return Promise.reject(new Error('offline'));
|
|
176
|
-
|
|
178
|
+
const res = { ok: a.status >= 200 && a.status < 300, status: a.status, json: () => Promise.resolve(a.body) };
|
|
179
|
+
// `slow`: answers on a timer, after every microtask-settled read (the order a slow network gives)
|
|
180
|
+
return a.slow ? new Promise((resolve) => setTimeout(() => resolve(res), 5)) : Promise.resolve(res);
|
|
177
181
|
};
|
|
178
182
|
const document = {
|
|
179
183
|
title: 'Cloud Bongos — Community',
|
|
@@ -225,7 +229,8 @@ test('the leaderboard, signed out: the all-time board in the server’s order wi
|
|
|
225
229
|
assert.equal(find(bo, 'mineRow__hint').textContent, '1 work shipped · 0 karma');
|
|
226
230
|
assert.equal(ui.$('boardNote').textContent, 'Credits earned across every project, since each builder started.');
|
|
227
231
|
const signedIn = await boot({ me: { github_login: 'someone' } });
|
|
228
|
-
assert.deepEqual(signedIn.gets(), ['/api/bongos/community/leaderboard?window=all'], 'signed in, the same
|
|
232
|
+
assert.deepEqual(signedIn.gets(), ['/api/bongos/community/leaderboard?window=all', '/api/bongos/my-projects'], 'signed in, the same board read: viewing is ungated, and the one more read is the Recruit door’s');
|
|
233
|
+
assert.equal(signedIn.$('boardList').textContent, ui.$('boardList').textContent, 'the same rows for every viewer');
|
|
229
234
|
});
|
|
230
235
|
|
|
231
236
|
test('the window dropdown and a craft are each one read; the pressed craft follows the pick', async () => {
|
|
@@ -320,6 +325,114 @@ test('recruiters: a door signed out, a refusal on a 403, rows only from a 200 as
|
|
|
320
325
|
assert.equal(failed.$('recruitFailed').textContent, 'This list could not be read. Reload the page to try again.');
|
|
321
326
|
});
|
|
322
327
|
|
|
328
|
+
// ── 2b. the Recruit door (task 1002292; spec D6, owner 2026-10-27) ───────────
|
|
329
|
+
// A project OWNER gets a Recruit link on each row: the hub cannot read a project's
|
|
330
|
+
// `project.recruit` (ADR 0335 D1), so GET /my-projects' `owned` stands in. The link is the
|
|
331
|
+
// project's own hall invite box with the GitHub login filled in (ADR 0353), and the
|
|
332
|
+
// project decides.
|
|
333
|
+
|
|
334
|
+
const OWNED = (...ps) => ({ status: 200, body: {
|
|
335
|
+
owned: ps.map(([client_id, origin, name]) => ({ client_id, name, origin, hall_origin: null, membership_kind: 'owner' })),
|
|
336
|
+
building: [{ client_id: 'zed', name: 'Zed', origin: 'https://zed.example', hall_origin: null, membership_kind: 'member' }],
|
|
337
|
+
applied: [],
|
|
338
|
+
} });
|
|
339
|
+
const recruitOf = (row) => find(row, 'commRecruit');
|
|
340
|
+
|
|
341
|
+
test('recruit: an owner gets a Recruit link on every row but their own, opening their project’s hall invite box with the GitHub login filled in', async () => {
|
|
342
|
+
const reads = READS();
|
|
343
|
+
reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example/x?y=1', 'Mercury']);
|
|
344
|
+
const ui = await boot({ me: { github_login: 'BO' }, reads });
|
|
345
|
+
assert.deepEqual(ui.gets(), ['/api/bongos/community/leaderboard?window=all', '/api/bongos/my-projects']);
|
|
346
|
+
const [ana, bo] = kids(ui.$('boardList'));
|
|
347
|
+
const a = recruitOf(ana);
|
|
348
|
+
assert.equal(a.tag, 'a');
|
|
349
|
+
assert.equal(a.textContent, 'Recruit');
|
|
350
|
+
assert.equal(a.attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'the project’s origin and nothing else of the address');
|
|
351
|
+
assert.equal(a.attrs.target, '_blank', 'the board stays open behind it');
|
|
352
|
+
assert.equal(a.attrs.rel, 'noopener');
|
|
353
|
+
assert.equal(a.attrs['aria-label'], 'Recruit ANA to Mercury (opens in a new tab)', 'the new tab is said, not sprung');
|
|
354
|
+
assert.equal(recruitOf(bo).tag, 'span', 'your own row: an invisible twin keeps the credits in line');
|
|
355
|
+
assert.equal(recruitOf(bo).attrs['aria-hidden'], 'true');
|
|
356
|
+
assert.match(ui.$('boardList').className, /\bcanRecruit\b/);
|
|
357
|
+
assert.equal(ui.$('boardFor').hidden, false);
|
|
358
|
+
assert.equal(ui.$('boardFor').textContent, 'Recruit opens Mercury’s hall with their name filled in, and you send the invite there.');
|
|
359
|
+
await ui.pick('30d');
|
|
360
|
+
assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=cy', 'a new window’s rows carry it too');
|
|
361
|
+
reads['/community/leaderboard?window=all'].slow = true;
|
|
362
|
+
const late = await boot({ me: { github_login: 'BO' }, reads });
|
|
363
|
+
assert.equal(kids(late.$('boardList')).length, 0, 'the board has not answered yet');
|
|
364
|
+
assert.equal(late.$('boardFor').hidden, false, 'the owner’s line is up before the board');
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
366
|
+
await settle();
|
|
367
|
+
assert.equal(recruitOf(kids(late.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'a board that answers after GET /my-projects draws its doors as it paints');
|
|
368
|
+
delete reads['/community/leaderboard?window=all'].slow;
|
|
369
|
+
reads['/community/leaderboard?window=all'].body.results[0].github_login = 'a b&c';
|
|
370
|
+
const odd = await boot({ me: { github_login: 'x' }, reads });
|
|
371
|
+
assert.equal(recruitOf(kids(odd.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=a%20b%26c', 'the login is encoded into the query');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('recruit: none for a builder who owns no project, signed out, on a failed read, or for a project with no web address; the rows stay the same', async () => {
|
|
375
|
+
const reads = READS();
|
|
376
|
+
reads['/my-projects'] = OWNED();
|
|
377
|
+
const member = await boot({ me: { github_login: 'someone' }, reads });
|
|
378
|
+
assert.equal(member.gets().pop(), '/api/bongos/my-projects');
|
|
379
|
+
assert.ok(kids(member.$('boardList')).every((r) => !recruitOf(r)), 'building on a project is not owning one');
|
|
380
|
+
assert.equal(member.$('boardFor').hidden, true);
|
|
381
|
+
assert.doesNotMatch(member.$('boardList').className, /canRecruit/);
|
|
382
|
+
const out = await boot({ reads });
|
|
383
|
+
assert.deepEqual(out.gets(), ['/api/bongos/community/leaderboard?window=all'], 'signed out, nobody is asked about projects');
|
|
384
|
+
for (const answer of [{ status: 500, body: {} }, { status: 401, body: {} }, { reject: true }, OWNED(['evil', 'javascript:alert(1)', 'Evil'], ['none', null, 'None'])]) {
|
|
385
|
+
reads['/my-projects'] = answer;
|
|
386
|
+
const ui = await boot({ me: { github_login: 'someone' }, reads });
|
|
387
|
+
assert.ok(kids(ui.$('boardList')).every((r) => !recruitOf(r)), 'no door, and the board is drawn all the same');
|
|
388
|
+
assert.equal(kids(ui.$('boardList')).length, 2);
|
|
389
|
+
assert.equal(ui.$('boardFailed').hidden, true, 'a failed projects read is never the board’s failure');
|
|
390
|
+
assert.equal(ui.$('boardFor').hidden, true);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('recruit: several projects are one “Recruit to” choice, and picking one re-points every row', async () => {
|
|
395
|
+
const reads = READS();
|
|
396
|
+
reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example', 'Mercury'], ['venus', 'http://venus.example:8080', 'Venus']);
|
|
397
|
+
const ui = await boot({ me: { github_login: 'nobody' }, reads });
|
|
398
|
+
const pick = find(ui.$('boardFor'), 'commForPick');
|
|
399
|
+
assert.equal(pick.tag, 'select');
|
|
400
|
+
assert.equal(pick.id, 'boardForPick');
|
|
401
|
+
assert.deepEqual(kids(pick).map((o) => [o.value, o.textContent]), [['0', 'Mercury'], ['1', 'Venus']]);
|
|
402
|
+
assert.equal(kids(ui.$('boardFor'))[0].attrs.for, 'boardForPick', 'the choice is labelled');
|
|
403
|
+
assert.match(ui.$('boardFor').textContent, /^Recruit to/);
|
|
404
|
+
assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'the first project, as the server listed them');
|
|
405
|
+
pick.value = '1';
|
|
406
|
+
for (const f of pick._on.change || []) f({});
|
|
407
|
+
await settle();
|
|
408
|
+
const [ana, bo] = kids(ui.$('boardList'));
|
|
409
|
+
assert.equal(recruitOf(ana).attrs.href, 'http://venus.example:8080/builders/watch?invite=ana');
|
|
410
|
+
assert.equal(recruitOf(bo).attrs['aria-label'], 'Recruit bo to Venus (opens in a new tab)');
|
|
411
|
+
assert.equal(find(ui.$('boardFor'), 'commForPick'), pick, 'the choice is not redrawn, so it keeps the focus');
|
|
412
|
+
pick.value = '7';
|
|
413
|
+
for (const f of pick._on.change || []) f({});
|
|
414
|
+
assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'http://venus.example:8080/builders/watch?invite=ana', 'a value it never offered changes nothing');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('recruit: found builders carry it too; the recruiter list never asks', async () => {
|
|
418
|
+
const reads = READS();
|
|
419
|
+
reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example', 'Mercury']);
|
|
420
|
+
const ui = await boot({ pathname: '/community/find', me: { github_login: 'ph' }, reads });
|
|
421
|
+
assert.deepEqual(ui.gets(), ['/api/bongos/my-projects'], 'nothing searched until asked');
|
|
422
|
+
assert.equal(ui.$('findFor').hidden, false);
|
|
423
|
+
assert.equal(ui.$('findFor').textContent, 'Recruit opens Mercury’s hall with their name filled in, and you send the invite there.');
|
|
424
|
+
assert.equal(ui.$('boardFor').hidden, true, 'only the shown sub-page’s line');
|
|
425
|
+
ui.$('findQ').value = 'pri';
|
|
426
|
+
await ui.submit();
|
|
427
|
+
const [priya, hidden] = kids(ui.$('findList'));
|
|
428
|
+
const acts = find(priya, 'mineRow__actions');
|
|
429
|
+
assert.equal(recruitOf(acts).attrs.href, 'https://mercury.example/builders/watch?invite=priyan', 'by GitHub login, never the handle');
|
|
430
|
+
assert.equal(recruitOf(hidden), null, 'your own row: no door, and no twin in a row that keeps no columns');
|
|
431
|
+
assert.equal(ui.$('findStatus').textContent, '2 builders found.');
|
|
432
|
+
const recruiters = await boot({ pathname: '/community/recruiters', me: { github_login: 'owner' }, reads });
|
|
433
|
+
assert.deepEqual(recruiters.gets(), ['/api/bongos/scouting'], 'the recruiter list draws no Recruit door');
|
|
434
|
+
});
|
|
435
|
+
|
|
323
436
|
// ── 3. every bar carries the Community menu ──────────────────────────────────
|
|
324
437
|
|
|
325
438
|
const SERVES_A_CHILD = ['privacy.html', 'terms.html', 'contact.html', 'index.html', 'projects.html'];
|
|
@@ -437,11 +550,20 @@ test('the kit renders each sub-page for everyone, the empty board, and a project
|
|
|
437
550
|
assert.equal(states.states['out-find'].url, '/community/find');
|
|
438
551
|
assert.equal(states.states['in-recruiters'].url, '/community/recruiters');
|
|
439
552
|
assert.ok(states.states['in-recruiters'].expect.visible.includes('#recruitClosed'), 'signed in but not admitted: told, shown no one');
|
|
440
|
-
for (const [file, name, fixture] of [['community-empty.states.json', 'community-leaderboard', 'community-leaderboard-empty'], ['community-recruiter.states.json', 'scouting', 'scouting-recruiter']]) {
|
|
553
|
+
for (const [file, name, fixture] of [['community-empty.states.json', 'community-leaderboard', 'community-leaderboard-empty'], ['community-recruiter.states.json', 'scouting', 'scouting-recruiter'], ['community-owner.states.json', 'my-projects', 'my-projects-owner'], ['community-owner-two.states.json', 'my-projects', 'my-projects-owner-two']]) {
|
|
441
554
|
assert.equal(json(file).stub.fixtures[name], fixture);
|
|
442
555
|
assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', fixture + '.json')), `fixture ${fixture}`);
|
|
443
556
|
}
|
|
444
557
|
assert.equal(json('community-recruiter.states.json').states['in-recruiter'].url, '/community/recruiters');
|
|
558
|
+
// the Recruit door (task 1002292): an owner of one project on the board and the search, and of two with the choice
|
|
559
|
+
assert.ok(json('community-owner.states.json').states['in-owner'].expect.count.some(([sel, n]) => sel === '#boardList a.commRecruit' && n === 6));
|
|
560
|
+
assert.equal(json('community-owner.states.json').states['in-owner-find'].url, '/community/find');
|
|
561
|
+
assert.ok(json('community-owner-two.states.json').states['in-owner-two-picked'].actions.some((a) => a[0] === 'select' && a[1] === '#boardForPick'));
|
|
562
|
+
for (const f of ['my-projects-owner', 'my-projects-owner-two']) {
|
|
563
|
+
const owned = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', f + '.json'), 'utf8')).owned;
|
|
564
|
+
assert.ok(owned.length && owned.every((p) => p.membership_kind === 'owner' && /^https:\/\//.test(p.origin)), `${f}: owned projects with web addresses`);
|
|
565
|
+
}
|
|
566
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'my-projects.json'), 'utf8')).owned, [], 'the default builder owns nothing, so every other state draws no door');
|
|
445
567
|
});
|
|
446
568
|
|
|
447
569
|
function stub(env = {}) {
|