@bongos/core 1.19.439 → 1.19.441

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.
@@ -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
@@ -695,6 +690,20 @@ summary{min-height:24px;padding:3px 0;}
695
690
  <div class="actions" id="mActs" hidden></div>
696
691
  </div>
697
692
 
693
+ <!-- its address (task 1003355): the bring-your-own-domain control, moved
694
+ off the list row when a row shrank to view and manage. Its home is
695
+ here, under the ledger's address fact. The section hides itself where
696
+ there is nothing to point an address at (a snagged or torn-down
697
+ project) — the same call the card's fold used to make. -->
698
+ <div class="card mSec" id="mAddress" hidden>
699
+ <h2>Its address</h2>
700
+ <div id="mDom"><p class="finePrint">Reading the project…</p></div>
701
+ <details class="finePrint" id="mDomWhat">
702
+ <summary>Where an address comes from</summary>
703
+ <p>Every project gets one on this platform, at its own handle. You can point a domain you already own at it instead: type the name here, then add a DNS record for it at your registrar. The platform holds the address for this project and wires the secure connection once the name resolves. Changing it later lets the old address go, and nothing on the project restarts for it.</p>
704
+ </details>
705
+ </div>
706
+
698
707
  <!-- live since task 1003045: reads the repo's real visibility from GitHub
699
708
  with the owner's own repository credential. Flip-to-PUBLIC only — the
700
709
  private direction is refused (server + copy) until the private-repo
@@ -1457,7 +1466,9 @@ summary{min-height:24px;padding:3px 0;}
1457
1466
  }
1458
1467
  function typeHtml(t) {
1459
1468
  var l = typeLabel(t);
1460
- return l ? '<span class="typeTag">' + l + '</span>' : '—';
1469
+ /* a ledger row's fact is a self-describing line, so "not declared" is said
1470
+ in words where the table's Type cell used to print a dash (task 1003356) */
1471
+ return l ? '<span class="typeTag">' + l + '</span>' : 'type not declared';
1461
1472
  }
1462
1473
  function matchesMapQuery(p, q) {
1463
1474
  if (!q) return true;
@@ -1531,44 +1542,63 @@ summary{min-height:24px;padding:3px 0;}
1531
1542
  });
1532
1543
  }
1533
1544
 
1545
+ /* ── the row's facts, the ledger grammar (task 1003356) ──
1546
+ Each fact is one self-describing label-face line, the shape the Your-projects
1547
+ row uses, so the two views read as one ledger. The distinction the columns
1548
+ drew survives the move: "we could not ask" (null) and "we asked, the answer
1549
+ is none" (0) are two different sentences, not a dash and a zero. A figure
1550
+ that was never a column — shipped in 30 days, the Listed date — takes no
1551
+ space when it has no answer, the poster card's own rule. */
1552
+ function factLines(p) {
1553
+ var out = ['<span class="fact">' + typeHtml(p.type) + '</span>'];
1554
+ out.push('<span class="fact">' + (p.builders == null ? 'builders not known'
1555
+ : esc(String(p.builders)) + (p.builders === 1 ? ' builder' : ' builders')) + '</span>');
1556
+ var shipped = shippedFigure(p);
1557
+ if (shipped) out.push('<span class="fact">' + esc(shipped) + ' shipped in 30 days</span>');
1558
+ if (p.created_at) out.push('<span class="fact">listed ' + esc(listedDate(p.created_at)) + '</span>');
1559
+ return out.join('');
1560
+ }
1561
+
1534
1562
  function renderRows(list) {
1535
1563
  var body = $('mapRows');
1536
1564
  /* replace only the generated rows — the platform's own row is static
1537
1565
  apex-only markup and must survive every re-render, for the same reason
1538
1566
  #selfOrb does in the sky. It stays pinned first: it is "this platform",
1539
1567
  not a peer in the catalog's sort order. */
1540
- body.querySelectorAll('tr[data-generated]').forEach(function (n) { n.remove(); });
1568
+ body.querySelectorAll('li[data-generated]').forEach(function (n) { n.remove(); });
1541
1569
  body.insertAdjacentHTML('beforeend', list.map(function (p) {
1542
1570
  var rel = relOf(p);
1543
- var pill = rel === 'owner' ? ' <span class="pill">yours</span>'
1544
- : rel === 'builder' ? ' <span class="pill">you build here</span>' : '';
1545
- return '<tr data-generated ' + (p._inst ? 'data-manage="' + esc(String(p._inst.id)) + '" ' : '') +
1546
- 'data-origin="' + esc(String(p.origin || '')) + '">' +
1547
- '<td class="colName"><span class="pname"><span class="pdot" style="--acc:' +
1548
- accentOf(p) + '"></span>' +
1549
- '<span>' + (p._inst
1571
+ var pill = rel === 'owner' ? '<span class="pill">yours</span>'
1572
+ : rel === 'builder' ? '<span class="pill">you build here</span>' : '';
1573
+ /* the project's own planet, the object the sky and the Your-projects row
1574
+ already draw — the owner's chosen template and colour first (task
1575
+ 1003347), the slug walk otherwise. Decorative here: the name beside it
1576
+ is the row's accessible door. */
1577
+ var planet = '<span class="worldOrb"' + planetAttrs({ tpl: templateOf(p) }) +
1578
+ ' style="--acc:' + accentOf(p) + ';--d:64px" aria-hidden="true"><span class="disc"></span></span>';
1579
+ return '<li class="tblRow" data-generated ' + (p._inst ? 'data-manage="' + esc(String(p._inst.id)) + '" ' : '') +
1580
+ 'data-origin="' + esc(String(p.origin || '')) + '">' + planet +
1581
+ '<div class="tWho">' + (p._inst
1550
1582
  ? '<a class="ptitle" data-manage="' + esc(String(p._inst.id)) + '" href="?view=manage&id=' + esc(String(p._inst.id)) + '">' + esc(p.name) + '</a>'
1551
- : '<span class="ptitle">' + esc(p.name) + '</span>') + pill + '<br>' +
1552
- '<span class="ptag">' + esc(p.tagline || '') + '</span></span></span></td>' +
1553
- /* Builders and Runs come from the project's OWN public API, enriched
1554
- server-side (task 1002771). null is unknown — the project was
1555
- unreachable — and an em dash says exactly that. 0 is a real answer and
1556
- renders as 0, because "nobody has joined yet" is a different fact from
1557
- "we could not ask". */
1558
- '<td class="colType">' + typeHtml(p.type) + '</td>' +
1559
- '<td class="right num">' + (p.builders == null ? '—' : esc(String(p.builders))) + '</td>' +
1560
- '<td class="colTags">' + tagsHtml(p.tags) + '</td>' +
1583
+ : '<span class="ptitle">' + esc(p.name) + '</span>') + pill +
1561
1584
  /* liveness on a CATALOG row is NOT probed by this surface — a chip
1562
1585
  saying "live" there would claim more than anything verified (C6).
1563
1586
  An OWNED row is different: the own-scoped feed answers for it, so
1564
1587
  it carries chipFor()'s truth, the same chip its orb and its manage
1565
1588
  card show (task 1002744). */
1566
- '<td class="right colStatus">' + (p._inst ? chipFor(p._inst)
1567
- : '<span class="chip calm"><span class="cdot"></span>—</span>') + '</td>' +
1568
- '<td class="colGo">' + (safeHttpUrl(p.origin)
1569
- ? '<a class="rowGo" href="' + esc(safeHttpUrl(p.origin)) + '" target="_blank" rel="noopener">Open →</a>'
1570
- : '') + hallGlyphHtml(p, rel) + '</td>' +
1571
- '</tr>';
1589
+ (p._inst ? chipFor(p._inst)
1590
+ : '<span class="chip calm"><span class="cdot"></span>not checked</span>') + '</div>' +
1591
+ '<div class="facts">' + factLines(p) + '</div>' +
1592
+ '<p class="ptag">' + esc(p.tagline || '') + '</p>' +
1593
+ /* Runs stays the module list here, where the poster card's Runs is the
1594
+ address (direction §7): null is "we could not ask", [] is "core" */
1595
+ '<div class="runs"><span class="rLbl">runs</span>' + tagsHtml(p.tags) + '</div>' +
1596
+ /* at most two doors (task 1003356): the site, and the hall where the
1597
+ viewer builds there and the hall answers at a different address */
1598
+ '<div class="rowDoors">' + (safeHttpUrl(p.origin)
1599
+ ? '<a class="rowGo" href="' + esc(safeHttpUrl(p.origin)) + '" target="_blank" rel="noopener">view</a>'
1600
+ : '') + hallGlyphHtml(p, rel) + '</div>' +
1601
+ '</li>';
1572
1602
  }).join(''));
1573
1603
  }
1574
1604
 
@@ -1651,7 +1681,7 @@ summary{min-height:24px;padding:3px 0;}
1651
1681
  }
1652
1682
  if (row) {
1653
1683
  row.hidden = !selfShown;
1654
- $('selfPill').innerHTML = badge ? ' <span class="pill">' + badge + '</span>' : '';
1684
+ $('selfPill').innerHTML = badge ? '<span class="pill">' + badge + '</span>' : '';
1655
1685
  /* the hall glyph: member-gated (the badge says so) and address-gated */
1656
1686
  var sh = $('selfHall');
1657
1687
  if (sh) sh.hidden = !badge || !hallUrlOf(SELF_PROJECT);
@@ -1897,7 +1927,7 @@ summary{min-height:24px;padding:3px 0;}
1897
1927
  }
1898
1928
  /* the row's own "Open →" keeps its job — the card is for the rest of the row */
1899
1929
  if (ev.target.closest && ev.target.closest('a')) return;
1900
- var tr = ev.target.closest && ev.target.closest('tr');
1930
+ var tr = ev.target.closest && ev.target.closest('li');
1901
1931
  if (!tr || !isPlainClick(ev)) return;
1902
1932
  if (tr.id === 'selfRow') return openCard(SELF_PROJECT, selfRel(), null);
1903
1933
  /* an owned row goes to MANAGE, exactly like its orb — two views, one job */
@@ -2016,7 +2046,7 @@ summary{min-height:24px;padding:3px 0;}
2016
2046
  var rows = (d && d.leaderboard) || [];
2017
2047
  if (!rows.length) return;
2018
2048
  var real = rows.filter(function (b) { return !/-system$/.test(String(b.github_login || '')); });
2019
- $('selfBuilders').textContent = String(real.length);
2049
+ $('selfBuilders').textContent = real.length === 1 ? '1 builder' : real.length + ' builders';
2020
2050
  /* the card reads these too: the same payload answers the platform's
2021
2051
  builder count and its owner — the archon row — by the same rule the
2022
2052
  server-side enrichment applies to every other project */
@@ -2042,6 +2072,10 @@ summary{min-height:24px;padding:3px 0;}
2042
2072
  var n = d.tasks.filter(function (t) { return Date.parse(t && t.shipped_at) >= since; }).length;
2043
2073
  SELF_PROJECT.shipped_30d = n;
2044
2074
  SELF_PROJECT.shipped_30d_capped = d.tasks.length >= 200 && n === d.tasks.length;
2075
+ /* the row's own fact, by the same rule every other row's is written */
2076
+ var sf = $('selfShipped');
2077
+ sf.textContent = shippedFigure(SELF_PROJECT) + ' shipped in 30 days';
2078
+ sf.hidden = false;
2045
2079
  })
2046
2080
  .catch(function () { /* unknown stays unknown: the figure hides */ });
2047
2081
  }
@@ -2099,21 +2133,33 @@ summary{min-height:24px;padding:3px 0;}
2099
2133
 
2100
2134
  $('onlyMine').addEventListener('change', applyMapFilter);
2101
2135
  $('mapQ').addEventListener('input', applyMapFilter);
2102
- /* the sort control is the BUTTON, not the th: a th takes no focus and no
2103
- Enter/Space, so the column sort was mouse-only. aria-sort stays on the th,
2104
- which is where a screen reader reads it from. */
2105
- document.querySelectorAll('.tbl th[data-sort]').forEach(function (th) {
2106
- var btn = th.querySelector('button');
2107
- if (!btn) return;
2136
+ /* ── the sort, off the column head (task 1003356) ──
2137
+ A ledger row has no columns to head, so aria-sort has no host: the two
2138
+ orders are toggle buttons in a group, pressed = the live order, and the
2139
+ sentence under the list states it for a screen reader that never saw the
2140
+ press. Pressing the pressed one reverses, exactly as the header did. */
2141
+ var SORT_WORDS = { name: ['a to z', 'z to a'], builders: ['fewest first', 'most first'] };
2142
+ function paintSort() {
2143
+ document.querySelectorAll('.tblSort button[data-sort]').forEach(function (b) {
2144
+ var on = b.getAttribute('data-sort') === mapSort.key;
2145
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
2146
+ var dir = b.querySelector('.dir');
2147
+ if (on && !dir) { dir = document.createElement('span'); dir.className = 'dir'; b.appendChild(document.createTextNode(' ')); b.appendChild(dir); }
2148
+ if (dir) { dir.hidden = !on; dir.textContent = on ? SORT_WORDS[mapSort.key][mapSort.dir === 1 ? 0 : 1] : ''; }
2149
+ });
2150
+ $('mapSorted').textContent = 'Sorted by ' + mapSort.key + ', ' +
2151
+ SORT_WORDS[mapSort.key][mapSort.dir === 1 ? 0 : 1] + '.';
2152
+ }
2153
+ document.querySelectorAll('.tblSort button[data-sort]').forEach(function (btn) {
2108
2154
  btn.addEventListener('click', function () {
2109
- var k = th.getAttribute('data-sort');
2155
+ var k = btn.getAttribute('data-sort');
2110
2156
  mapSort.dir = (mapSort.key === k) ? -mapSort.dir : 1;
2111
2157
  mapSort.key = k;
2112
- document.querySelectorAll('.tbl th[data-sort]').forEach(function (o) { o.removeAttribute('aria-sort'); });
2113
- th.setAttribute('aria-sort', mapSort.dir === 1 ? 'ascending' : 'descending');
2158
+ paintSort();
2114
2159
  applyMapFilter();
2115
2160
  });
2116
2161
  });
2162
+ paintSort();
2117
2163
 
2118
2164
  /* ══ THE LIVING SKY (task 1002762) ═════════════════════════════════════════
2119
2165
  The retired gate's cluster, rewritten for the hub. Physics is the gate's
@@ -3447,6 +3493,14 @@ summary{min-height:24px;padding:3px 0;}
3447
3493
  var GH_ICON = '<svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>';
3448
3494
 
3449
3495
  var instanceId = null; /* set when a create lands / a resumed watch starts */
3496
+ /* where a project is ACTED on since task 1003355: its own page. A list row
3497
+ carries only view and manage now, so every pointer that names a control —
3498
+ attach an address, retry a snagged standup, bring a torn-down project
3499
+ back — must land there, not on the list, which has no such button. */
3500
+ function projectPageLink(label) {
3501
+ return '<a href="' + (instanceId ? '?view=manage&id=' + encodeURIComponent(instanceId) : '?view=mine') +
3502
+ '">' + label + '</a>';
3503
+ }
3450
3504
  var live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
3451
3505
  var planPromises = {}; /* mode|domain → Promise<steps[]> */
3452
3506
  var lastStepsHtml = '';
@@ -3518,7 +3572,7 @@ summary{min-height:24px;padding:3px 0;}
3518
3572
  function renderStepOauthApp() {
3519
3573
  var domain = resolvedDomain();
3520
3574
  if (!domain) {
3521
- return '<li class="slocked" data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>Unlocks once your project has an address — sign-in needs a real domain. Attach one any time from <a href="?view=mine">your projects page</a>.</p></li>';
3575
+ return '<li class="slocked" data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>Unlocks once your project has an address — sign-in needs a real domain. Attach one any time from ' + projectPageLink('this project’s own page') + '.</p></li>';
3522
3576
  }
3523
3577
  /* honesty about the venue (owner walk, 2026-08-12): GitHub rejects app
3524
3578
  manifests whose hook URL isn't publicly reachable, so a locally-hosted
@@ -3535,7 +3589,7 @@ summary{min-height:24px;padding:3px 0;}
3535
3589
  address yet — says where to go instead of what the server said. */
3536
3590
  var snag, raw = '';
3537
3591
  if (live.manifest === 'expired') snag = 'That setup link expired (they last 30 minutes) — mint a fresh one.';
3538
- else if (live.manifestCode === 'domain_required') snag = 'This project needs an address first — attach one from <a href="?view=mine">your projects page</a>, then come back to this step.';
3592
+ else if (live.manifestCode === 'domain_required') snag = 'This project needs an address first — attach one from ' + projectPageLink('its own page') + ', then come back to this step.';
3539
3593
  else { snag = 'GitHub setup didn’t start. Nothing was changed on GitHub — try again.'; raw = live.manifestMsg; }
3540
3594
  return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>' + snag + '</p>' +
3541
3595
  (raw ? '<details><summary>Technical details</summary><p>' + esc(raw) + '</p></details>' : '') +
@@ -3866,7 +3920,8 @@ summary{min-height:24px;padding:3px 0;}
3866
3920
  answer at, so a verified answer can never arrive; say what IS true
3867
3921
  instead of promising the hall (sign-in stays locked without one) */
3868
3922
  setProgress(progressMarkup(-1, 2,
3869
- 'The build finished — your project is standing, but it has no address yet, so sign-in can’t be wired up. Attach a domain any time from <a href="?view=mine">your projects page</a>; the steps below that need one unlock then.', '', flow));
3923
+ 'The build finished — your project is standing, but it has no address yet, so sign-in can’t be wired up. Attach a domain any time from ' +
3924
+ projectPageLink('its own page') + '; the steps below that need one unlock then.', '', flow));
3870
3925
  return true;
3871
3926
  }
3872
3927
  if (inst.last_verified_up === false) {
@@ -3885,16 +3940,19 @@ summary{min-height:24px;padding:3px 0;}
3885
3940
  return false;
3886
3941
  } else if (s === 'error') {
3887
3942
  /* the honest snag (direction §5 / F19): no raw stderr as the headline, no
3888
- false "keeps retrying" — retries stop, and the retry door is the mine view */
3943
+ false "keeps retrying" — retries stop, and the retry door is the project's
3944
+ own page (task 1003355 moved it off the row) */
3889
3945
  setProgress(progressMarkup(1, 1,
3890
- 'The standup stopped partway — it won’t continue on its own. Retry it (and see the technical details) from <a href="?view=mine">your projects page</a>.', 'isErr'));
3946
+ 'The standup stopped partway — it won’t continue on its own. Retry it (and see the technical details) from ' +
3947
+ projectPageLink('its own page') + '.', 'isErr'));
3891
3948
  } else if (s === 'tearing_down') {
3892
3949
  /* a deliberate end is calm, never styled as a failure (direction §5) —
3893
3950
  and it speaks the teardown vocabulary, not the standup's (F22) */
3894
3951
  setProgress(progressMarkup(1, 1, 'Taking the server offline. The repository stays yours; ' +
3895
3952
  (inst.hosting_shape === 'dedicated' ? 'its database goes with the machine.' : 'the database is kept.'), '', 'teardown'));
3896
3953
  } else if (s === 'torn_down') {
3897
- setProgress(progressMarkup(-1, 3, 'This instance was torn down. Bring it back any time from <a href="?view=mine">your projects page</a>, or start another below.', '', 'teardown'));
3954
+ setProgress(progressMarkup(-1, 3, 'This instance was torn down. Bring it back any time from ' +
3955
+ projectPageLink('its own page') + ', or start another below.', '', 'teardown'));
3898
3956
  return true;
3899
3957
  } else {
3900
3958
  setProgress(progressMarkup(0, 0, 'Filed. Watching for the standup to start…'));
@@ -4234,16 +4292,6 @@ summary{min-height:24px;padding:3px 0;}
4234
4292
  return chipMarkup('', inst.status);
4235
4293
  }
4236
4294
 
4237
- /* client mirror of provisioning.TEARDOWN_FROM — every state EXCEPT torn_down
4238
- (nothing left to release), minus tearing_down (already going) and minus a
4239
- teardown that is already queued. It used to list only provisioning+active,
4240
- which is how a snagged project ended up with no way out of the owner's list
4241
- at all: its only button was "Try again" (walk finding N5, task 1003042). */
4242
- function canTeardown(inst) {
4243
- if (inst.open_intent && inst.open_intent.action === 'teardown') return false;
4244
- if (inst.status === 'tearing_down' || inst.status === 'torn_down') return false;
4245
- return true;
4246
- }
4247
4295
  /* client mirror of provisioning.DOMAIN_ATTACH_FROM */
4248
4296
  /* the platform-assigned <slug>.<apex> for this instance, from the server-stamped
4249
4297
  apex only — no host guessing here: the upgrade door below must never show for
@@ -4471,7 +4519,11 @@ summary{min-height:24px;padding:3px 0;}
4471
4519
  : 'The server goes away. Your repository is untouched. The database is kept, so bringing it back later restores its memory. Its web address stays reserved for this project and shows an error page until you bring it back. Nothing here erases a project.';
4472
4520
  }
4473
4521
 
4474
- function addressRow(inst) {
4522
+ /* onManage: the same facts, but the pointers name the controls THIS surface
4523
+ has. Since task 1003355 the list row acts on nothing, so a row's sentence
4524
+ sends the owner to the project's own page and the manage page's says
4525
+ "below" — the ADR 0199 §8 rule that a refusal must name a reachable door. */
4526
+ function addressRow(inst, onManage) {
4475
4527
  /* state FIRST, then domain: a torn-down or snagged project still holds its
4476
4528
  domain (ADR 0181 keeps port + address), and the standup promise below
4477
4529
  was reaching it — "answers once the instance is live" two lines above
@@ -4499,7 +4551,7 @@ summary{min-height:24px;padding:3px 0;}
4499
4551
  available. Where there is no button the sentence stops at the full stop. */
4500
4552
  if (inst.last_verified_up === false) {
4501
4553
  return 'Not answering at ' + link + ' right now. We keep checking every few minutes and update this on its own if it comes back' +
4502
- (canRestart(inst) ? ' — or restart it yourself below.' : '.');
4554
+ (canRestart(inst) ? (onManage ? ' — or restart it yourself below.' : ' — or restart it from its own page.') : '.');
4503
4555
  }
4504
4556
  if (inst.last_verified_up) return 'Its address is ' + link + ' — last answered ' + esc(relTime(verifiedAt(inst))) + '.';
4505
4557
  return 'Its address is ' + link + ' — confirming it answers.';
@@ -4508,50 +4560,9 @@ summary{min-height:24px;padding:3px 0;}
4508
4560
  return 'Address: <code>' + esc(inst.domain) + '</code> — it answers there once the instance is live.';
4509
4561
  }
4510
4562
  /* the HONEST no-address state: sign-in on the instance needs a real domain */
4511
- return '<span class="none">No address yet</span> — sign-in on this project can’t be set up until it has one. <a href="?view=mine" data-nav-to="mine">Give it an address from your projects</a>.';
4512
- }
4513
-
4514
- /* ── the bring-your-own-domain fold (owner decision 2, 2026-08-24). A live
4515
- card's loudest control used to be a full-width domain field + filled
4516
- button — for a domain the founder can't buy here — while the success
4517
- moment had only "Open site →". The field now sits behind the disclosure
4518
- the wizard's address step uses (direction §6): closed, after the card's
4519
- doors, and only where something is or will be answering — live and
4520
- queued. A snagged or torn-down card has nothing to point a domain at, so
4521
- it carries no fold (canAttachDomain stays the server's mirror; this is
4522
- the card's own call). The re-kick door (F19) keeps its shape — open and
4523
- pre-filled, one button: Try the address again. ── */
4524
- function attachFold(inst, iid) {
4525
- if (!canAttachDomain(inst)) return '';
4526
- if (inst.status !== 'active' && inst.status !== 'requested') return '';
4527
- /* the strip already says "Wiring the address" — a second attach would only
4528
- be refused (intent_conflict), so the fold waits for the run to land */
4529
- if (inst.open_intent && inst.open_intent.action === 'domain-attach') return '';
4530
- var rekick = inst.status === 'active' && inst.domain && inst.error_note;
4531
- /* the CORRECTION door (task 1003237): the project already answers on an
4532
- address the founder brought, and it can be changed. The field carries the
4533
- current one so a typo is fixed by editing it, not retyped from scratch —
4534
- the re-kick door's shape, for the same reason. The platform-assigned
4535
- <slug>.<apex> is NOT a correction: moving off it is the upgrade the fold
4536
- already offered, and the field stays empty for the domain being brought. */
4537
- var byo = inst.domain && inst.domain !== assignedDefaultFor(inst);
4538
- var repoint = !rekick && byo;
4539
- var prefill = rekick || repoint;
4540
- var label = rekick ? 'Try the address again'
4541
- : repoint ? 'Use a different domain'
4542
- : inst.domain ? 'Use a domain I own' : 'Give it an address';
4543
- return '<details class="attachFold"' + (rekick ? ' open' : '') + '>' +
4544
- '<summary>' + label + '</summary>' +
4545
- '<div class="attach">' +
4546
- '<input type="text" id="dom-' + iid + '" placeholder="myproject.example.com" autocomplete="off" spellcheck="false" maxlength="253" aria-label="A domain you own, for ' + esc(inst.slug) + '"' +
4547
- (prefill ? ' value="' + esc(inst.domain) + '"' : '') + '>' +
4548
- '<button type="button" class="btn small" data-attach="' + iid + '">' +
4549
- (rekick ? 'Try the address again' : repoint ? 'Change the address' : 'Use this domain') + '</button></div>' +
4550
- /* true in every state the fold renders in: a REQUESTED project never
4551
- answered anywhere yet, so "stops answering" would be a claim about a
4552
- thing that never happened (the F16 rule) */
4553
- (repoint ? '<p class="attachNote">Changing it lets <code>' + esc(inst.domain) + '</code> go — this project won’t use that address any more.</p>' : '') +
4554
- '</details>';
4563
+ return '<span class="none">No address yet</span> — sign-in on this project can’t be set up until it has one. ' +
4564
+ (onManage ? 'Give it an address below.'
4565
+ : '<a href="?view=manage&id=' + esc(String(inst.id)) + '" data-manage="' + esc(String(inst.id)) + '">Give it an address on its own page</a>.');
4555
4566
  }
4556
4567
 
4557
4568
  function renderCard(inst) {
@@ -4561,7 +4572,6 @@ summary{min-height:24px;padding:3px 0;}
4561
4572
  var iid = esc(String(inst.id));
4562
4573
  var li = liFor(inst.id);
4563
4574
  if (!li) return;
4564
- if (cardAsking !== null && cardAsking === String(inst.id)) return; /* keep THIS card's open confirmation */
4565
4575
  var gone = inst.status === 'torn_down';
4566
4576
  li.className = 'proj' + (gone ? ' gone' : '');
4567
4577
  var meta = [];
@@ -4596,73 +4606,38 @@ summary{min-height:24px;padding:3px 0;}
4596
4606
  /* a live region: an action's result is announced where the pointer is,
4597
4607
  not only painted (task 1003244) */
4598
4608
  body += '<div class="projMsg' + (lm && lm.cls ? ' ' + lm.cls : '') + '" id="msg-' + iid + '" role="status" aria-live="polite">' + (lm ? lm.text : '') + '</div>';
4599
- /* every owned card opens its manage view (task 1003041) — a real link, so
4600
- middle/ctrl-click works; the delegated handler upgrades a plain click */
4601
- var acts = ['<a class="btn small ghost" data-manage="' + iid + '" href="?view=manage&id=' + iid + '">Manage</a>'];
4602
- /* the success moment needs a door: a live project opens from its own card
4603
- (the orb and the address link were the only ways in — task 1003232) */
4604
- if (inst.status === 'active' && inst.domain) acts.unshift('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">Open site →</a>');
4605
- if (canRetry(inst)) acts.push('<button type="button" class="btn small" data-retry="' + iid + '">Try again</button>');
4606
- if (canRevive(inst)) acts.push('<button type="button" class="btn small" data-revive="' + iid + '">Bring it back</button>');
4607
- if (canRestart(inst)) acts.push('<button type="button" class="btn small" data-restart="' + iid + '">Restart it</button>');
4608
- if (canTeardown(inst)) acts.push('<button type="button" class="btn small danger" data-teardown="' + iid + '">Tear it down</button>');
4609
- if (acts.length) body += '<div class="actions" id="act-' + iid + '">' + acts.join('') + '</div>';
4610
- body += attachFold(inst, iid);
4611
- /* sig() buckets recency per minute, so a live card re-renders on the
4612
- heartbeat — that must not snap an open fold shut, eat a half-typed
4613
- domain or drop the caret. defaultValue tells a founder's typing from
4614
- the re-kick pre-fill. */
4615
- var oldFold = li.querySelector('details.attachFold');
4616
- var oldInput = oldFold ? oldFold.querySelector('input') : null;
4617
- var keepOpen = !!(oldFold && oldFold.open);
4618
- var typed = oldInput && oldInput.value !== oldInput.defaultValue ? oldInput.value : '';
4619
- var hadFocus = !!oldInput && document.activeElement === oldInput;
4620
- /* focus anywhere else on the card (a door, a confirm, the fold's summary)
4621
- comes back to the same control — or the nearest one — after the swap;
4622
- quietActions() leaves a note when a disabled button already let go */
4609
+ /* TWO DOORS, never more (owner, 2026-08-29 — task 1003355): View opens the
4610
+ project itself, Manage opens its own page. Restart, try again, bring it
4611
+ back, the ender and the address all live there now, so a row SAYS what is
4612
+ true and opens a door — it no longer acts on the project. Both are real
4613
+ links, so middle/ctrl-click works; the delegated handler upgrades a plain
4614
+ click on Manage (task 1003041). */
4615
+ var acts = [];
4616
+ if (inst.status === 'active' && inst.domain) acts.push('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">View</a>');
4617
+ acts.push('<a class="btn small ghost" data-manage="' + iid + '" href="?view=manage&id=' + iid + '">Manage</a>');
4618
+ body += '<div class="actions">' + acts.join('') + '</div>';
4619
+ /* sig() buckets recency per minute, so a live row re-renders on the
4620
+ heartbeat — a door that held focus must get it back after the swap */
4623
4621
  var wasInside = li.contains(document.activeElement) && document.activeElement !== li;
4624
4622
  var prefer = wasInside ? focusKeyOf(document.activeElement) : null;
4625
- var focusBack = wasInside || li.hasAttribute('data-focus-back');
4626
- li.removeAttribute('data-focus-back');
4627
4623
  li.innerHTML = orbFor(inst) + '<div class="projBody">' + body + '</div>';
4628
- var fold = li.querySelector('details.attachFold');
4629
- var input = fold ? fold.querySelector('input') : null;
4630
- if (fold && keepOpen) fold.open = true;
4631
- if (input && typed) input.value = typed;
4632
- if (input && hadFocus) input.focus();
4633
- else if (focusBack) focusCardControl(inst.id, prefer);
4624
+ if (wasInside) focusCardControl(inst.id, prefer);
4634
4625
  }
4635
4626
 
4636
4627
  /* ── keeping focus on the card through a re-render (task 1003244) ── */
4637
4628
 
4638
- /* which control had focus, as a selector the rebuilt card can answer;
4639
- a confirm's Yes/Keep lands back on the door that opened it */
4629
+ /* which door had focus, as a selector the rebuilt row can answer. Manage is
4630
+ the one that carries a marker; View falls through to the .actions fallback,
4631
+ which is the same element whenever both doors render. */
4640
4632
  function focusKeyOf(el) {
4641
- if (!el || !el.getAttribute) return null;
4642
- if (el.tagName === 'SUMMARY') return 'details.attachFold summary';
4643
- if (el.hasAttribute('data-teardown-yes') || el.hasAttribute('data-teardown-no')) return '[data-teardown]';
4644
- var keys = ['data-manage', 'data-attach', 'data-retry', 'data-revive', 'data-restart', 'data-teardown'];
4645
- for (var i = 0; i < keys.length; i++) if (el.hasAttribute(keys[i])) return '[' + keys[i] + ']';
4646
- return null;
4633
+ return el && el.getAttribute && el.hasAttribute('data-manage') ? '[data-manage]' : null;
4647
4634
  }
4648
4635
  function focusCardControl(id, prefer) {
4649
4636
  var li = liFor(id);
4650
4637
  if (!li) return;
4651
- var t = (prefer && li.querySelector(prefer)) ||
4652
- li.querySelector('.actions a, .actions button') ||
4653
- li.querySelector('details.attachFold summary');
4638
+ var t = (prefer && li.querySelector(prefer)) || li.querySelector('.actions a');
4654
4639
  if (t) t.focus();
4655
4640
  }
4656
- /* an action in flight: the row stays put and its buttons go quiet, so
4657
- nothing under the pointer is deleted mid-click; a button that held
4658
- focus lets go when disabled, so the card remembers to take it back */
4659
- function quietActions(id) {
4660
- var act = $('act-' + id);
4661
- if (!act) return;
4662
- var li = liFor(id);
4663
- if (li && act.contains(document.activeElement)) li.setAttribute('data-focus-back', '');
4664
- act.querySelectorAll('button').forEach(function (b) { b.disabled = true; });
4665
- }
4666
4641
 
4667
4642
  function render(instances) {
4668
4643
  $('loading').hidden = true;
@@ -4756,17 +4731,6 @@ summary{min-height:24px;padding:3px 0;}
4756
4731
  }
4757
4732
  timers[id] = setTimeout(tick, POLL_MS);
4758
4733
  }
4759
- function watchOnce(id) {
4760
- fetch(API + '/provisioning/instances/' + encodeURIComponent(id), { credentials: 'same-origin' })
4761
- .then(function (res) { if (!res.ok) throw new Error('soft'); return res.json(); })
4762
- .then(function (data) {
4763
- var inst = data.instance || {};
4764
- inst.open_intent = data.open_intent || null;
4765
- takeUpdate(id, inst);
4766
- if (shouldWatch(inst)) watch(id);
4767
- })
4768
- .catch(function () { if (byId[id] && shouldWatch(byId[id])) watch(id); });
4769
- }
4770
4734
 
4771
4735
  /* ── settled-card refresh: the heartbeat overlay must reach the page.
4772
4736
  A card at 'active' has no fast poll; once a minute (and on tab return)
@@ -4867,7 +4831,7 @@ summary{min-height:24px;padding:3px 0;}
4867
4831
  if (server) rows.push(['server', server]);
4868
4832
  $('mMeta').innerHTML = rows.map(ledgerRow).join('');
4869
4833
  $('mMeta').hidden = !rows.length;
4870
- var addr = addressRow(inst);
4834
+ var addr = addressRow(inst, true);
4871
4835
  $('mAddr').innerHTML = addr ? ledgerRow(['address', addr]) : '';
4872
4836
  $('mAddr').hidden = !addr;
4873
4837
  /* a fact with no answer hides — never a dash (the card rule) */
@@ -4877,6 +4841,9 @@ summary{min-height:24px;padding:3px 0;}
4877
4841
  it: the delete section reads only status + open_intent, both already in
4878
4842
  hand, and a queued teardown must show up without a reload */
4879
4843
  renderDelete(inst);
4844
+ /* its address (task 1003355) is fetch-free too — the control it carries
4845
+ reads status + domain + open_intent, all already in hand */
4846
+ renderAddress(inst);
4880
4847
  /* same contract: the visibility section's in-flight line reads open_intent
4881
4848
  only, so a queued settings push shows up on the tick without a reload */
4882
4849
  renderVisState(inst);
@@ -4962,6 +4929,121 @@ summary{min-height:24px;padding:3px 0;}
4962
4929
  if (t.hasAttribute('data-m-restart')) return mRestart();
4963
4930
  });
4964
4931
 
4932
+ /* ── its address (task 1003355): the bring-your-own-domain control, moved off
4933
+ the list row when a row shrank to view and manage. Every server truth the
4934
+ card's fold carried comes with it: canAttachDomain mirrors
4935
+ DOMAIN_ATTACH_FROM, a snagged or torn-down project has nothing to point an
4936
+ address at, no second control renders while one is wiring, and the re-kick
4937
+ door (F19) and the correction door (task 1003237) keep their pre-filled
4938
+ shapes. Fetch-free, like every other section here, so the minute tick may
4939
+ repaint it. ── */
4940
+
4941
+ var mDomFor = null; /* the project the section is drawn for */
4942
+ var mDomNote = null; /* its last receipt — a repaint must not swallow it */
4943
+
4944
+ function domNote(html, cls) {
4945
+ mDomNote = html ? { html: html, cls: cls || '' } : null;
4946
+ var box = $('mDomMsg');
4947
+ if (!box) return;
4948
+ box.className = 'projMsg' + (cls ? ' ' + cls : '');
4949
+ box.innerHTML = html || '';
4950
+ }
4951
+
4952
+ function renderAddress(inst) {
4953
+ if (mDomFor !== inst.id) { mDomFor = inst.id; mDomNote = null; }
4954
+ /* the server's mirror first, then the card's own call: a project that never
4955
+ stood up and one that is gone have nothing to point an address at */
4956
+ var offer = canAttachDomain(inst) && (inst.status === 'active' || inst.status === 'requested');
4957
+ $('mAddress').hidden = !offer;
4958
+ if (!offer) return;
4959
+ /* the strip already says "Wiring the address" — a second attach would only
4960
+ be refused (intent_conflict), so the control waits for the run to land */
4961
+ if (inst.open_intent && inst.open_intent.action === 'domain-attach') {
4962
+ $('mDom').innerHTML = '<div class="addr">Wiring the address' +
4963
+ (inst.domain ? ' for <b>' + esc(inst.domain) + '</b>' : '') + ' right now.</div>' +
4964
+ '<p class="finePrint">This page updates when it lands.</p>';
4965
+ return;
4966
+ }
4967
+ var rekick = inst.status === 'active' && inst.domain && inst.error_note;
4968
+ /* the platform-assigned <slug>.<apex> is NOT a correction: moving off it is
4969
+ the upgrade this control already offered, and the field stays empty for
4970
+ the domain being brought */
4971
+ var byo = inst.domain && inst.domain !== assignedDefaultFor(inst);
4972
+ var repoint = !rekick && byo;
4973
+ var prefill = rekick || repoint;
4974
+ var lead = rekick ? 'The wiring for this address stopped. Send it again and the platform picks it up.'
4975
+ : repoint ? 'It answers on an address you brought. Point it at a different one by editing the name.'
4976
+ : inst.domain ? 'It has the address the platform assigned it. Point a domain you own at it instead.'
4977
+ : 'It has no address yet. Point a domain you own at it.';
4978
+ /* the minute tick must not eat a half-typed domain or drop the caret;
4979
+ defaultValue tells the founder's typing from the pre-fill (the card rule) */
4980
+ var old = $('mDom').querySelector('input[type=text]');
4981
+ var typed = old && old.value !== old.defaultValue ? old.value : '';
4982
+ var hadFocus = !!old && document.activeElement === old;
4983
+ $('mDom').innerHTML = '<p class="plain">' + lead + '</p>' +
4984
+ '<div class="attach">' +
4985
+ '<input type="text" id="mDomName" placeholder="myproject.example.com" autocomplete="off" spellcheck="false" maxlength="253" aria-label="A domain you own, for ' + esc(inst.slug) + '"' +
4986
+ (prefill ? ' value="' + esc(inst.domain) + '"' : '') + '>' +
4987
+ '<button type="button" class="btn small" data-m-attach>' +
4988
+ (rekick ? 'Try the address again' : repoint ? 'Change the address' : 'Use this domain') + '</button></div>' +
4989
+ /* true in every state this renders in: a REQUESTED project never answered
4990
+ anywhere yet, so "stops answering" would be a claim about a thing that
4991
+ never happened (the F16 rule) */
4992
+ (repoint ? '<p class="attachNote">Changing it lets <code>' + esc(inst.domain) + '</code> go — this project won’t use that address any more.</p>' : '') +
4993
+ '<div class="projMsg' + (mDomNote && mDomNote.cls ? ' ' + mDomNote.cls : '') + '" id="mDomMsg" role="status" aria-live="polite">' +
4994
+ (mDomNote ? mDomNote.html : '') + '</div>';
4995
+ var field = $('mDomName');
4996
+ if (field && typed) field.value = typed;
4997
+ if (field && hadFocus) field.focus();
4998
+ }
4999
+
5000
+ /* every refusal as the server worded it (the task 1002692 rule); the codes the
5001
+ page can say more precisely get their own sentence */
5002
+ function attachFailure(data, status) {
5003
+ var e = apiError(data, status);
5004
+ if (e.code === 'bad_domain') return 'That doesn’t look like a usable address — ' + esc(e.details.reason || 'use a real hostname like myproject.example.com') + '.';
5005
+ if (e.code === 'domain_taken') return esc(e.message || 'That address is already in use by another project.');
5006
+ if (e.code === 'domain_not_attachable') return esc(e.message || 'An address can be attached once the instance settles.');
5007
+ if (e.code === 'intent_conflict') {
5008
+ /* the address may still have been RECORDED (details.recorded) — say so */
5009
+ var t = esc(e.message || 'Another run holds the queue right now — re-send once it settles.');
5010
+ return e.details.recorded ? t + ' (The address itself is saved.)' : t;
5011
+ }
5012
+ return esc(e.message || 'The address could not be recorded — try again in a moment.');
5013
+ }
5014
+
5015
+ function mAttach() {
5016
+ var inst = manageInst;
5017
+ if (!inst) return;
5018
+ var field = $('mDomName');
5019
+ var domain = field ? String(field.value || '').trim() : '';
5020
+ if (!domain) { domNote('Type the domain you own first — e.g. <code>myproject.example.com</code>.', 'err'); return; }
5021
+ var btn = $('mDom').querySelector('[data-m-attach]');
5022
+ if (btn) btn.disabled = true;
5023
+ domNote('Recording the address…');
5024
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(inst.id) + '/domain', {
5025
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
5026
+ body: JSON.stringify({ domain: domain })
5027
+ }).then(function (res) {
5028
+ if (res.status === 401) { domNote(signInAgain(), 'err'); return null; }
5029
+ return jsonOrEmpty(res).then(function (data) {
5030
+ if (res.ok) { domNote(esc(data.message || 'Address recorded.'), 'ok'); return; }
5031
+ domNote(attachFailure(data, res.status), 'err');
5032
+ });
5033
+ }).catch(function () {
5034
+ domNote('Couldn’t reach the platform — check your connection and try again.', 'err');
5035
+ }).then(function () {
5036
+ var b = $('mDom').querySelector('[data-m-attach]');
5037
+ if (b) b.disabled = false;
5038
+ loadManage(true, true); /* the address this page renders just changed */
5039
+ });
5040
+ }
5041
+
5042
+ $('mDom').addEventListener('click', function (ev) {
5043
+ var t = ev.target.closest && ev.target.closest('button');
5044
+ if (t && t.hasAttribute('data-m-attach')) mAttach();
5045
+ });
5046
+
4965
5047
  /* an IN-FLIGHT project is watched here at the card's cadence — its own copy
4966
5048
  says "this page updates when it starts", so it must. A settled project
4967
5049
  keeps the C8 rule: entry + tab return, plus the request-free minute tick. */
@@ -5631,9 +5713,11 @@ summary{min-height:24px;padding:3px 0;}
5631
5713
  function renderDelete(inst) {
5632
5714
  if (mDelAsking && mDelAsking === inst.slug) return; /* keep THIS project's open confirmation */
5633
5715
  if (inst.status === 'torn_down') {
5716
+ /* the revive door is on THIS page since task 1003355 — a pointer at the
5717
+ list would now send the owner to a row that only opens doors */
5634
5718
  delMsg('<div class="addr">This project is already torn down — its server is stopped.</div>' +
5635
- '<p class="finePrint">Its repository, its database and its web address are all still here. ' +
5636
- 'Bring it back any time from <a href="?view=mine" data-nav-to="mine">your projects</a>.</p>');
5719
+ '<p class="finePrint">Its repository, its database and its web address are all still here.' +
5720
+ (canRevive(inst) ? ' Bring it back with the button at the top of this page.' : '') + '</p>');
5637
5721
  return;
5638
5722
  }
5639
5723
  if (inst.status === 'tearing_down') {
@@ -5675,6 +5759,22 @@ summary{min-height:24px;padding:3px 0;}
5675
5759
  return !!(field && manageInst && field.value.trim() === manageInst.slug);
5676
5760
  }
5677
5761
 
5762
+ /* every refusal as the server worded it, with the two codes the page can say
5763
+ more precisely spelled out. This vocabulary rode the list row's sender until
5764
+ task 1003355 made the ender the only teardown door — losing it would have
5765
+ turned two named refusals back into the generic "could not be queued" the
5766
+ tasks 1002691/1002692 walk was about. */
5767
+ function teardownFailure(data, status) {
5768
+ var e = apiError(data, status);
5769
+ if (e.code === 'intent_conflict') {
5770
+ return esc(e.message || ('A ' + (e.details.action || 'previous') + ' run is still ' + (e.details.state || 'open') + ' — retry in a moment.'));
5771
+ }
5772
+ if (e.code === 'not_teardownable') {
5773
+ return esc(e.message || ('Nothing to tear down right now — the instance is ' + (e.details.status || 'settled') + '.'));
5774
+ }
5775
+ return esc(e.message || 'The teardown could not be queued — try again in a moment.');
5776
+ }
5777
+
5678
5778
  function doDelete() {
5679
5779
  var inst = manageInst;
5680
5780
  /* the typed name is this page's own gate, never the server's — so a click
@@ -5696,10 +5796,8 @@ summary{min-height:24px;padding:3px 0;}
5696
5796
  /* the still-true state first, then exactly what the server said */
5697
5797
  renderDelete(inst);
5698
5798
  $('mDel').insertAdjacentHTML('beforeend',
5699
- res.status === 401
5700
- ? '<div class="projMsg err">' + signInAgain() + '</div>'
5701
- : '<div class="projMsg err">' + esc(apiError(data, res.status).message ||
5702
- 'The teardown could not be queued — try again in a moment.') + '</div>');
5799
+ '<div class="projMsg err">' +
5800
+ (res.status === 401 ? signInAgain() : teardownFailure(data, res.status)) + '</div>');
5703
5801
  });
5704
5802
  }).catch(function () {
5705
5803
  renderDelete(inst);
@@ -5722,109 +5820,12 @@ summary{min-height:24px;padding:3px 0;}
5722
5820
  if (t.hasAttribute('data-del-no')) { mDelAsking = null; if (manageInst) renderDelete(manageInst); }
5723
5821
  });
5724
5822
 
5725
- /* ── teardown: ask, confirm (the truth, itemized — direction §5.4), then
5726
- SAY exactly what the server said ── */
5727
-
5728
- /* the id whose confirmation is open — renderCard skips that card, so the
5729
- minute re-render (sig() buckets recency) cannot wipe the prompt mid-read
5730
- (the manage page's mDelAsking rule, task 1003232) */
5731
- var cardAsking = null;
5732
- function askTeardown(id) {
5733
- var act = $('act-' + id);
5734
- if (!act) return;
5735
- cardAsking = String(id);
5736
- act.innerHTML = '<div class="confirmRow" tabindex="-1">' +
5737
- '<span class="plain">Tear it down? ' + teardownTruth(byId[id]) + '</span>' +
5738
- '<button type="button" class="btn small danger" data-teardown-yes="' + esc(id) + '">Yes, tear it down</button>' +
5739
- '<button type="button" class="linklike" data-teardown-no="' + esc(id) + '">Keep it running</button></div>';
5740
- /* the pressed button is gone — focus lands on the question itself, so
5741
- the truth is read before Tab reaches Yes (task 1003244) */
5742
- var row = act.firstChild;
5743
- if (row && row.focus) row.focus();
5744
- }
5745
- function unAskTeardown(id) { cardAsking = null; var inst = byId[id]; if (inst) renderCard(inst); }
5746
- function doTeardown(id) {
5747
- cardAsking = null;
5748
- quietActions(id);
5749
- msg(id, 'Asking…');
5750
- fetch(API + '/provisioning/instances/' + encodeURIComponent(id) + '/teardown', {
5751
- method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}'
5752
- }).then(function (res) {
5753
- if (res.status === 401) { msg(id, signInAgain(), 'err'); return null; }
5754
- return jsonOrEmpty(res).then(function (data) {
5755
- if (res.ok) {
5756
- /* truthful outcomes (task 1002691): queued:true = really queued;
5757
- queued:false = one was ALREADY queued; superseded = a stale
5758
- provision intent was cleared to make room */
5759
- var note = esc(data.message || 'Teardown requested.');
5760
- if (data.superseded) note += ' (A stale standup request was cleared to make room.)';
5761
- msg(id, note, 'ok');
5762
- return;
5763
- }
5764
- var e = apiError(data, res.status);
5765
- if (e.code === 'intent_conflict') {
5766
- msg(id, esc(e.message || ('A ' + (e.details.action || 'previous') + ' run is still ' + (e.details.state || 'open') + ' — retry in a moment.')), 'err');
5767
- } else if (e.code === 'not_teardownable') {
5768
- msg(id, esc(e.message || ('Nothing to tear down right now — the instance is ' + (e.details.status || 'settled') + '.')), 'err');
5769
- } else {
5770
- msg(id, esc(e.message || 'The teardown could not be queued — try again in a moment.'), 'err');
5771
- }
5772
- });
5773
- }).catch(function () {
5774
- msg(id, 'Couldn’t reach the platform — check your connection and try again.', 'err');
5775
- }).then(function () {
5776
- /* wake the quieted row even when the state didn't change (renderCard
5777
- also hands focus back), then pick up whatever DID change */
5778
- if (byId[id]) renderCard(byId[id]);
5779
- watchOnce(id);
5780
- });
5781
- }
5782
-
5783
- /* ── re-enqueue: Try again (snagged) and Bring it back (torn down) both
5784
- re-POST the slug — the server's PROVISION_ENQUEUE_FROM gate decides.
5785
- Same truth-telling as every other action: say what the server said. ── */
5786
-
5787
- function reProvision(id, verb) {
5788
- var inst = byId[id];
5789
- if (!inst) return;
5790
- quietActions(id);
5791
- msg(id, 'Asking…');
5792
- var body = { slug: inst.slug, hosting_shape: inst.hosting_shape || 'standalone' };
5793
- if (inst.onboard_mode) body.onboard_mode = inst.onboard_mode;
5794
- if (inst.target_ref) body.target_ref = inst.target_ref;
5795
- if (inst.domain) body.domain = inst.domain;
5796
- fetch(API + '/provisioning/instances', {
5797
- method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
5798
- body: JSON.stringify(body)
5799
- }).then(function (res) {
5800
- if (res.status === 401) { msg(id, signInAgain(), 'err'); return null; }
5801
- return jsonOrEmpty(res).then(function (data) {
5802
- if (res.ok) {
5803
- msg(id, esc(data.message || (verb === 'revive' ? 'Revive queued — bringing it back.' : 'Queued again — the platform picks it up from here.')), 'ok');
5804
- return;
5805
- }
5806
- var e = apiError(data, res.status);
5807
- if (e.code === 'intent_conflict') {
5808
- msg(id, esc(e.message || 'A previous run is still open — retry once it settles.'), 'err');
5809
- } else if (e.code === 'rate_limited') {
5810
- msg(id, esc(e.message || 'Too many requests right now — give it a minute and try again.'), 'err');
5811
- } else {
5812
- msg(id, esc(e.message || 'The request could not be queued — try again in a moment.'), 'err');
5813
- }
5814
- });
5815
- }).catch(function () {
5816
- msg(id, 'Couldn’t reach the platform — check your connection and try again.', 'err');
5817
- }).then(function () {
5818
- if (byId[id]) renderCard(byId[id]);
5819
- watchOnce(id);
5820
- });
5821
- }
5822
-
5823
5823
  /* ── restart an unreachable project (task 1003238). Its own route, because
5824
5824
  PROVISION_ENQUEUE_FROM excludes 'active' — re-POSTing the slug the way
5825
5825
  Try again / Bring it back do would be refused on every press. Every
5826
5826
  refusal is shown as the server worded it: the server owns the gate, and
5827
- a card that invents its own reason drifts from it. ── */
5827
+ a page that invents its own reason drifts from it. mRestart() is the one
5828
+ sender since task 1003355 moved the door off the list row. ── */
5828
5829
 
5829
5830
  function restartFailure(data, status) {
5830
5831
  var e = apiError(data, status);
@@ -5832,84 +5833,16 @@ summary{min-height:24px;padding:3px 0;}
5832
5833
  return e.message || 'The restart could not be queued — try again in a moment.';
5833
5834
  }
5834
5835
 
5835
- function doRestart(id) {
5836
- var inst = byId[id];
5837
- if (!inst) return;
5838
- quietActions(id);
5839
- msg(id, 'Asking…');
5840
- fetch(API + '/provisioning/instances/' + encodeURIComponent(id) + '/restart', {
5841
- method: 'POST', credentials: 'same-origin'
5842
- }).then(function (res) {
5843
- if (res.status === 401) { msg(id, signInAgain(), 'err'); return null; }
5844
- return jsonOrEmpty(res).then(function (data) {
5845
- if (res.ok) { msg(id, esc(data.message || 'Restart queued.'), 'ok'); return; }
5846
- msg(id, esc(restartFailure(data, res.status)), 'err');
5847
- });
5848
- }).catch(function () {
5849
- msg(id, 'Couldn’t reach the platform — check your connection and try again.', 'err');
5850
- }).then(function () {
5851
- if (byId[id]) renderCard(byId[id]);
5852
- watchOnce(id);
5853
- });
5854
- }
5855
-
5856
- /* ── domain attach: same truth-telling for every refusal (task 1002692) ── */
5857
-
5858
- function doAttach(id) {
5859
- var input = $('dom-' + id);
5860
- var domain = input ? String(input.value || '').trim() : '';
5861
- if (!domain) { msg(id, 'Type the domain you own first — e.g. <code>myproject.example.com</code>.', 'err'); return; }
5862
- msg(id, 'Recording the address…');
5863
- fetch(API + '/provisioning/instances/' + encodeURIComponent(id) + '/domain', {
5864
- method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
5865
- body: JSON.stringify({ domain: domain })
5866
- }).then(function (res) {
5867
- if (res.status === 401) { msg(id, signInAgain(), 'err'); return null; }
5868
- return jsonOrEmpty(res).then(function (data) {
5869
- if (res.ok) {
5870
- msg(id, esc(data.message || 'Address recorded.'), 'ok');
5871
- return;
5872
- }
5873
- var e = apiError(data, res.status);
5874
- if (e.code === 'bad_domain') {
5875
- msg(id, 'That doesn’t look like a usable address — ' + esc(e.details.reason || 'use a real hostname like myproject.example.com') + '.', 'err');
5876
- } else if (e.code === 'domain_taken') {
5877
- msg(id, esc(e.message || 'That address is already in use by another project.'), 'err');
5878
- } else if (e.code === 'domain_not_attachable') {
5879
- msg(id, esc(e.message || 'An address can be attached once the instance settles.'), 'err');
5880
- } else if (e.code === 'intent_conflict') {
5881
- /* the address may still have been RECORDED (details.recorded) — say so */
5882
- var t = esc(e.message || 'Another run holds the queue right now — re-send once it settles.');
5883
- if (e.details.recorded) t += ' (The address itself is saved.)';
5884
- msg(id, t, 'err');
5885
- } else {
5886
- msg(id, esc(e.message || 'The address could not be recorded — try again in a moment.'), 'err');
5887
- }
5888
- });
5889
- }).catch(function () {
5890
- msg(id, 'Couldn’t reach the platform — check your connection and try again.', 'err');
5891
- }).then(function () { watchOnce(id); });
5892
- }
5893
-
5894
- /* one delegated listener for all card actions */
5836
+ /* the list's one delegated handler. A row holds no sender since task 1003355
5837
+ — only its two doors — so the Manage link is all there is to upgrade: it is
5838
+ a real anchor, a modified click keeps its browser meaning and a plain one
5839
+ stays in-page (the orb-click contract). The address line's "give it an
5840
+ address" link carries the same marker, so it lands on the same page. */
5895
5841
  $('projectsList').addEventListener('click', function (ev) {
5896
- /* the Manage link is a real anchor — a modified click keeps its browser
5897
- meaning, a plain one stays in-page (the orb-click contract) */
5898
5842
  var m = ev.target.closest && ev.target.closest('a[data-manage]');
5899
- if (m) {
5900
- if (!isPlainClick(ev)) return;
5901
- ev.preventDefault();
5902
- return goToManage(m.getAttribute('data-manage'));
5903
- }
5904
- var t = ev.target.closest('button');
5905
- if (!t) return;
5906
- if (t.hasAttribute('data-teardown')) return askTeardown(t.getAttribute('data-teardown'));
5907
- if (t.hasAttribute('data-teardown-yes')) return doTeardown(t.getAttribute('data-teardown-yes'));
5908
- if (t.hasAttribute('data-teardown-no')) return unAskTeardown(t.getAttribute('data-teardown-no'));
5909
- if (t.hasAttribute('data-attach')) return doAttach(t.getAttribute('data-attach'));
5910
- if (t.hasAttribute('data-retry')) return reProvision(t.getAttribute('data-retry'), 'retry');
5911
- if (t.hasAttribute('data-revive')) return reProvision(t.getAttribute('data-revive'), 'revive');
5912
- if (t.hasAttribute('data-restart')) return doRestart(t.getAttribute('data-restart'));
5843
+ if (!m || !isPlainClick(ev)) return;
5844
+ ev.preventDefault();
5845
+ goToManage(m.getAttribute('data-manage'));
5913
5846
  });
5914
5847
 
5915
5848
  /* ── boot: who am I → my instances ────────────────────────────────────── */