@bongos/core 1.19.440 → 1.19.442

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.
Files changed (33) hide show
  1. package/.bongos-core.json +71 -31
  2. package/clients/bongos-client/index.d.ts +1 -2
  3. package/docs/api/openapi.json +3 -20
  4. package/docs/api-reference.md +1 -1
  5. package/docs/copy-inventory.md +180 -173
  6. package/docs/copy-registry.json +300 -226
  7. package/docs/design/mocks/hub/mine.html +89 -1
  8. package/docs/design/projects-hub-direction-v2.md +59 -0
  9. package/docs/module-api-changelog.md +4 -0
  10. package/modules/platform-identity/routes/projects.js +2 -1
  11. package/modules/provisioning/provisioning.js +11 -2
  12. package/modules/provisioning/routes/provisioning.js +10 -5
  13. package/modules/ui-design/kit/fixtures/featured-hub.json +1 -1
  14. package/modules/ui-design/kit/serve.js +29 -4
  15. package/package-lock.json +2 -2
  16. package/package.json +1 -1
  17. package/public-landing/assets/cosmos.css +89 -65
  18. package/public-landing/assets/world/r4-card-chrome.jpg +0 -0
  19. package/public-landing/assets/world/r4-card-chrome.jpg.json +14 -0
  20. package/public-landing/assets/world/r4-card-deepfield.jpg +0 -0
  21. package/public-landing/assets/world/r4-card-deepfield.jpg.json +14 -0
  22. package/public-landing/assets/world/r4-card-dust.jpg +0 -0
  23. package/public-landing/assets/world/r4-card-dust.jpg.json +14 -0
  24. package/public-landing/assets/world/r4-card-nebula.jpg +0 -0
  25. package/public-landing/assets/world/r4-card-nebula.jpg.json +14 -0
  26. package/public-landing/projects-floor.states.json +28 -1
  27. package/public-landing/projects.html +201 -69
  28. package/public-landing/projects.probes.json +16 -2
  29. package/public-landing/projects.states.json +3 -1
  30. package/src/module-api.js +1 -1
  31. package/tests/projects_hub.mjs +173 -65
  32. package/tests/projects_hub_pre_uat.mjs +11 -9
  33. package/tests/provisioning_settings.mjs +49 -4
@@ -0,0 +1,14 @@
1
+ {
2
+ "kind": "card-band",
3
+ "model": "soul_location (Higgsfield) — nano_banana_pro was asked first; its queue had not started a job after 25 minutes, so the round moved to the model that answered",
4
+ "job_id": "a9e4cb59-7436-4f8f-9289-aba9b1fd567e",
5
+ "aspect_ratio": "5:2",
6
+ "source_aspect_ratio": "21:9",
7
+ "generated": "2026-08-30T17:09Z",
8
+ "round": 4,
9
+ "task": 1003352,
10
+ "prompt": "Ultra-wide studio photograph on a pure black ground: a single sweeping ribbon of liquid chrome, polished mirror metal with soft neutral specular highlights and a faint cool blue and warm bronze reflection, curving gently through the centre-right of the frame and fading into black at both ends. Abstract material study, not an object, no recognisable shape, no product. The left third and both lower corners stay almost pure black and empty. No text, no watermark, no logo, no border, no reflection of a studio or a person. Pure black dominates the frame.",
11
+ "source": ".impeccable/mocks/world-assets/r4-card-chrome-src.jpg",
12
+ "encoded": "ffmpeg crop=2560:1024:0:24 (21:9 → 5:2, centred), JPEG -q:v 4",
13
+ "use": "the poster card's backdrop on /projects, chosen by an owner as card_backdrop=\"chrome\": a sweeping ribbon of liquid chrome — the world's own material with no object in it. Dark only (ADR 0204); composed so the left third and the lower corners stay near-black, where the card puts its disc, its name and its two figure callouts."
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "kind": "card-band",
3
+ "model": "soul_location (Higgsfield) — nano_banana_pro was asked first; its queue had not started a job after 25 minutes, so the round moved to the model that answered",
4
+ "job_id": "debaaba3-6094-4670-9b08-c578fc6e8bb9",
5
+ "aspect_ratio": "5:2",
6
+ "source_aspect_ratio": "21:9",
7
+ "generated": "2026-08-30T17:09Z",
8
+ "round": 4,
9
+ "task": 1003352,
10
+ "prompt": "Ultra-wide deep-field astronomical photograph on a pure black ground: many very small, very distant galaxies and faint stars scattered sparsely across an almost entirely black frame, a few with the faintest cool blue or warm amber tint, no bright object anywhere, extremely quiet and even. No planets, no moons, no large nebula, no ground plane, no horizon, no lens flare, no text, no watermark, no logo, no border. Pure black dominates the frame.",
11
+ "source": ".impeccable/mocks/world-assets/r4-card-deepfield-src.jpg",
12
+ "encoded": "ffmpeg crop=2560:1024:0:24 (21:9 → 5:2, centred), JPEG -q:v 4",
13
+ "use": "the poster card's backdrop on /projects, chosen by an owner as card_backdrop=\"deepfield\": a deep field of very distant galaxies on near-black; the quietest of the four. Dark only (ADR 0204); composed so the left third and the lower corners stay near-black, where the card puts its disc, its name and its two figure callouts."
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "kind": "card-band",
3
+ "model": "soul_location (Higgsfield) — nano_banana_pro was asked first; its queue had not started a job after 25 minutes, so the round moved to the model that answered",
4
+ "job_id": "7ec35328-3943-4765-8dd7-8392fb8c48c3",
5
+ "aspect_ratio": "5:2",
6
+ "source_aspect_ratio": "21:9",
7
+ "generated": "2026-08-30T17:09Z",
8
+ "round": 4,
9
+ "task": 1003352,
10
+ "prompt": "Ultra-wide cinematic deep-space photograph on a pure black ground: soft interstellar dust lanes in warm bronze and dark amber sweeping diagonally across the centre-right, very low contrast, a haze rather than a shape, a scatter of tiny faint stars. The left third and both lower corners stay almost pure black and empty. No planets, no moons, no nebula pillars, no ground plane, no horizon, no lens flare, no text, no watermark, no logo, no border. Pure black dominates the frame.",
11
+ "source": ".impeccable/mocks/world-assets/r4-card-dust-src.jpg",
12
+ "encoded": "ffmpeg crop=2560:1024:0:24 (21:9 → 5:2, centred), eq=gamma=1.20, JPEG -q:v 4",
13
+ "use": "the poster card's backdrop on /projects, chosen by an owner as card_backdrop=\"dust\": warm bronze dust lanes, very low contrast; a haze rather than a shape. Dark only (ADR 0204); composed so the left third and the lower corners stay near-black, where the card puts its disc, its name and its two figure callouts."
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "kind": "card-band",
3
+ "model": "soul_location (Higgsfield) — nano_banana_pro was asked first; its queue had not started a job after 25 minutes, so the round moved to the model that answered",
4
+ "job_id": "35ad2c18-aaa3-413c-9fd8-958e5017d77c",
5
+ "aspect_ratio": "5:2",
6
+ "source_aspect_ratio": "21:9",
7
+ "generated": "2026-08-30T17:09Z",
8
+ "round": 4,
9
+ "task": 1003352,
10
+ "prompt": "Ultra-wide cinematic deep-space photograph on a pure black ground: a faint nebula wash in muted violet and deep blue with a soft bronze warmth drifting through the centre-right of the frame, very sparse pin-sharp stars of varied brightness, gentle depth. The left third and both lower corners stay almost pure black and empty. No planets, no moons, no ground plane, no horizon, no lens flare, no text, no watermark, no logo, no border. Pure black dominates the frame.",
11
+ "source": ".impeccable/mocks/world-assets/r4-card-nebula-src.jpg",
12
+ "encoded": "ffmpeg crop=2560:1024:0:24 (21:9 → 5:2, centred), eq=gamma=1.75:saturation=1.30, JPEG -q:v 4 — the render was too dark to read at the card's 560px; a gamma lift raises the wash without crushing the black the disc and the name sit on (contrast would have)",
13
+ "use": "the poster card's backdrop on /projects, chosen by an owner as card_backdrop=\"nebula\": a violet-and-bronze nebula wash, sparse stars — the field's own language in the card's shape. Dark only (ADR 0204); composed so the left third and the lower corners stay near-black, where the card puts its disc, its name and its two figure callouts."
14
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "The all-null floor card (task 1003301) on the hub's own catalog (task 1003345): the same page, the same stub, the catalog aliased to featured-hub.json — the landing's three plus Tidewater, a null row nobody owns. Demo, the landing catalog's floor row, is owned by the signed-in stub, and an owned orb's plain click opens its manage view, never its card. Rendered by name: node modules/ui-design/kit/render.js --states public-landing/projects-floor.states.json",
2
+ "_": "The all-null floor card (task 1003301) on the hub's own catalog (task 1003345): the same page, the same stub, the catalog aliased to featured-hub.json — the landing's three plus Tidewater, a null row nobody owns. Demo, the landing catalog's floor row, is owned by the signed-in stub, and an owned orb's plain click opens its manage view, never its card. Since task 1003352 it also carries the card BACKDROP state: featured-hub gives Off the Boats a card_backdrop, so the owner's plate can be seen standing in front of the project's own picture. Rendered by name: node modules/ui-design/kit/render.js --states public-landing/projects-floor.states.json",
3
3
  "page": "/projects",
4
4
  "surface": "public-landing",
5
5
  "modes": [
@@ -58,6 +58,33 @@
58
58
  "#cardListed"
59
59
  ]
60
60
  }
61
+ },
62
+ "card-backdrop": {
63
+ "auth": true,
64
+ "actions": [
65
+ [
66
+ "dispatchClick",
67
+ "#orbField a.worldOrb[data-origin=\"https://amazonprimea.com\"]"
68
+ ],
69
+ [
70
+ "wait",
71
+ 600
72
+ ]
73
+ ],
74
+ "expect": {
75
+ "dialog": true,
76
+ "visible": [
77
+ "#cardArt",
78
+ "#cardOpen"
79
+ ],
80
+ "attr": [
81
+ [
82
+ "#cardArt",
83
+ "src",
84
+ "/assets/world/r4-card-nebula.jpg"
85
+ ]
86
+ ]
87
+ }
61
88
  }
62
89
  }
63
90
  }
@@ -492,37 +492,32 @@ summary{min-height:24px;padding:3px 0;}
492
492
  <h2 class="tblTitle">every project</h2>
493
493
  <span class="mastObj" aria-hidden="true"></span>
494
494
  </div>
495
- <table class="tbl">
496
- <thead>
497
- <tr>
498
- <th class="colName" data-sort="name" aria-sort="ascending"><button type="button" class="thSort">Project</button></th>
499
- <th class="colType">Type</th>
500
- <th class="right colBuilders" data-sort="builders"><button type="button" class="thSort">Builders</button></th>
501
- <th class="colTags">Runs</th>
502
- <th class="right colStatus">Status</th>
503
- <th class="colGo"></th>
504
- </tr>
505
- </thead>
506
- <tbody id="mapRows">
507
- <!-- The platform's own row. Static and apex-only for exactly the reason
508
- the orb above is (stripApexOnly() is a string transform that cannot
509
- reach anything a script renders later), and preserved across
510
- re-renders by the same data-generated contract renderOrbs uses. -->
511
- <!--apex-only:start-->
512
- <tr id="selfRow">
513
- <td class="colName"><span class="pname"><span class="pdot" style="--acc:var(--accent)"></span>
514
- <span><span class="ptitle" data-brand-product-inline>Cloud Bongos</span><span id="selfPill"></span><br>
515
- <span class="ptag">this platform</span></span></span></td>
516
- <td class="colType">—</td>
517
- <td class="right num" id="selfBuilders">—</td>
518
- <td class="colTags" id="selfTags">—</td>
519
- <td class="right colStatus"><span class="chip calm"><span class="cdot"></span>—</span></td>
520
- <td class="colGo"><a class="rowGo" href="/">Open →</a><a class="rowHall" id="selfHall" href="{{buildersOrigin}}" aria-label="Builders' hall" title="Builders' hall" hidden><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M2.8 7.4 10 3.5l7.2 3.9"/><path d="M5.2 8.8v5.6M10 8.8v5.6M14.8 8.8v5.6"/><path d="M3.4 16.3h13.2"/></svg></a></td>
521
- </tr>
522
- <!--apex-only:end-->
523
- </tbody>
524
- </table>
525
- <p class="finePrint" style="text-align:center">Sort by Project or Builders.</p>
495
+ <!-- The sort left the column head with the header row (task 1003356): a
496
+ ledger row has no columns to head, so the two orders are hairline
497
+ pills in the mine view's own grammar — real buttons, aria-pressed, the
498
+ live order stated under the list where a screen reader hears it. -->
499
+ <div class="tblSort" role="group" aria-label="Sort the projects">
500
+ <span class="sortLbl">sort</span>
501
+ <button type="button" class="sortPill" data-sort="name" aria-pressed="true">name <span class="dir">a to z</span></button>
502
+ <button type="button" class="sortPill" data-sort="builders" aria-pressed="false">builders</button>
503
+ </div>
504
+ <ul class="tbl" id="mapRows">
505
+ <!-- The platform's own row. Static and apex-only for exactly the reason
506
+ the orb above is (stripApexOnly() is a string transform that cannot
507
+ reach anything a script renders later), and preserved across
508
+ re-renders by the same data-generated contract renderOrbs uses. -->
509
+ <!--apex-only:start-->
510
+ <li class="tblRow" id="selfRow">
511
+ <span class="worldOrb" data-shape="pair" data-style="chrome" data-template="pair-chrome" style="--acc:var(--accent);--d:64px" aria-hidden="true"><span class="disc"></span></span>
512
+ <div class="tWho"><span class="ptitle" data-brand-product-inline>Cloud Bongos</span><span id="selfPill"></span><span class="chip calm"><span class="cdot"></span>not checked</span></div>
513
+ <div class="facts"><span class="fact" id="selfBuilders">builders not known</span><span class="fact" id="selfShipped" hidden></span></div>
514
+ <p class="ptag">this platform</p>
515
+ <div class="runs"><span class="rLbl">runs</span><span id="selfTags">—</span></div>
516
+ <div class="rowDoors"><a class="rowGo" href="/">view</a><a class="rowHall" id="selfHall" href="{{buildersOrigin}}" aria-label="Builders' hall" title="Builders' hall" hidden><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M2.8 7.4 10 3.5l7.2 3.9"/><path d="M5.2 8.8v5.6M10 8.8v5.6M14.8 8.8v5.6"/><path d="M3.4 16.3h13.2"/></svg></a></div>
517
+ </li>
518
+ <!--apex-only:end-->
519
+ </ul>
520
+ <p class="finePrint" id="mapSorted" role="status" aria-live="polite">Sorted by name, A to Z.</p>
526
521
  </div>
527
522
 
528
523
  <!-- The project card. An OVERLAY inside the map, not a fourth view: opening
@@ -774,6 +769,18 @@ summary{min-height:24px;padding:3px 0;}
774
769
  </details>
775
770
  </div>
776
771
 
772
+ <!-- its card (task 1003352): the poster card's backdrop, a plate the
773
+ platform drew. Beside the planet, on the same settings channel, and
774
+ like it: nothing is pushed to the project and nothing restarts. -->
775
+ <div class="card mSec" id="mCard">
776
+ <h2>Its card</h2>
777
+ <div id="mCardPick"><p class="finePrint">Reading the project…</p></div>
778
+ <details class="finePrint" id="mCardWhat">
779
+ <summary>What the choice changes</summary>
780
+ <p>The backdrop behind this project’s name on its card — the panel that opens when someone picks it out of the map. Four plates the platform drew, or the project’s own picture if you have given it one. Saving here changes nothing on the project itself: no restart, nothing to apply.</p>
781
+ </details>
782
+ </div>
783
+
777
784
  <!-- live since task 1003042: the teardown control, for a project in ANY
778
785
  state that still holds something. The server used to refuse a
779
786
  'requested' or an 'error' project outright — and force-teardown reused
@@ -1471,7 +1478,9 @@ summary{min-height:24px;padding:3px 0;}
1471
1478
  }
1472
1479
  function typeHtml(t) {
1473
1480
  var l = typeLabel(t);
1474
- return l ? '<span class="typeTag">' + l + '</span>' : '—';
1481
+ /* a ledger row's fact is a self-describing line, so "not declared" is said
1482
+ in words where the table's Type cell used to print a dash (task 1003356) */
1483
+ return l ? '<span class="typeTag">' + l + '</span>' : 'type not declared';
1475
1484
  }
1476
1485
  function matchesMapQuery(p, q) {
1477
1486
  if (!q) return true;
@@ -1545,44 +1554,63 @@ summary{min-height:24px;padding:3px 0;}
1545
1554
  });
1546
1555
  }
1547
1556
 
1557
+ /* ── the row's facts, the ledger grammar (task 1003356) ──
1558
+ Each fact is one self-describing label-face line, the shape the Your-projects
1559
+ row uses, so the two views read as one ledger. The distinction the columns
1560
+ drew survives the move: "we could not ask" (null) and "we asked, the answer
1561
+ is none" (0) are two different sentences, not a dash and a zero. A figure
1562
+ that was never a column — shipped in 30 days, the Listed date — takes no
1563
+ space when it has no answer, the poster card's own rule. */
1564
+ function factLines(p) {
1565
+ var out = ['<span class="fact">' + typeHtml(p.type) + '</span>'];
1566
+ out.push('<span class="fact">' + (p.builders == null ? 'builders not known'
1567
+ : esc(String(p.builders)) + (p.builders === 1 ? ' builder' : ' builders')) + '</span>');
1568
+ var shipped = shippedFigure(p);
1569
+ if (shipped) out.push('<span class="fact">' + esc(shipped) + ' shipped in 30 days</span>');
1570
+ if (p.created_at) out.push('<span class="fact">listed ' + esc(listedDate(p.created_at)) + '</span>');
1571
+ return out.join('');
1572
+ }
1573
+
1548
1574
  function renderRows(list) {
1549
1575
  var body = $('mapRows');
1550
1576
  /* replace only the generated rows — the platform's own row is static
1551
1577
  apex-only markup and must survive every re-render, for the same reason
1552
1578
  #selfOrb does in the sky. It stays pinned first: it is "this platform",
1553
1579
  not a peer in the catalog's sort order. */
1554
- body.querySelectorAll('tr[data-generated]').forEach(function (n) { n.remove(); });
1580
+ body.querySelectorAll('li[data-generated]').forEach(function (n) { n.remove(); });
1555
1581
  body.insertAdjacentHTML('beforeend', list.map(function (p) {
1556
1582
  var rel = relOf(p);
1557
- var pill = rel === 'owner' ? ' <span class="pill">yours</span>'
1558
- : rel === 'builder' ? ' <span class="pill">you build here</span>' : '';
1559
- return '<tr data-generated ' + (p._inst ? 'data-manage="' + esc(String(p._inst.id)) + '" ' : '') +
1560
- 'data-origin="' + esc(String(p.origin || '')) + '">' +
1561
- '<td class="colName"><span class="pname"><span class="pdot" style="--acc:' +
1562
- accentOf(p) + '"></span>' +
1563
- '<span>' + (p._inst
1583
+ var pill = rel === 'owner' ? '<span class="pill">yours</span>'
1584
+ : rel === 'builder' ? '<span class="pill">you build here</span>' : '';
1585
+ /* the project's own planet, the object the sky and the Your-projects row
1586
+ already draw — the owner's chosen template and colour first (task
1587
+ 1003347), the slug walk otherwise. Decorative here: the name beside it
1588
+ is the row's accessible door. */
1589
+ var planet = '<span class="worldOrb"' + planetAttrs({ tpl: templateOf(p) }) +
1590
+ ' style="--acc:' + accentOf(p) + ';--d:64px" aria-hidden="true"><span class="disc"></span></span>';
1591
+ return '<li class="tblRow" data-generated ' + (p._inst ? 'data-manage="' + esc(String(p._inst.id)) + '" ' : '') +
1592
+ 'data-origin="' + esc(String(p.origin || '')) + '">' + planet +
1593
+ '<div class="tWho">' + (p._inst
1564
1594
  ? '<a class="ptitle" data-manage="' + esc(String(p._inst.id)) + '" href="?view=manage&id=' + esc(String(p._inst.id)) + '">' + esc(p.name) + '</a>'
1565
- : '<span class="ptitle">' + esc(p.name) + '</span>') + pill + '<br>' +
1566
- '<span class="ptag">' + esc(p.tagline || '') + '</span></span></span></td>' +
1567
- /* Builders and Runs come from the project's OWN public API, enriched
1568
- server-side (task 1002771). null is unknown — the project was
1569
- unreachable — and an em dash says exactly that. 0 is a real answer and
1570
- renders as 0, because "nobody has joined yet" is a different fact from
1571
- "we could not ask". */
1572
- '<td class="colType">' + typeHtml(p.type) + '</td>' +
1573
- '<td class="right num">' + (p.builders == null ? '—' : esc(String(p.builders))) + '</td>' +
1574
- '<td class="colTags">' + tagsHtml(p.tags) + '</td>' +
1595
+ : '<span class="ptitle">' + esc(p.name) + '</span>') + pill +
1575
1596
  /* liveness on a CATALOG row is NOT probed by this surface — a chip
1576
1597
  saying "live" there would claim more than anything verified (C6).
1577
1598
  An OWNED row is different: the own-scoped feed answers for it, so
1578
1599
  it carries chipFor()'s truth, the same chip its orb and its manage
1579
1600
  card show (task 1002744). */
1580
- '<td class="right colStatus">' + (p._inst ? chipFor(p._inst)
1581
- : '<span class="chip calm"><span class="cdot"></span>—</span>') + '</td>' +
1582
- '<td class="colGo">' + (safeHttpUrl(p.origin)
1583
- ? '<a class="rowGo" href="' + esc(safeHttpUrl(p.origin)) + '" target="_blank" rel="noopener">Open →</a>'
1584
- : '') + hallGlyphHtml(p, rel) + '</td>' +
1585
- '</tr>';
1601
+ (p._inst ? chipFor(p._inst)
1602
+ : '<span class="chip calm"><span class="cdot"></span>not checked</span>') + '</div>' +
1603
+ '<div class="facts">' + factLines(p) + '</div>' +
1604
+ '<p class="ptag">' + esc(p.tagline || '') + '</p>' +
1605
+ /* Runs stays the module list here, where the poster card's Runs is the
1606
+ address (direction §7): null is "we could not ask", [] is "core" */
1607
+ '<div class="runs"><span class="rLbl">runs</span>' + tagsHtml(p.tags) + '</div>' +
1608
+ /* at most two doors (task 1003356): the site, and the hall where the
1609
+ viewer builds there and the hall answers at a different address */
1610
+ '<div class="rowDoors">' + (safeHttpUrl(p.origin)
1611
+ ? '<a class="rowGo" href="' + esc(safeHttpUrl(p.origin)) + '" target="_blank" rel="noopener">view</a>'
1612
+ : '') + hallGlyphHtml(p, rel) + '</div>' +
1613
+ '</li>';
1586
1614
  }).join(''));
1587
1615
  }
1588
1616
 
@@ -1665,7 +1693,7 @@ summary{min-height:24px;padding:3px 0;}
1665
1693
  }
1666
1694
  if (row) {
1667
1695
  row.hidden = !selfShown;
1668
- $('selfPill').innerHTML = badge ? ' <span class="pill">' + badge + '</span>' : '';
1696
+ $('selfPill').innerHTML = badge ? '<span class="pill">' + badge + '</span>' : '';
1669
1697
  /* the hall glyph: member-gated (the badge says so) and address-gated */
1670
1698
  var sh = $('selfHall');
1671
1699
  if (sh) sh.hidden = !badge || !hallUrlOf(SELF_PROJECT);
@@ -1804,7 +1832,7 @@ summary{min-height:24px;padding:3px 0;}
1804
1832
  var href = p.isSelf ? '/' : safeHttpUrl(p.origin);
1805
1833
  /* the art: catalog text an Archon typed, scheme-checked server-side and
1806
1834
  again here before it becomes a src */
1807
- cardArt(p.isSelf ? '' : safeHttpUrl(p.art_url));
1835
+ cardArt(p.isSelf ? '' : (backdropPlate(p.card_backdrop) || safeHttpUrl(p.art_url)));
1808
1836
  $('cardDot').style.setProperty('--acc', p.isSelf ? 'var(--accent)' : accentOf(p));
1809
1837
  $('cardName').textContent = p.name || '';
1810
1838
  $('cardTag').textContent = p.tagline || '';
@@ -1911,7 +1939,7 @@ summary{min-height:24px;padding:3px 0;}
1911
1939
  }
1912
1940
  /* the row's own "Open →" keeps its job — the card is for the rest of the row */
1913
1941
  if (ev.target.closest && ev.target.closest('a')) return;
1914
- var tr = ev.target.closest && ev.target.closest('tr');
1942
+ var tr = ev.target.closest && ev.target.closest('li');
1915
1943
  if (!tr || !isPlainClick(ev)) return;
1916
1944
  if (tr.id === 'selfRow') return openCard(SELF_PROJECT, selfRel(), null);
1917
1945
  /* an owned row goes to MANAGE, exactly like its orb — two views, one job */
@@ -2030,7 +2058,7 @@ summary{min-height:24px;padding:3px 0;}
2030
2058
  var rows = (d && d.leaderboard) || [];
2031
2059
  if (!rows.length) return;
2032
2060
  var real = rows.filter(function (b) { return !/-system$/.test(String(b.github_login || '')); });
2033
- $('selfBuilders').textContent = String(real.length);
2061
+ $('selfBuilders').textContent = real.length === 1 ? '1 builder' : real.length + ' builders';
2034
2062
  /* the card reads these too: the same payload answers the platform's
2035
2063
  builder count and its owner — the archon row — by the same rule the
2036
2064
  server-side enrichment applies to every other project */
@@ -2056,6 +2084,10 @@ summary{min-height:24px;padding:3px 0;}
2056
2084
  var n = d.tasks.filter(function (t) { return Date.parse(t && t.shipped_at) >= since; }).length;
2057
2085
  SELF_PROJECT.shipped_30d = n;
2058
2086
  SELF_PROJECT.shipped_30d_capped = d.tasks.length >= 200 && n === d.tasks.length;
2087
+ /* the row's own fact, by the same rule every other row's is written */
2088
+ var sf = $('selfShipped');
2089
+ sf.textContent = shippedFigure(SELF_PROJECT) + ' shipped in 30 days';
2090
+ sf.hidden = false;
2059
2091
  })
2060
2092
  .catch(function () { /* unknown stays unknown: the figure hides */ });
2061
2093
  }
@@ -2113,21 +2145,33 @@ summary{min-height:24px;padding:3px 0;}
2113
2145
 
2114
2146
  $('onlyMine').addEventListener('change', applyMapFilter);
2115
2147
  $('mapQ').addEventListener('input', applyMapFilter);
2116
- /* the sort control is the BUTTON, not the th: a th takes no focus and no
2117
- Enter/Space, so the column sort was mouse-only. aria-sort stays on the th,
2118
- which is where a screen reader reads it from. */
2119
- document.querySelectorAll('.tbl th[data-sort]').forEach(function (th) {
2120
- var btn = th.querySelector('button');
2121
- if (!btn) return;
2148
+ /* ── the sort, off the column head (task 1003356) ──
2149
+ A ledger row has no columns to head, so aria-sort has no host: the two
2150
+ orders are toggle buttons in a group, pressed = the live order, and the
2151
+ sentence under the list states it for a screen reader that never saw the
2152
+ press. Pressing the pressed one reverses, exactly as the header did. */
2153
+ var SORT_WORDS = { name: ['a to z', 'z to a'], builders: ['fewest first', 'most first'] };
2154
+ function paintSort() {
2155
+ document.querySelectorAll('.tblSort button[data-sort]').forEach(function (b) {
2156
+ var on = b.getAttribute('data-sort') === mapSort.key;
2157
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
2158
+ var dir = b.querySelector('.dir');
2159
+ if (on && !dir) { dir = document.createElement('span'); dir.className = 'dir'; b.appendChild(document.createTextNode(' ')); b.appendChild(dir); }
2160
+ if (dir) { dir.hidden = !on; dir.textContent = on ? SORT_WORDS[mapSort.key][mapSort.dir === 1 ? 0 : 1] : ''; }
2161
+ });
2162
+ $('mapSorted').textContent = 'Sorted by ' + mapSort.key + ', ' +
2163
+ SORT_WORDS[mapSort.key][mapSort.dir === 1 ? 0 : 1] + '.';
2164
+ }
2165
+ document.querySelectorAll('.tblSort button[data-sort]').forEach(function (btn) {
2122
2166
  btn.addEventListener('click', function () {
2123
- var k = th.getAttribute('data-sort');
2167
+ var k = btn.getAttribute('data-sort');
2124
2168
  mapSort.dir = (mapSort.key === k) ? -mapSort.dir : 1;
2125
2169
  mapSort.key = k;
2126
- document.querySelectorAll('.tbl th[data-sort]').forEach(function (o) { o.removeAttribute('aria-sort'); });
2127
- th.setAttribute('aria-sort', mapSort.dir === 1 ? 'ascending' : 'descending');
2170
+ paintSort();
2128
2171
  applyMapFilter();
2129
2172
  });
2130
2173
  });
2174
+ paintSort();
2131
2175
 
2132
2176
  /* ══ THE LIVING SKY (task 1002762) ═════════════════════════════════════════
2133
2177
  The retired gate's cluster, rewritten for the hub. Physics is the gate's
@@ -4819,6 +4863,7 @@ summary{min-height:24px;padding:3px 0;}
4819
4863
  /* its planet (task 1003347) reads off the row — fetch-free too; a pick in
4820
4864
  progress survives the tick because only a different project re-renders it */
4821
4865
  renderPick(inst);
4866
+ renderCardPick(inst);
4822
4867
  /* the page names its project — the heading holds #mName; and in the tab */
4823
4868
  document.title = (inst.slug ? inst.slug + ' — ' : '') + BASE_TITLE;
4824
4869
  /* the card's own story, on the project's own page (task 1003232): the
@@ -5400,6 +5445,20 @@ summary{min-height:24px;padding:3px 0;}
5400
5445
  var PICK_STYLES = [['tint', 'tinted'], ['wash', 'washed'], ['plain', 'plain']];
5401
5446
  var PICK_ACCENTS = [['sea', 'sea'], ['terracotta', 'terracotta'], ['gold-soft', 'soft gold'], ['sun-lit', 'sunlit'], ['accent', 'the accent']];
5402
5447
  var PICK_FIELDS = { mPickShape: 'shape', mPickMaterial: 'material', mPickStyle: 'style', mPickAccent: 'accent' };
5448
+ /* ── the card's backdrop (task 1003352): the same KIND of key as the planet —
5449
+ a closed list of platform-drawn plates, saved on the same settings channel,
5450
+ pushed to no instance and restarting nothing. '' is "its own picture": the
5451
+ card falls back to the project's art_url, which is all it read before. The
5452
+ plates are the world's own material (DESIGN.md, round 4), dark only. ── */
5453
+ var CARD_BACKDROPS = [['', 'its own picture'], ['nebula', 'nebula'], ['dust', 'dust'], ['deepfield', 'deep field'], ['chrome', 'chrome']];
5454
+ var CARD_PLATES = { nebula: 'r4-card-nebula.jpg', dust: 'r4-card-dust.jpg', deepfield: 'r4-card-deepfield.jpg', chrome: 'r4-card-chrome.jpg' };
5455
+ var CARD_WORDS = { nebula: 'a violet and bronze nebula wash', dust: 'warm bronze dust lanes',
5456
+ deepfield: 'a deep field of distant galaxies', chrome: 'a sweep of liquid chrome' };
5457
+ /* a key → the plate's path, or '' for anything this platform does not draw
5458
+ (an unknown key from an older row must never become a broken src) */
5459
+ function backdropPlate(key) {
5460
+ return CARD_PLATES[key] ? '/assets/world/' + CARD_PLATES[key] : '';
5461
+ }
5403
5462
  var mPick = null; /* the pending choice: { shape, material, style, accent } */
5404
5463
  var mPickFor = null; /* which project it belongs to — a different one starts afresh */
5405
5464
  var mPickTouched = false; /* the owner changed something since the seed — a late catalog must not undo it */
@@ -5508,6 +5567,79 @@ summary{min-height:24px;padding:3px 0;}
5508
5567
  });
5509
5568
  }
5510
5569
 
5570
+ /* ── its card (task 1003352; the owner's ask on the planet round: "this could
5571
+ also house project card backdrop customization"). Its own section BESIDE the
5572
+ planet rather than inside it, so each heading stays true — one is how the
5573
+ project is drawn everywhere, the other is one surface's backdrop. Same
5574
+ mechanics as the planet, key for key: reads off the row, saves on the
5575
+ settings PATCH, and the reply is a receipt the card repaints from. ── */
5576
+ var mCard = null; /* the pending choice: the backdrop key, or '' */
5577
+ var mCardFor = null; /* which project it belongs to */
5578
+ function renderCardPick(inst) {
5579
+ if (mCard === null || mCardFor !== inst.id) { mCard = inst.card_backdrop || ''; mCardFor = inst.id; }
5580
+ var plate = backdropPlate(mCard);
5581
+ $('mCardPick').innerHTML =
5582
+ '<div class="pick">' +
5583
+ '<div class="pickPreview"><span class="cardBand"' +
5584
+ (plate ? ' style="background-image:url(' + esc(plate) + ')"' : ' data-own') + ' aria-hidden="true"></span>' +
5585
+ /* the line speaks for the ROW, so a pick that is ahead of the row says
5586
+ so — the preview above it is already showing the plate */
5587
+ '<p class="pickWords">' + esc(CARD_WORDS[mCard] || 'the project’s own picture, when it has one') +
5588
+ '<small>' + (mCard !== (inst.card_backdrop || '') ? 'not saved yet'
5589
+ : inst.card_backdrop ? 'your choice' : 'nothing chosen') + '</small></p></div>' +
5590
+ '<div class="pickBands">' + pickBand('Backdrop', 'mCardBackdrop', CARD_BACKDROPS, mCard) + '</div>' +
5591
+ '</div>' +
5592
+ '<div class="actions"><button type="button" class="btn small" data-card-save>Save its card</button></div>' +
5593
+ '<div class="projMsg" id="mCardMsg" role="status" aria-live="polite"></div>';
5594
+ }
5595
+ $('mCardPick').addEventListener('change', function (ev) {
5596
+ var t = ev.target;
5597
+ if (!t || t.type !== 'radio' || t.name !== 'mCardBackdrop' || !manageInst) return;
5598
+ mCard = t.value;
5599
+ renderCardPick(manageInst); /* the preview follows the pick, keeping the cell's focus */
5600
+ var again = document.querySelector('#mCardPick input[value="' + t.value + '"]');
5601
+ if (again) again.focus({ preventScroll: true });
5602
+ });
5603
+ $('mCardPick').addEventListener('click', function (ev) {
5604
+ var t = ev.target.closest && ev.target.closest('button');
5605
+ if (t && t.hasAttribute('data-card-save')) doCardSave();
5606
+ });
5607
+ function doCardSave() {
5608
+ if (!manageInst || mCard === null) return;
5609
+ var box = $('mCardMsg');
5610
+ document.querySelectorAll('#mCardPick .actions button').forEach(function (b) { b.disabled = true; });
5611
+ box.className = 'projMsg'; box.textContent = 'Saving…';
5612
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', {
5613
+ method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
5614
+ body: JSON.stringify({ card_backdrop: mCard || null })
5615
+ }).then(function (res) {
5616
+ return jsonOrEmpty(res).then(function (data) {
5617
+ if (res.ok) {
5618
+ /* the row now holds the choice; the card reads it from there */
5619
+ manageInst.card_backdrop = (data.planet || {}).card_backdrop || null;
5620
+ var row = byId[manageInst.id];
5621
+ if (row) row.card_backdrop = manageInst.card_backdrop;
5622
+ mCard = null;
5623
+ renderCardPick(manageInst);
5624
+ var done = $('mCardMsg');
5625
+ done.className = 'projMsg ok';
5626
+ done.textContent = (data.push && data.push.message) || 'Saved.';
5627
+ loadCatalog();
5628
+ return;
5629
+ }
5630
+ var e = apiError(data, res.status);
5631
+ if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
5632
+ box.className = 'projMsg err';
5633
+ box.textContent = e.message || 'The choice could not be saved — try again in a moment.';
5634
+ });
5635
+ }).catch(function () {
5636
+ box.className = 'projMsg err';
5637
+ box.textContent = 'Couldn’t reach the platform — check your connection and try again.';
5638
+ }).then(function () {
5639
+ document.querySelectorAll('#mCardPick .actions button').forEach(function (b) { b.disabled = false; });
5640
+ });
5641
+ }
5642
+
5511
5643
  /* ── how people join (task 1003044): the joinability toggle — the visibility
5512
5644
  section's shape, key for key: the CHOICE from the platform's settings row,
5513
5645
  what the project RUNS from its own manifest (brand.project.joinability),
@@ -75,8 +75,16 @@
75
75
  "text": [["#cardRel", "yours"]], "href": [["#cardOpen", "/"]] } },
76
76
  { "name": "the table: the hall glyph rides the door cell on the one member row whose hall is elsewhere, and on the platform's row", "continue": true,
77
77
  "steps": [["press", "Escape"], ["wait", 150], ["click", "#projTrig"], ["wait", 120], ["click", "a[data-sub=\"table\"]"], ["wait", 300]],
78
- "expect": { "dialog": false, "visible": ["#selfHall"], "count": [["#mapRows tr[data-generated] .rowHall", 1], ["#mapRows tr[data-generated] td", 42]],
79
- "href": [["#mapRows tr[data-generated] .rowHall", "https://builders.amazonprimea.com/"]] } },
78
+ "expect": { "dialog": false, "visible": ["#selfHall"], "count": [["#mapRows li[data-generated] .rowHall", 1], ["#mapRows li[data-generated] .facts", 7], ["#mapRows li[data-generated] .runs", 7], ["#mapRows li[data-generated] .rowDoors", 7]],
79
+ "href": [["#mapRows li[data-generated] .rowHall", "https://builders.amazonprimea.com/"]] } },
80
+ { "name": "the table: the sort left the column head — the pills carry the order, and the sentence under the list states it (task 1003356)", "continue": true,
81
+ "steps": [["click", ".tblSort button[data-sort=\"builders\"]"], ["wait", 200]],
82
+ "expect": { "attr": [[".tblSort button[data-sort=\"builders\"]", "aria-pressed", "true"], [".tblSort button[data-sort=\"name\"]", "aria-pressed", "false"]],
83
+ "text": [["#mapSorted", "Sorted by builders, fewest first."]] } },
84
+ { "name": "the table: pressing the pressed sort reverses it, exactly as the column header did", "continue": true,
85
+ "steps": [["click", ".tblSort button[data-sort=\"builders\"]"], ["wait", 200]],
86
+ "expect": { "attr": [[".tblSort button[data-sort=\"builders\"]", "aria-pressed", "true"]],
87
+ "text": [["#mapSorted", "Sorted by builders, most first."]] } },
80
88
  { "name": "back to the sky for the rest of the sequence", "continue": true,
81
89
  "steps": [["click", "#projTrig"], ["wait", 120], ["click", "a[data-sub=\"sky\"]"], ["wait", 300]],
82
90
  "expect": { "hidden": ["#sub-table-body"] } },
@@ -123,6 +131,12 @@
123
131
  { "name": "manage flows: try again from the snagged project's own page re-files it too", "auth": true, "width": 1440, "url": "/projects?view=manage&id=103",
124
132
  "steps": [["wait",900],["click","[data-m-retry]"],["wait",900]],
125
133
  "expect": {"text":[["#mMsg","Queued. The platform picks it up from here."]],"hasClass":[["#mMsg","ok",true]],"hidden":["#mAddress"]} },
134
+ { "name": "manage flows: its card (task 1003352) — pick the nebula plate; save; the receipt lands and the preview takes the plate", "auth": true, "width": 1440, "url": "/projects?view=manage&id=101",
135
+ "steps": [["wait",900],["check","#mCardPick input[name=\"mCardBackdrop\"][value=\"nebula\"]"],["wait",150],["click","[data-card-save]"],["wait",700]],
136
+ "expect": {"text":[["#mCardMsg","Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it."]],"hasClass":[["#mCardMsg","ok",true]],"count":[["#mCardPick .cardBand[data-own]",0]]} },
137
+ { "name": "manage flows: its card — handing it back to the platform restores the project's own picture", "continue": true,
138
+ "steps": [["check","#mCardPick input[name=\"mCardBackdrop\"][value=\"\"]"],["wait",150],["click","[data-card-save]"],["wait",700]],
139
+ "expect": {"hasClass":[["#mCardMsg","ok",true]],"count":[["#mCardPick .cardBand[data-own]",1]]} },
126
140
  { "name": "manage flows: how people join — pick a cell, save, the receipt lands under the choice", "auth": true, "width": 1440, "url": "/projects?view=manage&id=101",
127
141
  "steps": [["wait",900],["check","input[name=\"mJoinPick\"][value=\"apply\"]"],["click","[data-join-save]"],["wait",600]],
128
142
  "expect": {"text":[["#mJoinMsg","Saved. The project restarts to apply it."],["#mName","demo"]],"checked":"input[name=\"mJoinPick\"][value=\"apply\"]","visible":["#mOrb .worldOrb","#mFacts .ledger .k"]} },
@@ -15,11 +15,13 @@
15
15
  "in-map-kbd": { "auth": true, "actions": [["focus", "#projTrig"], ["press", "Enter"], ["wait", 350], ["press", "ArrowDown"], ["wait", 120]], "expect": { "expanded": true, "activeText": "Table" } },
16
16
  "in-map-search": { "auth": true, "actions": [["clickIfVisible", "#qToggle"], ["wait", 200], ["fill", "#mapQ", "emer"], ["wait", 300]], "expect": { "value": [["#mapQ", "emer"]] } },
17
17
  "in-map-mine-on": { "auth": true, "actions": [["check", "#onlyMine"], ["wait", 300]], "expect": { "checked": "#onlyMine" } },
18
- "in-table": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 250], ["click", "a[data-sub=\"table\"]"], ["wait", 400]], "expect": { "visible": ["#sub-table-body", ".tblMast", ".mastObj", "#selfHall"], "closed": "#menuProjects", "count": [["#mapRows tr[data-generated] .rowHall", 1]] } },
18
+ "in-table": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 250], ["click", "a[data-sub=\"table\"]"], ["wait", 400]], "expect": { "visible": ["#sub-table-body", ".tblMast", ".mastObj", "#selfHall"], "closed": "#menuProjects", "count": [["#mapRows li[data-generated] .rowHall", 1], ["#mapRows .tblRow .facts", 7], ["#mapRows .tblRow .rowDoors", 7]], "text": [["#mapSorted", "Sorted by name, a to z."]] } },
19
+ "in-table-by-builders": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 250], ["click", "a[data-sub=\"table\"]"], ["wait", 400], ["click", ".tblSort button[data-sort=\"builders\"]"], ["wait", 200]], "expect": { "visible": ["#sub-table-body"], "attr": [[".tblSort button[data-sort=\"builders\"]", "aria-pressed", "true"], [".tblSort button[data-sort=\"name\"]", "aria-pressed", "false"]], "text": [["#mapSorted", "Sorted by builders, fewest first."]] } },
19
20
  "in-mine": { "auth": true, "url": "/projects?view=mine", "expect": { "hidden": ["#qWrap", "#empty"], "visible": ["#mineBar", "#projectsList", "#projectsList .proj .worldOrb .disc"], "count": [["#projectsList .proj", 5], ["#projectsList .proj.gone", 1], ["#projectsList .proj .worldOrb[data-shape][data-material][data-style]", 5], ["#projectsList .proj .worldOrb[data-template=\"blob-frost-tint\"]", 1], ["#projectsList .proj .strip", 1], ["#projectsList .proj .worldOrb.ashen", 2], ["#projectsList .proj .actions a", 7], ["#projectsList .proj .actions button", 0]] } },
20
21
  "in-manage": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mMain", "#mChip .chip", "#mOrb .worldOrb", "#mFacts .ledger .k", "#mActs", "#mAddress"], "hidden": ["#qWrap", "#mGate", "#mMissing", "#mErr"], "text": [["#mName", "demo"], ["#mTitle", "demo"]] } },
21
22
  "in-manage-address": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mAddress", "#mAddress h2", "#mDom .plain", "#mDomName", "#mDom [data-m-attach]", "#mDomWhat summary"], "count": [["#mDom .attach input[type=text]", 1], ["#mDom .attach button", 1]] } },
22
23
  "in-manage-planet": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mPlanetPick", "#mPick .pick .pickPreview .worldOrb .disc", "#mPick [data-pick-save]", "#mPick [data-pick-reset]", "#mPickWhat summary"], "count": [["#mPick .pickCells[role=\"radiogroup\"]", 3], ["#mPick .pickCell", 10], ["#mPick .pickCell.on", 3], ["#mPick .pickSw", 5], ["#mPick .pickSw.on", 1]], "attr": [["#mPick .pickPreview .worldOrb", "data-template", "pair-obsidian-tint"], ["#mOrb .worldOrb", "data-template", "pair-obsidian-tint"]], "checked": "#mPick input[name=\"mPickAccent\"][value=\"sea\"]", "text": [["#mPlanet .v small", "your choice."]] } },
24
+ "in-manage-card": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mCard", "#mCardPick .pick .pickPreview .cardBand", "#mCardPick [data-card-save]", "#mCardWhat summary"], "count": [["#mCardPick .pickCells[role=\"radiogroup\"]", 1], ["#mCardPick .pickCell", 5], ["#mCardPick .pickCell.on", 1]], "checked": "#mCardPick input[name=\"mCardBackdrop\"][value=\"\"]", "attr": [["#mCardPick .cardBand", "data-own", ""]] } },
23
25
  "in-manage-snag": { "auth": true, "url": "/projects?view=manage&id=103", "expect": { "visible": ["#mMain", "#mErr", "#mActs"], "hidden": ["#mAddress"], "text": [["#mName", "tidepool"]] } },
24
26
  "in-manage-gone": { "auth": true, "url": "/projects?view=manage&id=104", "expect": { "visible": ["#mMain", "#mActs"], "hidden": ["#mErr", "#mAddress"], "text": [["#mName", "old-lantern"]] } },
25
27
  "in-manage-missing": { "auth": true, "url": "/projects?view=manage&id=999", "expect": { "visible": ["#mMissing"], "hidden": ["#mMain", "#mGate"] } },
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.440'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.442'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');