@bongos/core 1.21.68 → 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;}
@@ -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.68",
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.68",
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.68",
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",
@@ -9188,5 +9188,11 @@
9188
9188
  "id": "1004589",
9189
9189
  "text": "The /invitations project picker now draws its list once per keystroke instead of twice. Nothing looks different."
9190
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
+ }
9191
9197
  ]
9192
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.68'; // 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' } };
@@ -515,24 +515,23 @@ test('a stamped asset still serves — the query string is not part of the filen
515
515
  assert.match(res.body, /\.shell\{/, 'and it must be the real stylesheet');
516
516
  });
517
517
 
518
- // ── #view-mine: invitations, projects-you-build-on, join-by-URL (task 1003774) ──
518
+ // ── #view-mine: the ways to /invitations and /account ──────────────────────────
519
519
  // The hall's my-projects page was deleted in task 1003308 on the owner's own
520
- // decision that this is a hub surface; these three reads/actions were left with
521
- // nowhere to live until now. loadMemberships() already fetched GET /my-projects
522
- // for the map's Apply button (R10, task 1002286) — this just renders the rest
523
- // of what that same read carries, plus the separate invites list.
524
- // The invitations and the join-by-address field moved to their own page with task
525
- // 1004276 (the owner's call, ADR 0337 D5); tests/hub_invitations_page.mjs holds that
526
- // page as served. What stays here: building-on with Leave, and a door to the page.
527
- test('#view-mine keeps building-on with Leave, and points at /invitations for the invitations and the join field', async () => {
520
+ // decision that this is a hub surface, and task 1003774 brought its lists here.
521
+ // They have since moved on: the invitations and the join-by-address field to their
522
+ // own page (task 1004276, ADR 0337 D5; tests/hub_invitations_page.mjs), and the
523
+ // projects you build on, with Leave, to /account (task 1004277;
524
+ // tests/hub_account_page.mjs). What stays here: a door to each, and the map card's
525
+ // Apply, which still posts the join relay.
526
+ test('#view-mine points at /invitations and /account, and keeps neither list', async () => {
528
527
  const { body } = await request('/projects', 'game.test.local');
529
- assert.match(body, /id="buildingList"/);
530
- assert.match(body, /\/my-projects\/'\s*\+\s*encodeURIComponent/, 'Leave must address the project by its own client id');
531
- for (const gone of ['invitesList', 'guildInviteList', 'engageList', 'guildRequestList', 'joinOrigin', 'joinNote', 'joinBtn', 'mineInvites']) {
532
- assert.doesNotMatch(body, new RegExp(`id="${gone}"`), `#${gone} moved to /invitations`);
528
+ for (const gone of ['buildingList', 'buildingEmpty', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList', 'joinOrigin', 'joinNote', 'joinBtn', 'mineInvites']) {
529
+ assert.doesNotMatch(body, new RegExp(`id="${gone}"`), `#${gone} moved off this page`);
533
530
  }
534
531
  assert.doesNotMatch(body, /\/my-projects\/invites/, 'the invitations are read on their own page now');
532
+ assert.doesNotMatch(body, /\/my-projects\/'\s*\+\s*encodeURIComponent/, 'and Leave is the account page’s');
535
533
  assert.match(body, /<h2 id="mineToInvitesH">Invitations<\/h2>\s*<p class="lede">[^<]*<a href="\/invitations">Open your invitations<\/a><\/p>/, 'the mine view says where they went');
534
+ assert.match(body, /<h2 id="mineToAccountH">Projects you build on<\/h2>\s*<p class="lede">[^<]*<a href="\/account#aBuilding">Open your account<\/a><\/p>/);
536
535
  assert.match(body, /\/my-projects\/join/, 'the map card’s Apply to join still posts the join relay');
537
536
  });
538
537