@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.
@@ -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 (Archon only). Delegated on the
1235
- // curve body since renderCurveBody replaces the toggle's innerHTML.
1236
- $('#curve-body')?.addEventListener('click', (e) => {
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
- $('#curve-body')?.addEventListener('change', (e) => {
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
- if (v === 'mine' && me) refreshList();
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
- fetch(API + '/my-projects/join', {
2197
- method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
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
- return jsonOrEmpty(res).then(function (data) {
2206
- if (res.ok && data && data.status === 'requested') {
2207
- appliedOrigins[hostOf(p.origin)] = true;
2208
- btn.textContent = 'Applied';
2209
- if (msg) { msg.className = 'projMsg ok'; msg.textContent = data.message || 'Requested.'; }
2210
- return;
2211
- }
2212
- btn.textContent = 'Apply to join';
2213
- btn.disabled = false;
2214
- var e = apiError(data, res.status);
2215
- if (msg) {
2216
- msg.className = 'projMsg' + (res.ok ? '' : ' err');
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) { return res.ok ? res.json() : { owned: [], building: [], applied: [] }; })
2516
- .then(function (d) {
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.1027",
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.1027",
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.1027",
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.1027'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');
@@ -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
+ }