@bongos/core 1.21.67 → 1.21.69

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.
@@ -10,7 +10,8 @@
10
10
  once, and every project's hall links out to it instead of keeping a copy.
11
11
  Hub-only: platform-server.js serves /account only where platform-identity
12
12
  is on, because every read and write below is one of that module's own-scoped
13
- routes (GET/PATCH /me/cross-project, PATCH /me/handle, PATCH /me/profile).
13
+ routes (GET/PATCH /me/cross-project, PATCH /me/handle, PATCH /me/profile,
14
+ GET /my-projects and POST /my-projects/:clientId/leave).
14
15
  Server-stamped like the footer doors: {{…}} tokens filled by landingPage(). -->
15
16
  <title>{{productName}} — Your account</title>
16
17
  <meta name="description" content="Your {{productName}} account: your handle, your name, your bio and links, and who can find you.">
@@ -245,6 +246,24 @@
245
246
  <p class="projMsg" id="recordMsg" role="status" aria-live="polite"></p>
246
247
  </form>
247
248
  </section>
249
+
250
+ <!-- PROJECTS YOU BUILD ON — GET /my-projects building[], Leave → POST
251
+ /my-projects/:clientId/leave (task 1003774). Moved here from
252
+ /projects?view=mine by task 1004277: the owner's call of 2026-09-25 put it
253
+ on the builder's own profile, own view only, and with no hub profile page
254
+ built (/u/<handle> was abandoned) this page is that own view (the owner's
255
+ pick, 2026-10-06). Cross-project data, so never on a project hall's seat
256
+ profile. The rows are cosmos.css's .mineRow, as on /invitations. -->
257
+ <section class="aSec" id="aBuilding" aria-labelledby="aBuilding-h">
258
+ <h2 id="aBuilding-h">projects you build on</h2>
259
+ <p class="aSide">The projects you have joined as a builder. Only you see this list.</p>
260
+ <div class="aBody">
261
+ <p class="aWhat" id="buildingLoading">Reading the projects you build on…</p>
262
+ <p class="aWhat" id="buildingEmpty" hidden>You’re not building on any other projects yet.</p>
263
+ <ul class="mineRows" id="buildingList"></ul>
264
+ <p class="projMsg" id="buildingMsg" role="status" aria-live="polite"></p>
265
+ </div>
266
+ </section>
248
267
  </div>
249
268
  </main>
250
269
 
@@ -323,6 +342,12 @@
323
342
  $('loading').hidden = true;
324
343
  $('door').hidden = true;
325
344
  $('acctBody').hidden = false;
345
+ // a link to one section (/projects' "Open your account" names #aBuilding)
346
+ // lands on it: the browser looked for the anchor while the body was hidden
347
+ var hash = window.location && window.location.hash;
348
+ var target = hash && document.getElementById(hash.slice(1));
349
+ if (target && typeof target.scrollIntoView === 'function') target.scrollIntoView();
350
+ loadBuilding();
326
351
  }, function () { showAlert('Couldn’t reach the server — check your connection and refresh.'); });
327
352
  }
328
353
 
@@ -530,6 +555,68 @@
530
555
  : 'Cross-project record shown on your public profile.');
531
556
  });
532
557
 
558
+ // ---- projects you build on (task 1004277; the rows and Leave came from
559
+ // /projects?view=mine, task 1003774). Read once the account is shown, on its
560
+ // own: a failed read says so and never paints a false "not building on
561
+ // anything". The map's mineOrigins clean-up stayed behind with the map —
562
+ // /projects re-reads GET /my-projects whenever it is opened. ----
563
+ function hostOf(origin) {
564
+ return String(origin || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase();
565
+ }
566
+ function rowEl(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
567
+ function loadBuilding() {
568
+ var msg = $('buildingMsg');
569
+ return request('GET', '/my-projects').then(function (res) {
570
+ $('buildingLoading').hidden = true;
571
+ if (!res.ok || !res.data) { say(msg, 'Couldn’t read the projects you build on — refresh in a moment.', 'err'); return; }
572
+ paintBuilding(res.data.building || []);
573
+ }, function () {
574
+ $('buildingLoading').hidden = true;
575
+ say(msg, 'Couldn’t reach the server — check your connection and refresh.', 'err');
576
+ });
577
+ }
578
+ function paintBuilding(rows) {
579
+ var ul = $('buildingList');
580
+ ul.textContent = '';
581
+ rows.forEach(function (m) { ul.appendChild(buildingRow(m)); });
582
+ $('buildingEmpty').hidden = rows.length > 0;
583
+ }
584
+ function buildingRow(m) {
585
+ var label = m.name || m.client_id;
586
+ var row = rowEl('li', 'mineRow');
587
+ var main = rowEl('div', 'mineRow__main');
588
+ var name = rowEl('div', 'mineRow__name');
589
+ name.textContent = label;
590
+ main.appendChild(name);
591
+ var host = hostOf(m.origin);
592
+ if (host) { var h = rowEl('span', 'mineRow__host'); h.textContent = host; main.appendChild(h); }
593
+ row.appendChild(main);
594
+ var actions = rowEl('div', 'mineRow__actions');
595
+ var btn = document.createElement('button');
596
+ btn.type = 'button';
597
+ btn.className = 'btn small ghost';
598
+ btn.textContent = 'Leave';
599
+ // every row's button reads Leave; the name says which project it leaves
600
+ btn.setAttribute('aria-label', 'Leave ' + label);
601
+ btn.addEventListener('click', function () { leave(m.client_id, label, row, btn); });
602
+ actions.appendChild(btn);
603
+ row.appendChild(actions);
604
+ return row;
605
+ }
606
+ function leave(clientId, label, row, btn) {
607
+ var msg = $('buildingMsg');
608
+ function restore() { btn.disabled = false; btn.textContent = 'Leave'; }
609
+ btn.disabled = true;
610
+ btn.textContent = 'Leaving…';
611
+ request('POST', '/my-projects/' + encodeURIComponent(clientId) + '/leave').then(function (res) {
612
+ if (res.status === 401) { restore(); sessionGone(msg); return; }
613
+ if (!res.ok) { restore(); say(msg, 'Couldn’t leave ' + label + '. Try again.', 'err'); return; }
614
+ row.remove();
615
+ say(msg, 'You left ' + label + '.', 'ok');
616
+ if (!$('buildingList').children.length) $('buildingEmpty').hidden = false;
617
+ }, function () { restore(); say(msg, 'Couldn’t reach the server. Try again.', 'err'); });
618
+ }
619
+
533
620
  // The identity read failing is not a sign-out. If the account itself was
534
621
  // read, the chip names it from that answer, so the way out is never lost.
535
622
  load().then(function () { return bar.ready; }).then(function () {
@@ -30,6 +30,15 @@
30
30
  { "name": "hiding the record saves hide_stats alone", "continue": true,
31
31
  "steps": [["click", "#aRecord .aOpt:nth-child(2)"], ["click", "#recordSave"], ["wait", 400]],
32
32
  "expect": { "text": [["#recordMsg", "Cross-project record hidden — removed from your profile and directories immediately."]], "checked": "input[name=record][value=hide]" } },
33
+ { "name": "projects you build on (task 1004277): one row per project, each with its own Leave", "auth": true, "width": 1440, "url": "/account#aBuilding",
34
+ "steps": [["wait", 500]],
35
+ "expect": { "count": [["#buildingList .mineRow", 2]], "attr": [["#buildingList .mineRow:first-child button", "aria-label", "Leave Example World"], ["#buildingList .mineRow:last-child button", "aria-label", "Leave Emersonian Circles"]], "hidden": ["#buildingEmpty"] } },
36
+ { "name": "Leave drops the row and says so", "continue": true,
37
+ "steps": [["click", "#buildingList .mineRow:first-child button"], ["wait", 300]],
38
+ "expect": { "count": [["#buildingList .mineRow", 1]], "text": [["#buildingMsg", "You left Example World."]], "hidden": ["#buildingEmpty"] } },
39
+ { "name": "leaving the last project shows the empty line", "continue": true,
40
+ "steps": [["click", "#buildingList .mineRow:first-child button"], ["wait", 300]],
41
+ "expect": { "count": [["#buildingList .mineRow", 0]], "text": [["#buildingMsg", "You left Emersonian Circles."], ["#buildingEmpty", "You’re not building on any other projects yet."]], "visible": ["#buildingEmpty"] } },
33
42
  { "name": "the hub's bar: the chip names the person, both bands closed, the page starts under the bar (task 1004455)", "auth": true, "width": 1440,
34
43
  "steps": [["wait", 400]],
35
44
  "expect": { "expanded": false, "top": [["#acct", 64]], "visible": ["#projTrig", "#who"], "hidden": ["#signinTop", "#menuProjects", "#menuProfile"], "text": [["#whoLogin", "builder"]], "count": [["#mapQ", 0]] } },
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "The account page's states for modules/ui-design/kit/render.js (task 1004259, ADR 0337 D4.3): signed out it is the sign-in door and nothing else; signed in it is the hub's bar (task 1004455: the identity chip from GET /me, the Projects band and the profile band with Account current) over the four sections, painted from the stub's one account (fixtures/me__cross-project.json — public, recruiters following the profile setting, a bio and two links). Dark only, like every apex page (ADR 0204). The private, hidden, not-yet-published account renders from account-private.states.json.",
2
+ "_": "The account page's states for modules/ui-design/kit/render.js (task 1004259, ADR 0337 D4.3): signed out it is the sign-in door and nothing else; signed in it is the hub's bar (task 1004455: the identity chip from GET /me, the Projects band and the profile band with Account current) over the five sections, painted from the stub's one account (fixtures/me__cross-project.json — public, recruiters following the profile setting, a bio and two links), the fifth being the projects you build on, from fixtures/my-projects.json (task 1004277; in-building lands on it through /account#aBuilding). Dark only, like every apex page (ADR 0204). The private, hidden, not-yet-published account renders from account-private.states.json.",
3
3
  "page": "/account",
4
4
  "surface": "public-landing",
5
5
  "modes": ["dark"],
@@ -12,6 +12,7 @@
12
12
  "in": { "auth": true, "actions": [["wait", 400]], "expect": { "visible": ["#acctBody", "#fName", "#fBio", "#fHandle", "#recWhat", "#recordLine", "#who"], "text": [["#whoLogin", "builder"], ["#recordLine", "Your record: 3 projects · 1000 credits · 19 works shipped · 10 karma."]], "hidden": ["#door", "#loading", "#signinTop", "#menuProjects", "#menuProfile"], "value": [["#fHandle", "builder"], ["#fName", "A Builder"], ["#fLinkX", "https://x.com/builder"]] } },
13
13
  "in-projects-band": { "auth": true, "actions": [["wait", 400], ["click", "#projTrig"], ["wait", 300]], "expect": { "visible": ["#menuProjects", "#mineCell"], "hidden": ["#menuProfile"] } },
14
14
  "in-profile-band": { "auth": true, "actions": [["wait", 400], ["click", "#who"], ["wait", 300]], "expect": { "visible": ["#menuProfile"], "hidden": ["#menuProjects"], "text": [["#bandWhoLogin", "builder"], ["#menuProfile [aria-current=\"page\"]", "Account"]] } },
15
+ "in-building": { "auth": true, "url": "/account#aBuilding", "actions": [["wait", 500]], "expect": { "visible": ["#aBuilding", "#buildingList"], "hidden": ["#buildingEmpty", "#buildingLoading"], "count": [["#buildingList .mineRow", 2]], "text": [["#buildingList .mineRow:first-child .mineRow__name", "Example World"], ["#buildingList .mineRow:first-child .mineRow__host", "example.com"]], "attr": [["#buildingList .mineRow:first-child button", "aria-label", "Leave Example World"]] } },
15
16
  "in-privacy": { "auth": true, "actions": [["wait", 400], ["click", "#aPrivacy .aOpt:nth-child(2)"], ["wait", 150]], "expect": { "checked": "input[name=visibility][value=private]", "text": [["#recWhat", "They will see your name, your picture, your roles, and whether you have been active in the last 30 days — never your credits, works shipped, karma, or the projects you build on. This is the one thing a recruiter can see while your profile page is private. Turning this off removes you from both recruiting surfaces — the recruiting directory, and a recruiter’s look-up of one builder — straight away. · For now this follows your profile page setting."]] } }
16
17
  },
17
18
  "reducedMotion": { "auth": true, "actions": [["wait", 300]] }
@@ -1305,6 +1305,9 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1305
1305
  (this content is a stacked list, not a label/value pair). ── */
1306
1306
  .mineExtra section{margin-top:30px;padding-top:26px;border-top:1px solid var(--rule-soft);}
1307
1307
  .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;}
1308
+ /* the mine view's two ways out (to /invitations and /account) are links in a
1309
+ sentence; inline-block lets each clear the kit's 24px target floor */
1310
+ #view-mine .mineExtra .lede a{display:inline-block;min-height:24px;}
1308
1311
  .mineRows{list-style:none;display:flex;flex-direction:column;gap:0;margin:0;padding:0;}
1309
1312
  .mineRow{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 0;border-top:1px solid var(--rule-soft);}
1310
1313
  .mineRows .mineRow:first-child{border-top:0;}
@@ -197,6 +197,12 @@
197
197
  return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : '';
198
198
  } catch (e) { return ''; }
199
199
  }
200
+ /* hostOf is for DISPLAY: it tolerates a bare or half-typed address and drops
201
+ a leading www, so a row reads "example.com". pickHost (the join picker,
202
+ below) is for MATCHING: new URL().host keeps www and any port, so it agrees
203
+ exactly with the origins the server stores, and the picker's mine/applied
204
+ maps and the "Applied" echo key on that. Folding one into the other would
205
+ either mislabel rows or miss matches. */
200
206
  function hostOf(origin) {
201
207
  return String(origin || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase();
202
208
  }
@@ -222,16 +228,16 @@
222
228
  return row;
223
229
  }
224
230
 
225
- /* the leave/decline shape: disable, POST, drop the row on success, restore
226
- the button on failure. `onRemoved` carries whatever else the caller needs
227
- after the row is gone (the empty-line flip). */
228
- function postThenRemoveRow(url, row, btn, idleLabel, onRemoved) {
231
+ /* the decline shape: disable, POST, drop the row on success, re-enable the
232
+ button on failure. `onRemoved` carries whatever else the caller needs after
233
+ the row is gone (the empty-line flip). The busy/idle label swap the /projects
234
+ copy carries is for its Leave button, which never moved here. */
235
+ function postThenRemoveRow(url, row, btn, onRemoved) {
229
236
  btn.disabled = true;
230
- if (idleLabel) btn.textContent = idleLabel.busy;
231
237
  fetch(url, { method: 'POST', credentials: 'same-origin' })
232
238
  .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
233
239
  .then(function () { row.remove(); if (onRemoved) onRemoved(); })
234
- .catch(function () { btn.disabled = false; if (idleLabel) btn.textContent = idleLabel.idle; });
240
+ .catch(function () { btn.disabled = false; });
235
241
  }
236
242
 
237
243
  /* Each invitation renders as a way IN, never an Accept button: the hub's own
@@ -266,7 +272,7 @@
266
272
  decline.textContent = 'Decline';
267
273
  decline.addEventListener('click', function () {
268
274
  postThenRemoveRow(
269
- API + '/my-projects/invites/' + encodeURIComponent(inv.client_id) + '/decline', row, decline, null,
275
+ API + '/my-projects/invites/' + encodeURIComponent(inv.client_id) + '/decline', row, decline,
270
276
  paintInvitesSection
271
277
  );
272
278
  });
@@ -856,8 +862,11 @@
856
862
  list.hidden = false;
857
863
  input.setAttribute('aria-expanded', 'true');
858
864
  }
865
+ /* once the feed is cached a keystroke renders once; only the first open
866
+ paints "Loading projects…" and then re-renders when the reads settle */
859
867
  function openPicker() {
860
868
  renderPicker();
869
+ if (picker.projects) return;
861
870
  loadPicker().then(function () {
862
871
  if (document.activeElement === $('joinOrigin')) renderPicker();
863
872
  });
@@ -955,20 +955,22 @@ summary{min-height:24px;padding:3px 0;}
955
955
  <ul class="projects" id="projectsList"></ul>
956
956
  <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>
957
957
 
958
- <!-- ══ PROJECTS YOU BUILD ON (task 1003774) · the way to INVITATIONS ══
958
+ <!-- ══ the ways to INVITATIONS and to PROJECTS YOU BUILD ON ══
959
959
  The invitations (a project's own, a guild's, a guild's engagements, the
960
960
  requests to a guild you own) and the join-by-address field have their own
961
- page since task 1004276 (the owner's call, ADR 0337 D5): /invitations. ══ -->
961
+ page since task 1004276 (the owner's call, ADR 0337 D5): /invitations.
962
+ The projects you build on, with Leave (task 1003774), moved to the
963
+ account page with task 1004277: the owner put them on the builder's own
964
+ profile, and /account is the hub's own view of it. ══ -->
962
965
  <div class="mineExtra">
963
966
  <section aria-labelledby="mineToInvitesH">
964
967
  <h2 id="mineToInvitesH">Invitations</h2>
965
968
  <p class="lede">Invitations from projects and guilds, and applying to a project by its address, have their own page. <a href="/invitations">Open your invitations</a></p>
966
969
  </section>
967
970
 
968
- <section aria-labelledby="mineBuildingH">
969
- <h2 id="mineBuildingH">Projects you build on</h2>
970
- <p class="mineEmpty" id="buildingEmpty" hidden>You’re not building on any other projects yet.</p>
971
- <ul class="mineRows" id="buildingList"></ul>
971
+ <section aria-labelledby="mineToAccountH">
972
+ <h2 id="mineToAccountH">Projects you build on</h2>
973
+ <p class="lede">The projects you build on, and leaving one, are on your account page. <a href="/account#aBuilding">Open your account</a></p>
972
974
  </section>
973
975
  </div>
974
976
  </section>
@@ -2522,10 +2524,10 @@ summary{min-height:24px;padding:3px 0;}
2522
2524
  : '%3Fview%3D' + v + idPart.replace('&', '%26').replace('=', '%3D');
2523
2525
  $('signinTop').href = '/api/bongos/auth/web/start?return=/projects' + backPart + '&repos=1';
2524
2526
  /* the mine view is the only one that polls; it re-reads on entry so a
2525
- card that changed while you were on the map is not stale — including
2526
- the membership list (task 1003774), the same re-read
2527
- refreshList() already does for owned instances */
2528
- if (v === 'mine' && me) { refreshList(); loadMemberships(); }
2527
+ card that changed while you were on the map is not stale. (It re-read
2528
+ the memberships too while it listed the projects you build on; that
2529
+ list is /account's since task 1004277.) */
2530
+ if (v === 'mine' && me) refreshList();
2529
2531
  /* manage re-reads its project on entry — no timer lives here (C8) */
2530
2532
  if (v === 'manage') loadManage();
2531
2533
  /* owned chips refresh on re-entry, never on a timer — C8 keeps the map quiet */
@@ -3525,23 +3527,13 @@ summary{min-height:24px;padding:3px 0;}
3525
3527
  401 (signed out) is not an error here. */
3526
3528
  function loadMemberships() {
3527
3529
  return fetch(API + '/my-projects', { credentials: 'same-origin' })
3528
- .then(function (res) {
3529
- return res.ok
3530
- ? res.json().then(function (d) { return { ok: true, d: d }; })
3531
- : { ok: false, d: { owned: [], building: [], applied: [] } };
3532
- })
3533
- .then(function (r) {
3534
- var d = r.d;
3530
+ .then(function (res) { return res.ok ? res.json() : { owned: [], building: [], applied: [] }; })
3531
+ .then(function (d) {
3535
3532
  mineOrigins = {};
3536
3533
  appliedOrigins = {};
3537
3534
  (d.owned || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'owner'; });
3538
3535
  (d.building || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'builder'; });
3539
3536
  (d.applied || []).forEach(function (m) { if (m.origin) appliedOrigins[hostOf(m.origin)] = true; });
3540
- /* only a genuinely successful read may repaint "Projects you build on" —
3541
- a transient failure resets the map's origin maps (as before) but must
3542
- not overwrite the list with a false "not building on anything" empty
3543
- state on every re-entry to this view (task 1003774) */
3544
- if (r.ok) renderBuildingList(d.building || []);
3545
3537
  })
3546
3538
  .catch(function () { mineOrigins = {}; appliedOrigins = {}; });
3547
3539
  }
@@ -3569,65 +3561,6 @@ summary{min-height:24px;padding:3px 0;}
3569
3561
  .catch(function () { ownedInstances = []; ownedByHost = {}; });
3570
3562
  }
3571
3563
 
3572
- /* ── the building-on rows (task 1003774; the invitation rows moved to
3573
- /invitations with task 1004276) ── Same row shape as that page's lists: a name, its host in the mono face, and one
3574
- action on the right. textContent-only, matching the deleted hall page
3575
- this restores. */
3576
- function mineEl(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
3577
- function mineRow(p) {
3578
- var row = mineEl('li', 'mineRow');
3579
- var main = mineEl('div', 'mineRow__main');
3580
- var name = mineEl('div', 'mineRow__name');
3581
- name.textContent = p.name || p.client_id;
3582
- main.appendChild(name);
3583
- var host = hostOf(p.origin);
3584
- if (host) { var h = mineEl('span', 'mineRow__host'); h.textContent = host; main.appendChild(h); }
3585
- row.appendChild(main);
3586
- return row;
3587
- }
3588
-
3589
- /* the leave/decline shape: disable, POST, drop the row on success, restore
3590
- the button on failure. `onRemoved` carries whatever else the caller needs
3591
- after the row is gone (the empty-state flip, the mineOrigins cleanup). */
3592
- function postThenRemoveRow(url, row, btn, idleLabel, onRemoved) {
3593
- btn.disabled = true;
3594
- if (idleLabel) btn.textContent = idleLabel.busy;
3595
- fetch(url, { method: 'POST', credentials: 'same-origin' })
3596
- .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
3597
- .then(function () { row.remove(); if (onRemoved) onRemoved(); })
3598
- .catch(function () { btn.disabled = false; if (idleLabel) btn.textContent = idleLabel.idle; });
3599
- }
3600
-
3601
- function renderBuildingList(rows) {
3602
- var ul = $('buildingList');
3603
- ul.textContent = '';
3604
- (rows || []).forEach(function (m) {
3605
- var row = mineRow(m);
3606
- var actions = mineEl('div', 'mineRow__actions');
3607
- var btn = document.createElement('button');
3608
- btn.type = 'button';
3609
- btn.className = 'btn small ghost';
3610
- btn.textContent = 'Leave';
3611
- btn.addEventListener('click', function () {
3612
- postThenRemoveRow(
3613
- API + '/my-projects/' + encodeURIComponent(m.client_id) + '/leave', row, btn,
3614
- { busy: 'Leaving…', idle: 'Leave' },
3615
- /* the map reads mineOrigins for its "you build here" pill and Apply
3616
- button — clear this project's entry so a leave takes effect there
3617
- on the next repaint too, not just in this list (task 1003774) */
3618
- function () {
3619
- delete mineOrigins[hostOf(m.origin)];
3620
- if (!ul.children.length) $('buildingEmpty').hidden = false;
3621
- }
3622
- );
3623
- });
3624
- actions.appendChild(btn);
3625
- row.appendChild(actions);
3626
- ul.appendChild(row);
3627
- });
3628
- $('buildingEmpty').hidden = !!(rows && rows.length);
3629
- }
3630
-
3631
3564
  /* the relay the map's per-card Apply (submitApply) drives — POST
3632
3565
  /my-projects/join, the 401 case and the 'requested' echo update kept in ONE
3633
3566
  place (task 1003774). /invitations carries the same relay for its join field
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.67",
3
+ "version": "1.21.69",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.67",
9
+ "version": "1.21.69",
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.21.67",
3
+ "version": "1.21.69",
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",
@@ -9182,5 +9182,17 @@
9182
9182
  "id": "1004257",
9183
9183
  "text": "The cloudbongos.com landing page now has a \"Download the CLI\" button in its closing section. It picks the right download for the visitor's computer and lists the others. It stays hidden until a CLI release is published, so not"
9184
9184
  }
9185
+ ],
9186
+ "1.21.68": [
9187
+ {
9188
+ "id": "1004589",
9189
+ "text": "The /invitations project picker now draws its list once per keystroke instead of twice. Nothing looks different."
9190
+ }
9191
+ ],
9192
+ "1.21.69": [
9193
+ {
9194
+ "id": "1004277",
9195
+ "text": "The projects you build on, with their Leave buttons, now live on your account page. My projects links there."
9196
+ }
9185
9197
  ]
9186
9198
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.67'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.69'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -246,7 +246,6 @@ test('loadMemberships reads GET /my-projects\' applied echo into appliedOrigins,
246
246
  API: '/api/bongos',
247
247
  mineOrigins: {},
248
248
  appliedOrigins: {},
249
- renderBuildingList: () => {},
250
249
  hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
251
250
  fetch: () => Promise.resolve({
252
251
  ok: true,
@@ -81,7 +81,7 @@ test('the script builds no markup from strings, and the controls are real markup
81
81
  test('the door returns here, and the page calls only its own-scoped routes', () => {
82
82
  assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account&amp;repos=1"/, 'sign-in lands back on /account, asking for repo access once (task 1004549)');
83
83
  const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
84
- assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile'].sort());
84
+ assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile', 'GET /my-projects', 'POST /my-projects/'].sort());
85
85
  // the bar's two calls live in the shared asset: the identity read and sign out
86
86
  const barCalls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
87
87
  assert.deepEqual(barCalls.sort(), ['GET /me', 'POST /auth/logout']);
@@ -180,14 +180,27 @@ const ACCOUNT = () => ({
180
180
  function boot(answer) {
181
181
  const els = new Map();
182
182
  const listeners = (o) => { o._on = {}; o.addEventListener = (t, f) => { (o._on[t] ||= []).push(f); }; return o; };
183
- const el = (id, hidden, attrs) => listeners({
184
- id, hidden, attrs, value: '', textContent: '', className: '', disabled: false, style: {},
185
- appendChild(c) { this.textContent += c.textContent; return c; },
183
+ // textContent reads the node's own text plus its children's, and setting it
184
+ // drops the children, as in the browser — so a list can be built, read and
185
+ // emptied (the projects you build on, task 1004277)
186
+ const withChildren = (o) => {
187
+ o.children = [];
188
+ o._text = '';
189
+ Object.defineProperty(o, 'textContent', {
190
+ get() { return o._text + o.children.map((c) => c.textContent).join(''); },
191
+ set(v) { o._text = String(v); o.children = []; },
192
+ });
193
+ o.appendChild = (c) => { c.parent = o; o.children.push(c); return c; };
194
+ o.remove = () => { if (o.parent) o.parent.children = o.parent.children.filter((c) => c !== o); };
195
+ return o;
196
+ };
197
+ const el = (id, hidden, attrs) => withChildren(listeners({
198
+ id, hidden, attrs, value: '', className: '', disabled: false, style: {},
186
199
  getAttribute(n) { return n in attrs ? attrs[n] : null; },
187
200
  setAttribute(n, v) { attrs[n] = v; },
188
201
  closest(sel) { return sel === '.trig' && /\btrig\b/.test(attrs.class || '') ? this : null; },
189
202
  focus() { dom.focused = id; document.activeElement = this; },
190
- });
203
+ }));
191
204
  for (const m of PAGE.matchAll(/<(\w+)([^>]*?)\sid="([\w-]+)"([^>]*)>/g)) {
192
205
  const attrs = Object.fromEntries([...(m[2] + m[4]).matchAll(/\s([\w-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
193
206
  els.set(m[3], el(m[3], /\shidden\b/.test(m[2] + m[4]), attrs));
@@ -262,7 +275,10 @@ function boot(answer) {
262
275
  if (!names.length) throw new Error(`unstubbed selector: ${sel}`);
263
276
  return radios.filter((r) => names.includes(r.name));
264
277
  },
265
- createElement: () => ({ className: '', textContent: '' }),
278
+ createElement: (tag) => {
279
+ const attrs = {};
280
+ return withChildren(listeners({ tag, attrs, className: '', disabled: false, setAttribute(n, v) { attrs[n] = String(v); } }));
281
+ },
266
282
  };
267
283
  const fetch = (url, init = {}) => {
268
284
  const method = init.method || 'GET';
@@ -440,6 +456,116 @@ test('a session that expired mid-edit reopens the door instead of failing silent
440
456
  assert.equal(dom.els.get('signinTop').hidden, false);
441
457
  });
442
458
 
459
+ // ── 3a. the projects you build on (task 1004277) ─────────────────────────────
460
+ // Moved here from /projects?view=mine (task 1003774's list and Leave): the owner
461
+ // put it on the builder's own profile, own view only, and with no hub profile
462
+ // page built, /account is that view (the owner's pick, 2026-10-06).
463
+
464
+ const BUILDING = [
465
+ { client_id: 'otb', name: 'Example World', origin: 'https://www.example.com/' },
466
+ { client_id: 'a b', name: null, origin: 'https://emersonian.cloudbongos.com' },
467
+ ];
468
+ const building = (rows, leave = () => ({ status: 200, data: { ok: true, left: true } })) => (m, p) => {
469
+ if (m === 'GET' && p === '/me/cross-project') return { status: 200, data: ACCOUNT() };
470
+ if (m === 'GET' && p === '/my-projects') return rows ? { status: 200, data: { owned: [], building: rows, applied: [] } } : { status: 500, data: null };
471
+ if (m === 'POST' && /^\/my-projects\/[^/]+\/leave$/.test(p)) return leave(p);
472
+ return { status: 500, data: null };
473
+ };
474
+ const rowsOf = (dom) => dom.els.get('buildingList').children;
475
+ const leaveOf = (row) => row.children[1].children[0];
476
+
477
+ test('the section ships inside the signed-in body, last, as a list with an empty line and a status line', () => {
478
+ const body = PAGE.split('<div id="acctBody" hidden>')[1].split('</main>')[0];
479
+ const sec = (body.match(/<section class="aSec" id="aBuilding" aria-labelledby="aBuilding-h">[\s\S]*?<\/section>/) || [''])[0];
480
+ assert.ok(sec, 'the projects you build on are a section of the account body');
481
+ assert.ok(body.indexOf('id="aBuilding"') > body.indexOf('id="aRecord"'), 'after your record, which counts them');
482
+ assert.match(sec, /<h2 id="aBuilding-h">projects you build on<\/h2>/);
483
+ assert.match(sec, /<p class="aWhat" id="buildingEmpty" hidden>/, 'the empty line waits for a read that says so');
484
+ assert.match(sec, /<ul class="mineRows" id="buildingList"><\/ul>/, 'the rows are cosmos.css’s, as on /invitations');
485
+ assert.match(sec, /<p class="projMsg" id="buildingMsg" role="status" aria-live="polite"><\/p>/);
486
+ });
487
+
488
+ test('signed in: the list is read once, after the account, and each row names its project and host with its own Leave', async () => {
489
+ const dom = boot(building(BUILDING));
490
+ await settle();
491
+ assert.deepEqual(dom.calls.filter((c) => c.method === 'GET' && c.path !== '/me').map((c) => c.path), ['/me/cross-project', '/my-projects']);
492
+ assert.equal(dom.els.get('buildingLoading').hidden, true);
493
+ assert.equal(dom.els.get('buildingEmpty').hidden, true);
494
+ const rows = rowsOf(dom);
495
+ assert.equal(rows.length, 2);
496
+ assert.equal(rows[0].className, 'mineRow');
497
+ assert.equal(rows[0].children[0].children[0].textContent, 'Example World');
498
+ assert.equal(rows[0].children[0].children[1].textContent, 'example.com', 'the host, without scheme, www or path');
499
+ assert.equal(rows[1].children[0].children[0].textContent, 'a b', 'no name: the client id stands in');
500
+ assert.equal(leaveOf(rows[0]).textContent, 'Leave');
501
+ assert.equal(leaveOf(rows[0]).attrs['aria-label'], 'Leave Example World', 'every button reads Leave; its name says which project');
502
+ assert.equal(leaveOf(rows[0]).className, 'btn small ghost');
503
+ });
504
+
505
+ test('nothing built on: the empty line shows; a failed read says so and never claims the list is empty', async () => {
506
+ const none = boot(building([]));
507
+ await settle();
508
+ assert.equal(rowsOf(none).length, 0);
509
+ assert.equal(none.els.get('buildingEmpty').hidden, false);
510
+ assert.match(PAGE, /id="buildingEmpty" hidden>You’re not building on any other projects yet\.<\/p>/);
511
+
512
+ const failed = boot(building(null));
513
+ await settle();
514
+ assert.equal(failed.els.get('buildingEmpty').hidden, true, 'a failed read is not "nothing"');
515
+ assert.equal(failed.els.get('buildingLoading').hidden, true);
516
+ assert.equal(failed.text('buildingMsg'), 'Couldn’t read the projects you build on — refresh in a moment.');
517
+ assert.equal(failed.els.get('buildingMsg').className, 'projMsg err');
518
+ });
519
+
520
+ test('Leave posts to the project’s own client id, says so while it goes, drops the row, and the last one shows the empty line', async () => {
521
+ const dom = boot(building(BUILDING));
522
+ await settle();
523
+ const [first] = rowsOf(dom);
524
+ dom.click(leaveOf(first));
525
+ assert.equal(leaveOf(first).disabled, true);
526
+ assert.equal(leaveOf(first).textContent, 'Leaving…');
527
+ await settle();
528
+ assert.equal(dom.calls.filter((c) => c.method === 'POST')[0].path, '/my-projects/otb/leave');
529
+ assert.equal(rowsOf(dom).length, 1, 'the row is gone once the server says so');
530
+ assert.equal(dom.text('buildingMsg'), 'You left Example World.');
531
+ assert.equal(dom.els.get('buildingMsg').className, 'projMsg ok');
532
+ assert.equal(dom.els.get('buildingEmpty').hidden, true, 'one left still');
533
+
534
+ dom.click(leaveOf(rowsOf(dom)[0]));
535
+ await settle();
536
+ assert.equal(dom.calls.filter((c) => c.method === 'POST')[1].path, '/my-projects/a%20b/leave', 'the id is encoded, never spliced raw');
537
+ assert.equal(rowsOf(dom).length, 0);
538
+ assert.equal(dom.els.get('buildingEmpty').hidden, false, 'and with the last gone, the empty line');
539
+ });
540
+
541
+ test('a refused Leave keeps the row and its button, and an expired session opens the door', async () => {
542
+ const refused = boot(building(BUILDING, () => ({ status: 500, data: { error: { code: 'leave_failed', message: 'internal error' } } })));
543
+ await settle();
544
+ const row = rowsOf(refused)[0];
545
+ refused.click(leaveOf(row));
546
+ await settle();
547
+ assert.equal(rowsOf(refused).length, 2);
548
+ assert.equal(leaveOf(row).disabled, false);
549
+ assert.equal(leaveOf(row).textContent, 'Leave');
550
+ assert.equal(refused.text('buildingMsg'), 'Couldn’t leave Example World. Try again.', 'plain words, not the server’s "internal error"');
551
+ assert.equal(refused.els.get('buildingMsg').className, 'projMsg err');
552
+
553
+ const expired = boot(building(BUILDING, () => ({ status: 401, data: null })));
554
+ await settle();
555
+ expired.click(leaveOf(rowsOf(expired)[0]));
556
+ await settle();
557
+ assert.equal(rowsOf(expired).length, 2);
558
+ assert.equal(expired.els.get('door').hidden, false);
559
+ assert.equal(expired.els.get('acctBody').hidden, true);
560
+ });
561
+
562
+ test('/projects keeps no copy: the mine view points here, and nothing there reads or leaves the list', () => {
563
+ assert.doesNotMatch(HUB, /id="buildingList"|renderBuildingList|\/leave'/, 'the list and Leave moved here whole');
564
+ assert.match(HUB, /<h2 id="mineToAccountH">Projects you build on<\/h2>\s*<p class="lede">[^<]*<a href="\/account#aBuilding">Open your account<\/a><\/p>/, 'the mine view says where they went');
565
+ assert.match(HUB, /\(d\.building \|\| \[\]\)\.forEach\(function \(m\) \{ if \(m\.origin\) mineOrigins\[hostOf\(m\.origin\)\] = 'builder'; \}\);/, 'the map still reads who you build with');
566
+ assert.match(SCRIPT, /if \(target && typeof target\.scrollIntoView === 'function'\) target\.scrollIntoView\(\);/, 'and #aBuilding lands on the section once the body shows');
567
+ });
568
+
443
569
  // ── 3b. the bar, executed (task 1004455) ─────────────────────────────────────
444
570
 
445
571
  const ME = { builder: { id: '1', github_login: 'octocat', avatar_url: 'https://avatars.example/u/1' } };
@@ -253,6 +253,21 @@ test('the picker lists the map minus the hub, an anonymous row and your own proj
253
253
  assert.equal(ui.$('joinOrigin').value, '', 'an Applied row cannot be picked again');
254
254
  });
255
255
 
256
+ test('the picker: once the map is cached, a keystroke renders the list once (task 1004589)', async () => {
257
+ const ui = await boot({ me: { github_login: 'octocat' }, reads: { '/projects/featured': FEED, '/my-projects': MINE } });
258
+ const input = ui.$('joinOrigin');
259
+ let renders = 0;
260
+ const set = input.setAttribute;
261
+ input.setAttribute = (k, v) => { if (k === 'aria-expanded' && v === 'true') renders++; return set(k, v); };
262
+ await ui.fire(input, 'focus');
263
+ assert.equal(renders, 2, 'the first open paints "Loading projects…", then the list when the reads settle');
264
+ renders = 0;
265
+ input.value = 'grove';
266
+ await ui.fire(input, 'input');
267
+ assert.equal(renders, 1, 'not a second time when the already-settled read resolves again');
268
+ assert.deepEqual(optionsOf(ui).map((o) => o.children[0].textContent), ['The Grove']);
269
+ });
270
+
256
271
  test('the picker narrows by name or address, and the keyboard picks: arrows skip an Applied row, Enter fills the address, Escape closes', async () => {
257
272
  const ui = await boot({ me: { github_login: 'octocat' }, reads: { '/projects/featured': FEED, '/my-projects': MINE } });
258
273
  const input = ui.$('joinOrigin');