@bongos/core 1.19.1027 → 1.19.1028
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 -20
- package/docs/copy-inventory.md +206 -198
- package/docs/copy-registry.json +323 -251
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/builders.js +7 -4
- package/modules/public-landing/public/assets/cosmos.css +36 -0
- package/modules/public-landing/public/projects.html +229 -26
- package/modules/ui-design/kit/fixtures/my-projects__invites.json +1 -0
- package/modules/ui-design/kit/serve.js +12 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/card_apply_ui.mjs +8 -1
- package/tests/curve_controls_delegation.mjs +87 -0
- package/tests/my_projects_surface.mjs +26 -14
- package/tests/public_landing_projects.mjs +40 -0
|
@@ -2533,5 +2533,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2533
2533
|
landed since 1.19.1025 with no explicit bump. run 36186644421. (task 1002620)
|
|
2534
2534
|
1.19.1027 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2535
2535
|
landed since 1.19.1026 with no explicit bump. run 36187742785. (task 1002620)
|
|
2536
|
+
1.19.1028 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2537
|
+
landed since 1.19.1027 with no explicit bump. run 36188894725. (task 1002620)
|
|
2536
2538
|
---------------------------------------------------------------------------
|
|
2537
2539
|
```
|
|
@@ -1231,9 +1231,12 @@
|
|
|
1231
1231
|
if (e.target.closest('#disc-save')) saveDisciplines();
|
|
1232
1232
|
});
|
|
1233
1233
|
|
|
1234
|
-
// V3.R72 (#291): the dev-curve scope toggle
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1234
|
+
// V3.R72 (#291): the dev-curve scope toggle. Delegated because
|
|
1235
|
+
// renderCurveBody replaces the toggle's innerHTML — and from the whole
|
|
1236
|
+
// Progress section, NOT #curve-body: the controls render into
|
|
1237
|
+
// #curve-scope-note in the section's lede, a sibling of #curve-body, so a
|
|
1238
|
+
// listener there never heard them (task 1004079).
|
|
1239
|
+
$('#analytics-scroll')?.addEventListener('click', (e) => {
|
|
1237
1240
|
const btn = e.target.closest('.curve__scope-btn[data-scope]');
|
|
1238
1241
|
if (!btn) return;
|
|
1239
1242
|
const scope = btn.dataset.scope === 'eco' ? 'eco' : 'self';
|
|
@@ -1247,7 +1250,7 @@
|
|
|
1247
1250
|
// listener bound to the element itself would not survive the first render.
|
|
1248
1251
|
// 'change', not 'input' — a keyboard user arrowing through a native select
|
|
1249
1252
|
// fires input per option, and each one would be a pair of round trips.
|
|
1250
|
-
$('#
|
|
1253
|
+
$('#analytics-scroll')?.addEventListener('change', (e) => {
|
|
1251
1254
|
const sel = e.target.closest('#curve-kind');
|
|
1252
1255
|
if (!sel) return;
|
|
1253
1256
|
const kind = sel.value ? sel.value : null;
|
|
@@ -350,6 +350,12 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
|
|
|
350
350
|
.btn:disabled{background:color-mix(in srgb,var(--ink) 12%,var(--bg-deep));color:color-mix(in srgb,var(--ink) 40%,var(--bg-deep));box-shadow:none;cursor:not-allowed;transform:none;}
|
|
351
351
|
.btn.ghost{background:transparent;color:var(--ink-soft);box-shadow:none;border:2px solid var(--line);}
|
|
352
352
|
.btn.ghost:hover{border-color:var(--accent);color:var(--accent-deep);}
|
|
353
|
+
/* a disabled ghost button must LOOK disabled — the same .btn:disabled-vs-.btn.ghost
|
|
354
|
+
specificity tie .btn.danger:disabled fixed below, applied to "Applied" on the
|
|
355
|
+
map's card (R10, task 1002286: it kept full-strength ghost styling and only the
|
|
356
|
+
cursor said it was inert, task 1003774). */
|
|
357
|
+
.btn.ghost:disabled{background:transparent;color:color-mix(in srgb,var(--ink) 40%,var(--bg-deep));border-color:color-mix(in srgb,var(--ink) 18%,transparent);box-shadow:none;cursor:not-allowed;transform:none;}
|
|
358
|
+
.btn.ghost:disabled:hover{border-color:color-mix(in srgb,var(--ink) 18%,transparent);color:color-mix(in srgb,var(--ink) 40%,var(--bg-deep));}
|
|
353
359
|
.btn.small{font-size:13.5px;padding:8px 16px;}
|
|
354
360
|
.btn.danger{background:transparent;color:var(--err);box-shadow:none;border:2px solid color-mix(in srgb,var(--err) 35%,transparent);}
|
|
355
361
|
.btn.danger:hover{background:var(--err-soft);border-color:var(--err);}
|
|
@@ -1162,6 +1168,10 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1162
1168
|
underlined accent-deep text link (the footer-link grammar) */
|
|
1163
1169
|
.projCard .cardActions{order:9;display:flex;align-items:center;justify-content:flex-start;gap:22px;flex-wrap:wrap;
|
|
1164
1170
|
margin:24px 0 0;padding:0 26px;}
|
|
1171
|
+
/* the apply outcome sits UNDER the buttons, never beside them (R10, task 1002286 /
|
|
1172
|
+
1003774): flex-basis:100% forces the wrap this row's own flex-wrap already allows,
|
|
1173
|
+
so the message never lands in the gap next to a short "Applied" label. */
|
|
1174
|
+
.projCard .cardActions .projMsg{flex-basis:100%;}
|
|
1165
1175
|
.projCard .cardOpen{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:0 30px;
|
|
1166
1176
|
border-radius:var(--radius-pill);background:var(--accent);color:var(--accent-on);font-family:var(--font-display);
|
|
1167
1177
|
font-size:16px;font-weight:500;text-transform:lowercase;text-decoration:none;
|
|
@@ -1309,6 +1319,32 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1309
1319
|
#view-mine .mineEmpty{font-weight:400;color:var(--ink-soft);}
|
|
1310
1320
|
#view-mine .linklike,#view-manage .linklike{font-weight:400;color:var(--ink-soft);}
|
|
1311
1321
|
|
|
1322
|
+
/* ── invitations · projects you build on · join by URL (task 1003774) — the
|
|
1323
|
+
#view-manage .mSec heading voice, without that section's two-column grid
|
|
1324
|
+
(this content is a stacked list, not a label/value pair). ── */
|
|
1325
|
+
.mineExtra section{margin-top:30px;padding-top:26px;border-top:1px solid var(--rule-soft);}
|
|
1326
|
+
.mineExtra h2{font-family:var(--font-display);font-weight:300;font-size:clamp(20px,2.2vw,24px);letter-spacing:-.02em;line-height:1.15;text-transform:lowercase;color:var(--ink);margin:0 0 14px;}
|
|
1327
|
+
.mineRows{list-style:none;display:flex;flex-direction:column;gap:0;margin:0;padding:0;}
|
|
1328
|
+
.mineRow{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 0;border-top:1px solid var(--rule-soft);}
|
|
1329
|
+
.mineRows .mineRow:first-child{border-top:0;}
|
|
1330
|
+
.mineRow__main{min-width:0;flex:1 1 auto;}
|
|
1331
|
+
.mineRow__name{font-size:15px;font-weight:500;color:var(--ink);}
|
|
1332
|
+
.mineRow__host{display:block;margin-top:2px;font-family:var(--font-mono);font-size:12.5px;color:var(--ink-faint);overflow-wrap:anywhere;}
|
|
1333
|
+
.mineRow__hint{margin:2px 0 0;font-size:12.5px;color:var(--ink-faint);}
|
|
1334
|
+
.mineRow__actions{display:flex;align-items:center;gap:10px;flex:none;}
|
|
1335
|
+
/* a two-button action group (Open project + Decline) has no room beside the
|
|
1336
|
+
name at phone width — drop it to its own line rather than squeeze the name
|
|
1337
|
+
and hint past their box (found by render-check.js at 320px, task 1003774) */
|
|
1338
|
+
@media (max-width:480px){
|
|
1339
|
+
.mineRow{flex-wrap:wrap;}
|
|
1340
|
+
.mineRow__actions{flex-basis:100%;justify-content:flex-start;margin-top:8px;}
|
|
1341
|
+
}
|
|
1342
|
+
.mineJoinRow{display:flex;gap:10px;flex-wrap:wrap;}
|
|
1343
|
+
.mineJoinRow input{flex:1 1 260px;min-width:0;min-height:44px;padding:0 16px;font:inherit;font-size:15px;color:var(--ink);background:var(--bg-card);border:1px solid var(--ctl-line);border-radius:var(--radius-pill);}
|
|
1344
|
+
.mineJoinRow input::placeholder{color:var(--ink-faint);}
|
|
1345
|
+
.mineJoinNote{display:block;width:100%;box-sizing:border-box;margin-top:10px;min-height:64px;padding:12px 16px;font:inherit;font-size:14px;line-height:1.5;color:var(--ink);background:var(--bg-card);border:1px solid var(--ctl-line);border-radius:14px;resize:vertical;}
|
|
1346
|
+
.mineJoinNote::placeholder{color:var(--ink-faint);}
|
|
1347
|
+
|
|
1312
1348
|
/* ── the ledger row: planet · name and chip · the facts · the rail · the
|
|
1313
1349
|
doors. The planet and the doors are fixed widths; the three middle tracks
|
|
1314
1350
|
share the rest by ratio with NO content minimum, so every row lays its
|
|
@@ -655,6 +655,36 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
655
655
|
</section>
|
|
656
656
|
<ul class="projects" id="projectsList"></ul>
|
|
657
657
|
<p class="finePrint">Buttons here ask the platform to act; each row reports only what was verified, and when. A standup keeps going even with this tab closed.</p>
|
|
658
|
+
|
|
659
|
+
<!-- ══ INVITATIONS · PROJECTS YOU BUILD ON · JOIN BY URL (task 1003774) ══
|
|
660
|
+
Restores what the hall's deleted my-projects.js rendered (task 1003308,
|
|
661
|
+
ADR 0141 §3). No Accept button — ADR 0335 retired that hub-only flip as
|
|
662
|
+
a dead end; an invite is a link into the project instead. ══ -->
|
|
663
|
+
<div class="mineExtra">
|
|
664
|
+
<section id="mineInvites" hidden aria-labelledby="mineInvitesH">
|
|
665
|
+
<h2 id="mineInvitesH">Invitations</h2>
|
|
666
|
+
<ul class="mineRows" id="invitesList"></ul>
|
|
667
|
+
</section>
|
|
668
|
+
|
|
669
|
+
<section aria-labelledby="mineBuildingH">
|
|
670
|
+
<h2 id="mineBuildingH">Projects you build on</h2>
|
|
671
|
+
<p class="mineEmpty" id="buildingEmpty" hidden>You’re not building on any other projects yet.</p>
|
|
672
|
+
<ul class="mineRows" id="buildingList"></ul>
|
|
673
|
+
</section>
|
|
674
|
+
|
|
675
|
+
<section aria-labelledby="mineJoinH">
|
|
676
|
+
<h2 id="mineJoinH">Join a project</h2>
|
|
677
|
+
<p class="lede">Paste the project’s address — its owner sees your request in their own queue.</p>
|
|
678
|
+
<div class="mineJoinRow">
|
|
679
|
+
<label class="vh" for="joinOrigin">Project address</label>
|
|
680
|
+
<input id="joinOrigin" type="url" placeholder="https://project.example.com" autocomplete="off">
|
|
681
|
+
<button id="joinBtn" type="button" class="btn small">Request to join</button>
|
|
682
|
+
</div>
|
|
683
|
+
<label class="vh" for="joinNote">Add a note (optional)</label>
|
|
684
|
+
<textarea id="joinNote" class="mineJoinNote" rows="2" maxlength="400" placeholder="A note for the owner (optional) — what you’d like to work on, who you are."></textarea>
|
|
685
|
+
<p class="projMsg" id="joinStatus" role="status" aria-live="polite"></p>
|
|
686
|
+
</section>
|
|
687
|
+
</div>
|
|
658
688
|
</section>
|
|
659
689
|
</div>
|
|
660
690
|
</div>
|
|
@@ -1519,8 +1549,10 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1519
1549
|
$('signinTop').href = '/api/bongos/auth/web/start?return=/projects%3Fview%3D' + v +
|
|
1520
1550
|
idPart.replace('&', '%26').replace('=', '%3D') + (v === 'new' ? '&repos=1' : '');
|
|
1521
1551
|
/* the mine view is the only one that polls; it re-reads on entry so a
|
|
1522
|
-
card that changed while you were on the map is not stale
|
|
1523
|
-
|
|
1552
|
+
card that changed while you were on the map is not stale — including
|
|
1553
|
+
the membership + invitations lists (task 1003774), the same re-read
|
|
1554
|
+
refreshList() already does for owned instances */
|
|
1555
|
+
if (v === 'mine' && me) { refreshList(); loadMemberships(); loadInvites(); }
|
|
1524
1556
|
/* manage re-reads its project on entry — no timer lives here (C8) */
|
|
1525
1557
|
if (v === 'manage') loadManage();
|
|
1526
1558
|
/* owned chips refresh on re-entry, never on a timer — C8 keeps the map quiet */
|
|
@@ -2193,30 +2225,24 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2193
2225
|
btn.disabled = true;
|
|
2194
2226
|
btn.textContent = 'Applying…';
|
|
2195
2227
|
var msg = $('cardApplyMsg');
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
body: JSON.stringify({ origin: p.origin }),
|
|
2199
|
-
}).then(function (res) {
|
|
2200
|
-
if (res.status === 401) {
|
|
2228
|
+
postJoinRequest(p.origin, null).then(function (res) {
|
|
2229
|
+
if (res.unauthenticated) {
|
|
2201
2230
|
btn.textContent = 'Apply to join'; btn.disabled = false;
|
|
2202
2231
|
if (msg) { msg.className = 'projMsg err'; msg.innerHTML = signInAgain(); }
|
|
2203
2232
|
return;
|
|
2204
2233
|
}
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
msg.textContent = (res.ok && data && data.message) || e.message || 'Could not reach that project — try again in a moment.';
|
|
2218
|
-
}
|
|
2219
|
-
});
|
|
2234
|
+
if (res.ok && res.data && res.data.status === 'requested') {
|
|
2235
|
+
btn.textContent = 'Applied';
|
|
2236
|
+
if (msg) { msg.className = 'projMsg ok'; msg.textContent = res.data.message || 'Requested.'; }
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
btn.textContent = 'Apply to join';
|
|
2240
|
+
btn.disabled = false;
|
|
2241
|
+
var e = apiError(res.data, res.status);
|
|
2242
|
+
if (msg) {
|
|
2243
|
+
msg.className = 'projMsg' + (res.ok ? '' : ' err');
|
|
2244
|
+
msg.textContent = (res.ok && res.data && res.data.message) || e.message || 'Could not reach that project — try again in a moment.';
|
|
2245
|
+
}
|
|
2220
2246
|
}).catch(function () {
|
|
2221
2247
|
btn.textContent = 'Apply to join';
|
|
2222
2248
|
btn.disabled = false;
|
|
@@ -2260,7 +2286,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2260
2286
|
(p.isSelf ? 'Home →' : 'Open site →') + '</a>'
|
|
2261
2287
|
: '') + (hall
|
|
2262
2288
|
? '<a class="cardHall" id="cardHall" href="' + esc(hall) + '" target="_blank" rel="noopener">' + HALL_GLYPH + 'Builders\' hall</a>'
|
|
2263
|
-
: '') + applyBtn + (applyBtn ? '<p class="projMsg" id="cardApplyMsg"></p>' : '');
|
|
2289
|
+
: '') + applyBtn + (applyBtn ? '<p class="projMsg" id="cardApplyMsg" role="status" aria-live="polite"></p>' : '');
|
|
2264
2290
|
var applyEl = $('cardApply');
|
|
2265
2291
|
if (applyEl && !applyEl.disabled) applyEl.addEventListener('click', function () { submitApply(applyEl, p); });
|
|
2266
2292
|
closeMenus();
|
|
@@ -2512,13 +2538,23 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2512
2538
|
401 (signed out) is not an error here. */
|
|
2513
2539
|
function loadMemberships() {
|
|
2514
2540
|
return fetch(API + '/my-projects', { credentials: 'same-origin' })
|
|
2515
|
-
.then(function (res) {
|
|
2516
|
-
|
|
2541
|
+
.then(function (res) {
|
|
2542
|
+
return res.ok
|
|
2543
|
+
? res.json().then(function (d) { return { ok: true, d: d }; })
|
|
2544
|
+
: { ok: false, d: { owned: [], building: [], applied: [] } };
|
|
2545
|
+
})
|
|
2546
|
+
.then(function (r) {
|
|
2547
|
+
var d = r.d;
|
|
2517
2548
|
mineOrigins = {};
|
|
2518
2549
|
appliedOrigins = {};
|
|
2519
2550
|
(d.owned || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'owner'; });
|
|
2520
2551
|
(d.building || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'builder'; });
|
|
2521
2552
|
(d.applied || []).forEach(function (m) { if (m.origin) appliedOrigins[hostOf(m.origin)] = true; });
|
|
2553
|
+
/* only a genuinely successful read may repaint "Projects you build on" —
|
|
2554
|
+
a transient failure resets the map's origin maps (as before) but must
|
|
2555
|
+
not overwrite the list with a false "not building on anything" empty
|
|
2556
|
+
state on every re-entry to this view (task 1003774) */
|
|
2557
|
+
if (r.ok) renderBuildingList(d.building || []);
|
|
2522
2558
|
})
|
|
2523
2559
|
.catch(function () { mineOrigins = {}; appliedOrigins = {}; });
|
|
2524
2560
|
}
|
|
@@ -2546,6 +2582,173 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2546
2582
|
.catch(function () { ownedInstances = []; ownedByHost = {}; });
|
|
2547
2583
|
}
|
|
2548
2584
|
|
|
2585
|
+
/* ── invitations / building-on rows + join-by-URL (task 1003774) ──
|
|
2586
|
+
Same row shape for both lists: a name, its host in the mono face, and one
|
|
2587
|
+
action on the right. textContent-only, matching the deleted hall page
|
|
2588
|
+
this restores. */
|
|
2589
|
+
function mineEl(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
2590
|
+
function mineRow(p) {
|
|
2591
|
+
var row = mineEl('li', 'mineRow');
|
|
2592
|
+
var main = mineEl('div', 'mineRow__main');
|
|
2593
|
+
var name = mineEl('div', 'mineRow__name');
|
|
2594
|
+
name.textContent = p.name || p.client_id;
|
|
2595
|
+
main.appendChild(name);
|
|
2596
|
+
var host = hostOf(p.origin);
|
|
2597
|
+
if (host) { var h = mineEl('span', 'mineRow__host'); h.textContent = host; main.appendChild(h); }
|
|
2598
|
+
row.appendChild(main);
|
|
2599
|
+
return row;
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
/* the leave/decline shape: disable, POST, drop the row on success, restore
|
|
2603
|
+
the button on failure. `onRemoved` carries whatever else the caller needs
|
|
2604
|
+
after the row is gone (the empty-state flip, the mineOrigins cleanup). */
|
|
2605
|
+
function postThenRemoveRow(url, row, btn, idleLabel, onRemoved) {
|
|
2606
|
+
btn.disabled = true;
|
|
2607
|
+
if (idleLabel) btn.textContent = idleLabel.busy;
|
|
2608
|
+
fetch(url, { method: 'POST', credentials: 'same-origin' })
|
|
2609
|
+
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
|
2610
|
+
.then(function () { row.remove(); if (onRemoved) onRemoved(); })
|
|
2611
|
+
.catch(function () { btn.disabled = false; if (idleLabel) btn.textContent = idleLabel.idle; });
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
function renderBuildingList(rows) {
|
|
2615
|
+
var ul = $('buildingList');
|
|
2616
|
+
ul.textContent = '';
|
|
2617
|
+
(rows || []).forEach(function (m) {
|
|
2618
|
+
var row = mineRow(m);
|
|
2619
|
+
var actions = mineEl('div', 'mineRow__actions');
|
|
2620
|
+
var btn = document.createElement('button');
|
|
2621
|
+
btn.type = 'button';
|
|
2622
|
+
btn.className = 'btn small ghost';
|
|
2623
|
+
btn.textContent = 'Leave';
|
|
2624
|
+
btn.addEventListener('click', function () {
|
|
2625
|
+
postThenRemoveRow(
|
|
2626
|
+
API + '/my-projects/' + encodeURIComponent(m.client_id) + '/leave', row, btn,
|
|
2627
|
+
{ busy: 'Leaving…', idle: 'Leave' },
|
|
2628
|
+
/* the map reads mineOrigins for its "you build here" pill and Apply
|
|
2629
|
+
button — clear this project's entry so a leave takes effect there
|
|
2630
|
+
on the next repaint too, not just in this list (task 1003774) */
|
|
2631
|
+
function () {
|
|
2632
|
+
delete mineOrigins[hostOf(m.origin)];
|
|
2633
|
+
if (!ul.children.length) $('buildingEmpty').hidden = false;
|
|
2634
|
+
}
|
|
2635
|
+
);
|
|
2636
|
+
});
|
|
2637
|
+
actions.appendChild(btn);
|
|
2638
|
+
row.appendChild(actions);
|
|
2639
|
+
ul.appendChild(row);
|
|
2640
|
+
});
|
|
2641
|
+
$('buildingEmpty').hidden = !!(rows && rows.length);
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
/* Each invitation renders as a way IN, never an Accept button: the hub's own
|
|
2645
|
+
accept flip never admits anyone to the project's own instance (ADR 0335)
|
|
2646
|
+
— signing in there is what actually accepts it (task 1002969). Decline
|
|
2647
|
+
still works; it only clears the hub's own pending row. */
|
|
2648
|
+
function renderInvitesList(invites) {
|
|
2649
|
+
var section = $('mineInvites');
|
|
2650
|
+
var ul = $('invitesList');
|
|
2651
|
+
ul.textContent = '';
|
|
2652
|
+
if (!invites || !invites.length) { section.hidden = true; return; }
|
|
2653
|
+
invites.forEach(function (inv) {
|
|
2654
|
+
var row = mineRow(inv);
|
|
2655
|
+
var hint = mineEl('p', 'mineRow__hint');
|
|
2656
|
+
hint.textContent = 'Sign in there to accept.';
|
|
2657
|
+
row.querySelector('.mineRow__main').appendChild(hint);
|
|
2658
|
+
var actions = mineEl('div', 'mineRow__actions');
|
|
2659
|
+
var href = safeHttpUrl(inv.origin);
|
|
2660
|
+
if (href) {
|
|
2661
|
+
var open = document.createElement('a');
|
|
2662
|
+
open.className = 'btn small';
|
|
2663
|
+
open.href = href;
|
|
2664
|
+
open.target = '_blank';
|
|
2665
|
+
open.rel = 'noopener';
|
|
2666
|
+
open.textContent = 'Open project →';
|
|
2667
|
+
actions.appendChild(open);
|
|
2668
|
+
}
|
|
2669
|
+
var decline = document.createElement('button');
|
|
2670
|
+
decline.type = 'button';
|
|
2671
|
+
decline.className = 'btn small ghost';
|
|
2672
|
+
decline.textContent = 'Decline';
|
|
2673
|
+
decline.addEventListener('click', function () {
|
|
2674
|
+
postThenRemoveRow(
|
|
2675
|
+
API + '/my-projects/invites/' + encodeURIComponent(inv.client_id) + '/decline', row, decline, null,
|
|
2676
|
+
function () { if (!ul.children.length) section.hidden = true; }
|
|
2677
|
+
);
|
|
2678
|
+
});
|
|
2679
|
+
actions.appendChild(decline);
|
|
2680
|
+
row.appendChild(actions);
|
|
2681
|
+
ul.appendChild(row);
|
|
2682
|
+
});
|
|
2683
|
+
section.hidden = false;
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
function loadInvites() {
|
|
2687
|
+
return fetch(API + '/my-projects/invites', { credentials: 'same-origin' })
|
|
2688
|
+
.then(function (res) { return res.ok ? res.json() : { invites: [] }; })
|
|
2689
|
+
.then(function (d) { renderInvitesList(d.invites || []); })
|
|
2690
|
+
.catch(function () { $('mineInvites').hidden = true; });
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
/* the one relay both the map's per-card Apply (submitApply) and the
|
|
2694
|
+
address-typed join field drive — POST /my-projects/join, the 401 case and
|
|
2695
|
+
the 'requested' echo update kept in ONE place (task 1003774). Keys the
|
|
2696
|
+
echo off the server's OWN resolved origin where the response carries one:
|
|
2697
|
+
it need not match what the visitor typed byte-for-byte (a trailing
|
|
2698
|
+
slash, a different case) to be the same project the map's catalog names. */
|
|
2699
|
+
function postJoinRequest(origin, note) {
|
|
2700
|
+
return fetch(API + '/my-projects/join', {
|
|
2701
|
+
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
2702
|
+
body: JSON.stringify(note ? { origin: origin, note: note } : { origin: origin }),
|
|
2703
|
+
}).then(function (res) {
|
|
2704
|
+
if (res.status === 401) return { unauthenticated: true };
|
|
2705
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
2706
|
+
if (res.ok && data && data.status === 'requested') {
|
|
2707
|
+
appliedOrigins[hostOf((data.project && data.project.origin) || origin)] = true;
|
|
2708
|
+
}
|
|
2709
|
+
return { ok: res.ok, status: res.status, data: data };
|
|
2710
|
+
});
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
/* Join by URL, with the applicant's own optional note (task 1002330). */
|
|
2715
|
+
$('joinBtn').addEventListener('click', function () {
|
|
2716
|
+
var origin = ($('joinOrigin').value || '').trim();
|
|
2717
|
+
var status = $('joinStatus');
|
|
2718
|
+
if (!origin) { status.className = 'projMsg err'; status.textContent = 'Enter a project’s address.'; return; }
|
|
2719
|
+
/* a bare host ("grove.cloudbongos.com") never resolves — the server's own
|
|
2720
|
+
origin lookup is an exact match against the registered https:// form —
|
|
2721
|
+
and would otherwise surface as the generic "could not reach" message
|
|
2722
|
+
for what is really a typo. Assume https:// rather than making the
|
|
2723
|
+
visitor retype it. */
|
|
2724
|
+
if (!/^https?:\/\//i.test(origin)) origin = 'https://' + origin;
|
|
2725
|
+
var note = ($('joinNote').value || '').trim();
|
|
2726
|
+
var btn = $('joinBtn');
|
|
2727
|
+
btn.disabled = true;
|
|
2728
|
+
status.className = 'projMsg';
|
|
2729
|
+
status.textContent = 'Requesting…';
|
|
2730
|
+
postJoinRequest(origin, note).then(function (res) {
|
|
2731
|
+
btn.disabled = false;
|
|
2732
|
+
if (res.unauthenticated) { status.className = 'projMsg err'; status.innerHTML = signInAgain(); return; }
|
|
2733
|
+
if (res.ok && res.data && res.data.message) {
|
|
2734
|
+
status.className = 'projMsg' + (res.data.status === 'requested' ? ' ok' : '');
|
|
2735
|
+
status.textContent = res.data.message;
|
|
2736
|
+
if (res.data.status === 'requested') {
|
|
2737
|
+
$('joinOrigin').value = '';
|
|
2738
|
+
$('joinNote').value = '';
|
|
2739
|
+
}
|
|
2740
|
+
} else {
|
|
2741
|
+
var e = apiError(res.data, res.status);
|
|
2742
|
+
status.className = 'projMsg err';
|
|
2743
|
+
status.textContent = e.message || 'Could not reach that project — try again in a moment.';
|
|
2744
|
+
}
|
|
2745
|
+
}).catch(function () {
|
|
2746
|
+
btn.disabled = false;
|
|
2747
|
+
status.className = 'projMsg err';
|
|
2748
|
+
status.textContent = 'Couldn’t reach the platform — check your connection and try again.';
|
|
2749
|
+
});
|
|
2750
|
+
});
|
|
2751
|
+
|
|
2549
2752
|
$('onlyMine').addEventListener('change', applyMapFilter);
|
|
2550
2753
|
$('mapQ').addEventListener('input', applyMapFilter);
|
|
2551
2754
|
/* ── the sort, off the column head (task 1003356) ──
|
|
@@ -7953,7 +8156,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7953
8156
|
$('gate').hidden = true;
|
|
7954
8157
|
$('mine').hidden = false;
|
|
7955
8158
|
load();
|
|
7956
|
-
return Promise.all([loadMemberships(), loadOwnedInstances()]);
|
|
8159
|
+
return Promise.all([loadMemberships(), loadOwnedInstances(), loadInvites()]);
|
|
7957
8160
|
})
|
|
7958
8161
|
.catch(function (err) {
|
|
7959
8162
|
me = null;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"invites":[{"client_id":"cb-grove","name":"The Grove","origin":"https://grove.cloudbongos.com","invited_at":"2026-08-01T12:00:00.000Z"}]}
|
|
@@ -105,7 +105,7 @@ const FEEDS = {
|
|
|
105
105
|
'/public/leaderboard': 'leaderboard',
|
|
106
106
|
'/projects/featured': 'featured',
|
|
107
107
|
};
|
|
108
|
-
const AUTHED = { '/my-projects': 'my-projects' };
|
|
108
|
+
const AUTHED = { '/my-projects': 'my-projects', '/my-projects/invites': 'my-projects__invites' };
|
|
109
109
|
const UNAUTH = { error: { code: 'unauthenticated', message: 'unauthenticated' } };
|
|
110
110
|
|
|
111
111
|
// GET /provisioning/starter-bundles as the create wizard's module picker reads it
|
|
@@ -295,6 +295,17 @@ const server = http.createServer((req, res) => {
|
|
|
295
295
|
if (rest === '/provisioning/instances' && req.method === 'GET') return AUTH ? instances(res, null, false) : json(res, 401, UNAUTH);
|
|
296
296
|
const one = rest.match(/^\/provisioning\/instances\/(\d+)(\/settings)?$/);
|
|
297
297
|
if (one && req.method === 'GET') return AUTH ? instances(res, one[1], !!one[2]) : json(res, 401, UNAUTH);
|
|
298
|
+
// the hub's own cross-project surface (task 1003774): the requested/leave/
|
|
299
|
+
// decline receipts a real render or probe of the my-projects block drives,
|
|
300
|
+
// so those buttons are not stuck picturing only their failure branch.
|
|
301
|
+
if (rest === '/my-projects/join' && req.method === 'POST') {
|
|
302
|
+
return AUTH ? readBody(req, (body) => json(res, 200, {
|
|
303
|
+
ok: true, status: 'requested', project: { name: body.origin || 'a project', origin: body.origin || null },
|
|
304
|
+
message: 'Requested access. The project’s owner admits new builders; once approved, sign in to enter.',
|
|
305
|
+
})) : json(res, 401, UNAUTH);
|
|
306
|
+
}
|
|
307
|
+
if (/^\/my-projects\/[^/]+\/leave$/.test(rest) && req.method === 'POST') return AUTH ? json(res, 200, { ok: true, left: true }) : json(res, 401, UNAUTH);
|
|
308
|
+
if (/^\/my-projects\/invites\/[^/]+\/decline$/.test(rest) && req.method === 'POST') return AUTH ? json(res, 200, { ok: true, accepted: false }) : json(res, 401, UNAUTH);
|
|
298
309
|
// the manage flows (task 1003345): every door on a row and on the manage page only ASKS
|
|
299
310
|
// (ADR 0111) — the stub answers each ask with the route's receipt so the probes can drive
|
|
300
311
|
// attach / restart / teardown / revive / join end to end; nothing changes in the fixture
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1028",
|
|
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.1028",
|
|
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.1028",
|
|
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/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1028'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/card_apply_ui.mjs
CHANGED
|
@@ -52,6 +52,11 @@ const REGION = SCRIPT.slice(START, END);
|
|
|
52
52
|
for (const fn of ['function cardApplyHtml(', 'function submitApply(']) {
|
|
53
53
|
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
54
54
|
}
|
|
55
|
+
// submitApply drives the one join relay the address-typed join field shares
|
|
56
|
+
const RELAY_START = SCRIPT.indexOf('function postJoinRequest(');
|
|
57
|
+
const RELAY_END = SCRIPT.indexOf('\n }', RELAY_START) + 4;
|
|
58
|
+
assert.ok(RELAY_START > 0 && RELAY_END > RELAY_START, 'postJoinRequest must be findable');
|
|
59
|
+
const RELAY = SCRIPT.slice(RELAY_START, RELAY_END);
|
|
55
60
|
|
|
56
61
|
function boot({ me = { rank: 'thetes' }, appliedOrigins = {}, answers = [] } = {}) {
|
|
57
62
|
const requests = [];
|
|
@@ -93,6 +98,7 @@ function boot({ me = { rank: 'thetes' }, appliedOrigins = {}, answers = [] } = {
|
|
|
93
98
|
};
|
|
94
99
|
sandbox.globalThis = sandbox;
|
|
95
100
|
vm.createContext(sandbox);
|
|
101
|
+
vm.runInContext(RELAY, sandbox, { filename: 'projects.html#join-relay' });
|
|
96
102
|
vm.runInContext(REGION, sandbox, { filename: 'projects.html#card-apply' });
|
|
97
103
|
|
|
98
104
|
const settle = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
|
|
@@ -240,6 +246,7 @@ test('loadMemberships reads GET /my-projects\' applied echo into appliedOrigins,
|
|
|
240
246
|
API: '/api/bongos',
|
|
241
247
|
mineOrigins: {},
|
|
242
248
|
appliedOrigins: {},
|
|
249
|
+
renderBuildingList: () => {},
|
|
243
250
|
hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
|
|
244
251
|
fetch: () => Promise.resolve({
|
|
245
252
|
ok: true,
|
|
@@ -289,7 +296,7 @@ test('a fetch failure clears appliedOrigins too, not just mineOrigins', () => {
|
|
|
289
296
|
|
|
290
297
|
test('openCard builds the Apply button from cardApplyHtml and wires its click to submitApply', () => {
|
|
291
298
|
assert.match(SCRIPT, /var applyBtn = cardApplyHtml\(p, rel\);/);
|
|
292
|
-
assert.match(SCRIPT, /\) \+ applyBtn \+ \(applyBtn \? '<p class="projMsg" id="cardApplyMsg"><\/p>' : ''\);/,
|
|
299
|
+
assert.match(SCRIPT, /\) \+ applyBtn \+ \(applyBtn \? '<p class="projMsg" id="cardApplyMsg" role="status" aria-live="polite"><\/p>' : ''\);/,
|
|
293
300
|
'the message line only ships when there is a button that could produce one');
|
|
294
301
|
assert.match(SCRIPT, /var applyEl = \$\('cardApply'\);/);
|
|
295
302
|
assert.match(SCRIPT, /if \(applyEl && !applyEl\.disabled\) applyEl\.addEventListener\('click', function \(\) \{ submitApply\(applyEl, p\); \}\);/,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// tests/curve_controls_delegation.mjs — the Standing → Progress controls are
|
|
2
|
+
// actually heard (task 1004079).
|
|
3
|
+
//
|
|
4
|
+
// The bug: the "You / All builders" scope toggle and the "Kind of work" picker
|
|
5
|
+
// are drawn into #curve-scope-note, which sits in the section's lede <p> — a
|
|
6
|
+
// SIBLING of #curve-body, not a child of it. builders.js delegated both
|
|
7
|
+
// listeners from #curve-body, so a click or change on either control bubbled
|
|
8
|
+
// straight past the listener and the Progress tab only ever showed its first
|
|
9
|
+
// view. Every chart still rendered, so nothing looked broken until you pressed
|
|
10
|
+
// a button.
|
|
11
|
+
//
|
|
12
|
+
// What is pinned: whatever element builders.js delegates each curve control
|
|
13
|
+
// from must, in the widget markup hall-widgets.js mounts, be an ANCESTOR of
|
|
14
|
+
// #curve-scope-note (or be the section itself, which contains everything).
|
|
15
|
+
// There is no jsdom in this repo, so containment is checked by a balanced-tag
|
|
16
|
+
// scan over the markup string.
|
|
17
|
+
|
|
18
|
+
import test from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
26
|
+
const widgetsSrc = fs.readFileSync(path.join(PUB, 'hall-widgets.js'), 'utf8');
|
|
27
|
+
const buildersSrc = fs.readFileSync(path.join(PUB, 'builders.js'), 'utf8');
|
|
28
|
+
|
|
29
|
+
const SECTION_ID = 'analytics-scroll';
|
|
30
|
+
|
|
31
|
+
// The Progress widget's markup: the concatenated string literals of its w() call.
|
|
32
|
+
function progressMarkup() {
|
|
33
|
+
const start = widgetsSrc.indexOf(`w('${SECTION_ID}'`);
|
|
34
|
+
assert.ok(start >= 0, `hall-widgets.js no longer registers '${SECTION_ID}'`);
|
|
35
|
+
const end = widgetsSrc.indexOf("{ view: 'standing'", start);
|
|
36
|
+
const call = widgetsSrc.slice(start, end);
|
|
37
|
+
return [...call.matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((m) => m[1]).slice(2).join('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// True if the element with id=`outer` encloses the element with id=`inner`.
|
|
41
|
+
function encloses(html, outer, inner) {
|
|
42
|
+
const open = new RegExp(`<([a-z0-9]+)[^>]*\\bid="${outer}"`).exec(html);
|
|
43
|
+
if (!open) return false;
|
|
44
|
+
const tag = open[1];
|
|
45
|
+
const innerAt = html.indexOf(`id="${inner}"`);
|
|
46
|
+
if (innerAt < open.index) return false;
|
|
47
|
+
const re = new RegExp(`<(/?)${tag}\\b[^>]*>`, 'g');
|
|
48
|
+
re.lastIndex = open.index;
|
|
49
|
+
let depth = 0;
|
|
50
|
+
for (let m; (m = re.exec(html));) {
|
|
51
|
+
depth += m[1] ? -1 : 1;
|
|
52
|
+
if (depth === 0) return innerAt < m.index;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The selector each curve-control listener is attached to in builders.js.
|
|
58
|
+
function delegationTarget(marker) {
|
|
59
|
+
const at = buildersSrc.indexOf(marker);
|
|
60
|
+
assert.ok(at >= 0, `builders.js no longer handles ${marker}`);
|
|
61
|
+
const before = buildersSrc.slice(0, at);
|
|
62
|
+
const all = [...before.matchAll(/\$\('#([\w-]+)'\)\?\.addEventListener\('(click|change)'/g)];
|
|
63
|
+
assert.ok(all.length, `no delegated listener found before ${marker}`);
|
|
64
|
+
return all[all.length - 1][1];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test('the controls render into #curve-scope-note, outside #curve-body', () => {
|
|
68
|
+
// The premise of the bug. If this ever changes the test below still holds,
|
|
69
|
+
// but the comment above would be describing a layout that no longer exists.
|
|
70
|
+
const html = progressMarkup();
|
|
71
|
+
assert.ok(html.includes('id="curve-scope-note"'));
|
|
72
|
+
assert.equal(encloses(html, 'curve-body', 'curve-scope-note'), false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
for (const [name, marker] of [
|
|
76
|
+
['scope toggle (You / All builders)', ".curve__scope-btn[data-scope]"],
|
|
77
|
+
['kind-of-work picker', "closest('#curve-kind')"],
|
|
78
|
+
]) {
|
|
79
|
+
test(`the ${name} listener is delegated from an ancestor of the control`, () => {
|
|
80
|
+
const html = progressMarkup();
|
|
81
|
+
const target = delegationTarget(marker);
|
|
82
|
+
assert.ok(
|
|
83
|
+
target === SECTION_ID || encloses(html, target, 'curve-scope-note'),
|
|
84
|
+
`builders.js listens on #${target}, which does not contain #curve-scope-note — the ${name} is never heard`,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
}
|