@bongos/core 1.19.327 → 1.19.329

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.
@@ -42,6 +42,9 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
42
42
  intent); the control-plane runner does the real infra work (ADR 0111 §2). */
43
43
  .viewWrap{padding-bottom:40px;}
44
44
  .card{margin-bottom:26px;}
45
+ /* page titles align with the cards below them — cosmos's .hero is 880px-centred
46
+ for the landing hero, which indented every hub title ~48px past its card */
47
+ #view-mine .hero,#view-manage .hero{max-width:none;}
45
48
 
46
49
  /* ── the manage view (task 1003041): container rhythm only — every component
47
50
  it draws (card, chip, addr, empty) is cosmos vocabulary ── */
@@ -553,11 +556,11 @@ h1[tabindex="-1"]:focus{outline:none;}
553
556
  <p class="mineEmpty" id="mineEmpty" hidden>Nothing matches that. <button type="button" class="linklike" id="mineClear">Clear the filters</button></p>
554
557
 
555
558
  <section class="empty" id="empty" hidden>
556
- <p>You don't own any projects yet. Creating one takes about five minutes — name it, pick a home for its code, and it gets an address of its own in this sky.</p>
559
+ <p>You don't own any projects yet. Name one, pick a home for its code, and it gets an address of its own in this sky.</p>
557
560
  <a class="btn" href="?view=new" data-nav-to="new">Create your first project</a>
558
561
  </section>
559
562
  <ul class="projects" id="projectsList"></ul>
560
- <p class="finePrint">Buttons here ask the platform to act — a runner does the real work, and each card reports only what was verified, with a timestamp. Standups keep running even with this tab closed.</p>
563
+ <p class="finePrint">Buttons here ask the platform to act; each card reports only what was verified, and when. A standup keeps going even with this tab closed.</p>
561
564
  </section>
562
565
  </main>
563
566
  </div>
@@ -597,8 +600,8 @@ h1[tabindex="-1"]:focus{outline:none;}
597
600
 
598
601
  <section id="mMain" hidden>
599
602
  <div class="hero" style="padding-left:0;padding-right:0">
600
- <h1>Manage</h1>
601
- <p class="lede">One project, up close — its address, its repository, and what the platform last <b>verified</b> about it, with a timestamp. The controls land here piece by piece; each section says what it can do today.</p>
603
+ <h1 id="mTitle">Manage</h1>
604
+ <p class="lede">Its address, its repository, what the platform last <b>verified</b> about it — and the controls you have today.</p>
602
605
  </div>
603
606
 
604
607
  <div class="card">
@@ -606,16 +609,13 @@ h1[tabindex="-1"]:focus{outline:none;}
606
609
  <div class="projMeta" id="mMeta" hidden></div>
607
610
  <div class="addr" id="mAddr" hidden></div>
608
611
  <p class="finePrint" id="mCreated" hidden></p>
609
- </div>
610
-
611
- <div class="card" id="mVisibility">
612
- <h2 style="font-family:var(--font-display);font-size:19px;margin-bottom:12px;">Who can see it</h2>
613
- <div class="empty"><p>Choosing who can open this project’s pages — everyone, or only people signed in to it — isn’t wired up here yet. It lands in one of the next updates; until then every project keeps the behavior it already has.</p></div>
614
- </div>
615
-
616
- <div class="card" id="mJoinability">
617
- <h2 style="font-family:var(--font-display);font-size:19px;margin-bottom:12px;">How people join</h2>
618
- <div class="empty"><p>Choosing how new people join — walk right in, apply first, or invite only — isn’t wired up here yet. It lands in one of the next updates.</p></div>
612
+ <!-- the card's own story, on the project's own page (task 1003232): the
613
+ per-flow strip, the honest snag, and the doors — the same
614
+ stripFor/errMarkup/canRetry/canRevive the list card reads -->
615
+ <div id="mStrip"></div>
616
+ <div class="projMsg err" id="mErr" hidden></div>
617
+ <div class="projMsg" id="mMsg"></div>
618
+ <div class="actions" id="mActs" hidden></div>
619
619
  </div>
620
620
 
621
621
  <!-- live since task 1003045: reads the repo's real visibility from GitHub
@@ -636,6 +636,18 @@ h1[tabindex="-1"]:focus{outline:none;}
636
636
  </details>
637
637
  </div>
638
638
 
639
+ <!-- the two settings the sibling tasks (1003043 / 1003044) fill in — placed
640
+ BELOW the sections that work, so a founder meets controls before promises -->
641
+ <div class="card" id="mVisibility">
642
+ <h2 style="font-family:var(--font-display);font-size:19px;margin-bottom:12px;">Who can see it</h2>
643
+ <div class="empty"><p>Choosing who can open this project’s pages — everyone, or only people signed in to it — isn’t wired up here yet. For now, every project’s pages are open to everyone.</p></div>
644
+ </div>
645
+
646
+ <div class="card" id="mJoinability">
647
+ <h2 style="font-family:var(--font-display);font-size:19px;margin-bottom:12px;">How people join</h2>
648
+ <div class="empty"><p>Choosing how new people join — walk right in, apply first, or invite only — isn’t wired up here yet. For now, people apply to join and you invite them.</p></div>
649
+ </div>
650
+
639
651
  <!-- live since task 1003042: the teardown control, for a project in ANY
640
652
  state that still holds something. The server used to refuse a
641
653
  'requested' or an 'error' project outright — and force-teardown reused
@@ -684,7 +696,7 @@ h1[tabindex="-1"]:focus{outline:none;}
684
696
  <section class="panel" id="panel-gate" aria-labelledby="gate-h">
685
697
  <h1 id="gate-h">Create a project</h1>
686
698
  <p class="lede">Your own <strong data-brand-product-inline>Cloud Bongos</strong> project: its own repository, its own home on the web, and a hall of AI builders who do the building. You set the direction; agents lay down the code. Sign in with GitHub so the project belongs to <em>you</em> from the very first beat.</p>
687
- <!-- The wizard's own sign-in opts into repo scope (read:user + public_repo) up
699
+ <!-- The wizard's own sign-in opts into repo scope (read:user + repo, ADR 0155) up
688
700
  front (&repos=1) — anyone here intends to create a project, which needs repo
689
701
  access, so we ask once at sign-in instead of a second "Connect" step at Home.
690
702
  Hall + CLI sign-in stay identity-only (ADR 0143 least-privilege default). -->
@@ -692,6 +704,11 @@ h1[tabindex="-1"]:focus{outline:none;}
692
704
  <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>
693
705
  Sign in with GitHub
694
706
  </a>
707
+ <!-- Informed consent BEFORE GitHub's own wording (ADR 0155): the scope the
708
+ button asks for reads as "all your repositories" on GitHub's screen, so
709
+ the reason is stated here, on the path every founder actually takes —
710
+ not only on the Home step's re-connect branch (task 1003232). -->
711
+ <p class="finePrint" id="wizScopeNote">Signing in asks GitHub for repository access. GitHub words this as access to <em>all</em> your repositories — that is the only permission it offers that can write to a private repo. We use it to create (or read) the one repo your project lives in and push its starting files. Nothing else is touched.</p>
695
712
  <!-- Trust link → the AGPL license text (task 1002705): the old private-repo URL
696
713
  404ed, and no public code artifact exists until the ADR 0099 mirror flips
697
714
  (~2026-11; npm scopes are private too) — the license is the one live,
@@ -784,7 +801,7 @@ h1[tabindex="-1"]:focus{outline:none;}
784
801
  <div id="ghBusy" class="hint" hidden>Reading your GitHub repositories…</div>
785
802
  <div id="ghAdopt" class="wizField" hidden>
786
803
  <span>Pick one of your repositories</span>
787
- <input type="text" id="ghSearch" placeholder="Search your repos…" autocomplete="off">
804
+ <input type="text" id="ghSearch" placeholder="Search your repos…" autocomplete="off" aria-label="Search your repositories">
788
805
  <div id="ghList" class="ghlist" role="listbox" aria-label="Your repositories"></div>
789
806
  <div class="hint" id="ghAdoptHint">&nbsp;</div>
790
807
  <!-- visibility stated on EVERY repo path (task 1002721) — the picker
@@ -793,7 +810,7 @@ h1[tabindex="-1"]:focus{outline:none;}
793
810
  </div>
794
811
  <div id="ghFresh" class="wizField" hidden>
795
812
  <span>New repository name</span>
796
- <input type="text" id="ghNewName" placeholder="mercury" autocomplete="off" maxlength="100">
813
+ <input type="text" id="ghNewName" placeholder="mercury" autocomplete="off" maxlength="100" aria-label="New repository name">
797
814
  <!-- visibility is stated on EVERY repo path (task 1002721) — this baked
798
815
  line is the floor; updateFreshPreview() replaces it with the same
799
816
  claim plus the resolved repo URL once a name is typed -->
@@ -830,7 +847,7 @@ h1[tabindex="-1"]:focus{outline:none;}
830
847
  choice; the owner read the step as "weird" and never found it. -->
831
848
  <section class="panel" id="panel-address" aria-labelledby="addr-h">
832
849
  <h1 id="addr-h">Its address in the sky</h1>
833
- <p class="lede">The handle names your project on the platform — and its address comes with it. Sign-in, sharing, everything just works from the moment it stands up.</p>
850
+ <p class="lede">The handle names your project on the platform — and its address comes with it. Once it stands up, a few GitHub steps (the next screen walks you through them) switch sign-in on.</p>
834
851
  <form data-next="5">
835
852
  <label class="wizField"><span>Project handle</span>
836
853
  <input type="text" id="f-slug" placeholder="mercury" autocomplete="off" maxlength="40" spellcheck="false">
@@ -882,7 +899,7 @@ h1[tabindex="-1"]:focus{outline:none;}
882
899
  <div id="liveProgress"></div>
883
900
  <h2 style="font-family:var(--font-display);font-size:19px;margin-top:22px;">Your steps — the parts only you can play</h2>
884
901
  <ol class="humanSteps" id="humanSteps"></ol>
885
- <p class="samePath">This is the same sequence the <code style="font-family:var(--font-mono)">/new-project</code> assistant and <code style="font-family:var(--font-mono)">bongos onboard</code> use — three doors, one path.</p>
902
+ <p class="samePath">These are the same steps however a project is created — nothing here is special to this page.</p>
886
903
  </section>
887
904
  </div>
888
905
 
@@ -969,6 +986,7 @@ h1[tabindex="-1"]:focus{outline:none;}
969
986
  'use strict';
970
987
 
971
988
  var API = '/api/bongos';
989
+ var BASE_TITLE = document.title;
972
990
  var POLL_MS = 5000; /* the create.html watch cadence (in-flight cards) */
973
991
  var POLL_SOFT_MS = 10000; /* transient failure back-off */
974
992
  var REFRESH_MS = 60000; /* settled-card refresh: recency + heartbeat overlay
@@ -1037,17 +1055,28 @@ h1[tabindex="-1"]:focus{outline:none;}
1037
1055
 
1038
1056
  function setView(v, focus) {
1039
1057
  if (!VIEWS[v]) v = 'map';
1058
+ var prevView = currentView;
1040
1059
  currentView = v;
1041
1060
  Object.keys(VIEWS).forEach(function (k) { $('view-' + k).hidden = (k !== v); });
1042
1061
  document.querySelectorAll('[data-nav]').forEach(function (a) {
1043
1062
  /* Home is a PAGE, not a view — it never takes the current-view pill */
1044
1063
  if (a.dataset.nav !== 'home' && a.dataset.nav === v) a.setAttribute('aria-current', 'page');
1064
+ /* the manage view is a page OF your projects — that pill stays lit there */
1065
+ else if (v === 'manage' && a.dataset.nav === 'mine') a.setAttribute('aria-current', 'page');
1045
1066
  else a.removeAttribute('aria-current');
1046
1067
  });
1047
1068
  /* the manage view's URL names its project — the deep-link contract grew
1048
1069
  an id in task 1003041, so a manage tab is shareable and reload-safe */
1049
1070
  var idPart = (v === 'manage' && manageId) ? '&id=' + encodeURIComponent(manageId) : '';
1050
- try { history.replaceState(null, '', '?view=' + v + idPart); } catch (e) {}
1071
+ /* a DELIBERATE view change is a history entry, so the browser's Back button
1072
+ moves between views instead of leaving the hub; boot and background
1073
+ settles keep rewriting the entry in place (task 1003232) */
1074
+ var deliberate = focus || (v === 'manage' && prevView && prevView !== 'manage');
1075
+ try {
1076
+ if (deliberate && location.search !== '?view=' + v + idPart) history.pushState(null, '', '?view=' + v + idPart);
1077
+ else history.replaceState(null, '', '?view=' + v + idPart);
1078
+ } catch (e) {}
1079
+ if (v !== 'manage') document.title = BASE_TITLE;
1051
1080
  /* the shell's sign-in door returns the visitor to the view they left, and
1052
1081
  carries the repo scope when that view is the wizard (ADR 0143: the
1053
1082
  create path asks once up front instead of a second consent at Home) */
@@ -1080,6 +1109,9 @@ h1[tabindex="-1"]:focus{outline:none;}
1080
1109
  document.querySelectorAll('[data-nav-to]').forEach(function (a) {
1081
1110
  a.addEventListener('click', function (ev) { ev.preventDefault(); setView(a.dataset.navTo, true); });
1082
1111
  });
1112
+ /* Back / Forward land on the view the URL names — viewFromUrl() reads the
1113
+ manage id too, so a manage entry restores its project */
1114
+ window.addEventListener('popstate', function () { setView(viewFromUrl()); });
1083
1115
 
1084
1116
  /* ── the self-typing mark. `bongos` is the CONSTANT — it is the word you
1085
1117
  type — and the prefix is what varies, which is exactly what the vanity
@@ -1568,7 +1600,7 @@ h1[tabindex="-1"]:focus{outline:none;}
1568
1600
  function goToManage(instId) {
1569
1601
  manageId = String(instId || '');
1570
1602
  manageInst = null;
1571
- setView('manage');
1603
+ setView('manage'); /* setView pushes a history entry for a list → manage move, so Back returns to the list */
1572
1604
  }
1573
1605
  /* Card-shell lookup by instance id. CSS.escape, not esc(): the data-inst
1574
1606
  attribute holds the DECODED value, so an HTML-escaped id would never match
@@ -2293,6 +2325,8 @@ h1[tabindex="-1"]:focus{outline:none;}
2293
2325
  var avail = !custom && !state.noAddress && assigned ? slugAvailState() : 'unknown';
2294
2326
  box.classList.toggle('optedOut', state.noAddress && !custom);
2295
2327
  box.classList.toggle('unavailable', avail === 'taken' || avail === 'yours' || avail === 'reserved');
2328
+ /* the format verdict must not cheer a handle the box below is refusing */
2329
+ if (avail === 'taken' || avail === 'yours' || avail === 'reserved') { var hs = $('h-slug'); hs.innerHTML = '&nbsp;'; hs.className = 'hint'; }
2296
2330
  if (custom) {
2297
2331
  big.innerHTML = '<code>' + esc(custom) + '</code> — your own domain, wired up for you.';
2298
2332
  note.textContent = 'DNS pointing happens after the standup; the card on your projects page walks you through it.';
@@ -2301,7 +2335,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2301
2335
  note.textContent = 'Sign-in on your project can’t be set up until it has one — add it any time from your projects page.';
2302
2336
  } else if (assigned && avail === 'free') {
2303
2337
  big.innerHTML = '<code>' + esc(assigned) + '</code> — yours from the moment it stands up.';
2304
- note.textContent = 'Sign-in, sharing, everything just works. You can point a domain you own at it any time.';
2338
+ note.textContent = 'Sharing works from the moment it stands up; sign-in switches on after the GitHub steps. You can point a domain you own at it any time.';
2305
2339
  } else if (assigned && avail === 'taken') {
2306
2340
  big.innerHTML = '<code>' + esc(assigned) + '</code> — already taken.';
2307
2341
  note.textContent = 'Another project on the platform holds that handle. Pick a different one.';
@@ -2389,6 +2423,9 @@ h1[tabindex="-1"]:focus{outline:none;}
2389
2423
  (a pre-auth probe 401s for strangers and, worse, could seed the
2390
2424
  draft's owner with myLogin()'s placeholder) */
2391
2425
  if (step === 3) { syncHome(); if (authKnown) ensureRepoAccess(); }
2426
+ /* a resumed draft can land PAST the Home step with the probe never run —
2427
+ Create would then file a repo it promised to make but never did */
2428
+ else if (step > 3 && step < 6 && authKnown && repoAccess === null) ensureRepoAccess();
2392
2429
  /* re-check on every entry, not just on typing: a resumed draft arrives here with
2393
2430
  a handle nobody has asked the server about, and a handle free ten minutes ago
2394
2431
  may not be now (task 1002898 N10) */
@@ -2671,7 +2708,14 @@ h1[tabindex="-1"]:focus{outline:none;}
2671
2708
  $('h-slug').className = 'hint err'; $('f-slug').classList.add('bad');
2672
2709
  return false;
2673
2710
  }
2674
- if (state.domain && !looksLikeDomain(state.domain)) return false;
2711
+ if (state.domain && !looksLikeDomain(state.domain)) {
2712
+ $('addrCustom').open = true;
2713
+ var hd0 = $('h-domain');
2714
+ hd0.textContent = 'That doesn’t look like a domain yet (e.g. mercury.example.com) — fix it, or clear the field to use the address above.';
2715
+ hd0.className = 'hint err';
2716
+ $('f-domain').focus();
2717
+ return false;
2718
+ }
2675
2719
  /* A handle the server has already said is not free would only 409 at Create —
2676
2720
  stop here instead (task 1002898 N10). ONLY a definite negative blocks:
2677
2721
  'unknown' and 'checking' must never gate the step, because the check is
@@ -2730,7 +2774,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2730
2774
  ['Address', resolvedDomain()
2731
2775
  ? '<code>' + esc(resolvedDomain()) + '</code>'
2732
2776
  : '<em style="font-weight:600;color:var(--warn)">none — sign-in stays locked until it has one</em>'],
2733
- ['Shape', 'Standalone — your own repo, pinned platform core']
2777
+ ['Shape', 'Its own server, running the platform’s current release']
2734
2778
  ];
2735
2779
  $('reviewList').innerHTML = rows.map(function (r) {
2736
2780
  return '<li><span class="k">' + r[0] + '</span><span class="v">' + r[1] + '</span></li>';
@@ -2821,7 +2865,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2821
2865
  })
2822
2866
  });
2823
2867
  }).then(function (res) {
2824
- if (res.status === 401) { window.location.assign(API + '/auth/web/start?return=/projects%3Fview%3Dnew'); throw new Error('signing in'); }
2868
+ if (res.status === 401) { window.location.assign(API + '/auth/web/start?return=/projects%3Fview%3Dnew&repos=1'); throw new Error('signing in'); }
2825
2869
  return res.json().catch(function () { return {}; }).then(function (data) {
2826
2870
  if (!res.ok) {
2827
2871
  var e = new Error((data.error && (data.error.message || data.error.code)) || ('HTTP ' + res.status));
@@ -2877,7 +2921,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2877
2921
  var live = { instStatus: null, manifest: null, manifestMsg: '', appSlug: null };
2878
2922
  var planPromises = {}; /* mode|domain → Promise<steps[]> */
2879
2923
  var lastStepsHtml = '';
2880
- var FALLBACK_HUMAN_KEYS = ['github-repo', 'oauth-app', 'first-sign-in', 'enter-hall'];
2924
+ var FALLBACK_HUMAN_KEYS = ['github-repo', 'oauth-app', 'device-flow', 'app-install', 'first-sign-in', 'enter-hall'];
2881
2925
 
2882
2926
  function fetchPlan() {
2883
2927
  var domain = resolvedDomain() || '';
@@ -2960,8 +3004,14 @@ h1[tabindex="-1"]:focus{outline:none;}
2960
3004
  (live.manifest === 'expired' ? 'That setup link expired (they last 30 minutes) — mint a fresh one.' : 'GitHub setup hit a snag' + (live.manifestMsg ? ': ' + esc(live.manifestMsg) : '') + '. Try again.') + '</p>' +
2961
3005
  '<button type="button" class="ghbtn" data-action="manifest-start">' + GH_ICON + 'Set up GitHub sign-in</button>' + manualOauthDetails() + '</li>';
2962
3006
  }
2963
- if (live.manifest) { /* pending / code_received — GitHub or the runner is mid-flight */
2964
- return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>Waiting on GitHub — approve the app in the tab that opened. Once approved, the platform places the credentials and verifies sign-in on its own; this step ticks itself.</p></li>';
3007
+ if (live.manifest === 'code_received') {
3008
+ /* GitHub's half is done — say so, rather than keep telling the founder
3009
+ to approve an app they just approved */
3010
+ return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>GitHub’s part is done. The platform is placing the credentials on your project now — this step ticks itself.</p></li>';
3011
+ }
3012
+ if (live.manifest) { /* pending — GitHub holds the manifest, nobody has approved it yet */
3013
+ return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>Waiting on GitHub — approve the app in the tab that opened. Once approved, the platform places the credentials and verifies sign-in on its own; this step ticks itself.</p>' +
3014
+ (manifestStale ? '<button type="button" class="linklike" data-action="manifest-start">Didn’t get to GitHub? Start the setup again</button>' : '') + '</li>';
2965
3015
  }
2966
3016
  return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in — one click</b><p>Approve a pre-filled GitHub App and the platform wires sign-in by itself: no keys to copy, and the secret never leaves the server side.</p>' +
2967
3017
  '<button type="button" class="ghbtn" data-action="manifest-start">' + GH_ICON + 'Set up GitHub sign-in</button>' + manualOauthDetails() + '</li>';
@@ -3003,11 +3053,20 @@ h1[tabindex="-1"]:focus{outline:none;}
3003
3053
  if (!domain) {
3004
3054
  return '<li class="slocked" data-onboard-step="first-sign-in"><b>First sign-in</b><p>Unlocks with sign-in above — you’ll open your project’s address, sign in with GitHub once, and be seated as the owner (the founding Archon).</p></li>';
3005
3055
  }
3056
+ /* the door opens only when the project ANSWERS and sign-in is wired: a
3057
+ link to an address that does not resolve yet is the worst first
3058
+ impression a founder can get of their own project (task 1003232) */
3059
+ if (live.instStatus !== 'active' || live.manifest !== 'done') {
3060
+ return '<li class="slocked" data-onboard-step="first-sign-in"><b>First sign-in</b><p>Unlocks once your project is answering and sign-in above is wired. Then you’ll open <code style="font-family:var(--font-mono)">' + esc(domain) + '</code>, sign in with GitHub once, and be seated as the owner — the founding Archon.</p></li>';
3061
+ }
3006
3062
  return '<li data-onboard-step="first-sign-in"><b>First sign-in</b><p>Open <a href="https://' + esc(domain) + '" target="_blank" rel="noopener"><code style="font-family:var(--font-mono)">' + esc(domain) + '</code></a> and sign in with GitHub once. That seats you as the owner — the founding Archon.</p></li>';
3007
3063
  }
3008
3064
 
3009
3065
  function renderStepEnterHall() {
3010
3066
  var domain = resolvedDomain();
3067
+ if (domain && (live.instStatus !== 'active' || live.manifest !== 'done')) {
3068
+ return '<li class="slocked" data-onboard-step="enter-hall"><b>Enter your builders hall</b><p>Unlocks with the first sign-in — your project’s home will be <code style="font-family:var(--font-mono)">' + esc(domain) + '/builders</code>.</p></li>';
3069
+ }
3011
3070
  return domain
3012
3071
  ? '<li data-onboard-step="enter-hall"><b>Enter your builders hall</b><p>Your project’s home — open <a href="https://' + esc(domain) + '/builders" target="_blank" rel="noopener"><code style="font-family:var(--font-mono)">' + esc(domain) + '/builders</code></a> and start building.</p></li>'
3013
3072
  : '<li data-onboard-step="enter-hall"><b>Enter your builders hall</b><p>No address yet — run <code style="font-family:var(--font-mono)">bongos dev</code> in your repo checkout to start the local hall on your machine.</p></li>';
@@ -3095,6 +3154,20 @@ h1[tabindex="-1"]:focus{outline:none;}
3095
3154
  var manifestPollTimer = null;
3096
3155
  var manifestState = null; /* the one-time state token — kept so a parked poll can resume */
3097
3156
  var manifestPending = false;
3157
+ var manifestStartedAt = 0; /* when the founder was sent to GitHub */
3158
+ var manifestStale = false; /* ~90s with no approval → offer to start again */
3159
+ /* the manifest leg survives a reload: token + last status ride the CREATED
3160
+ record, so enter() re-seeds the step instead of re-offering "Set up
3161
+ GitHub sign-in" for an app the founder already approved (task 1003232) */
3162
+ function rememberManifest(stateToken, status, appSlug) {
3163
+ try {
3164
+ var rec = createdRecord() || {};
3165
+ if (stateToken !== undefined) rec.manifest_state = stateToken;
3166
+ if (status !== undefined) rec.manifest = status;
3167
+ if (appSlug !== undefined) rec.app_slug = appSlug;
3168
+ sessionStorage.setItem(CREATED_KEY, JSON.stringify(rec));
3169
+ } catch (e) {}
3170
+ }
3098
3171
  /* a cleared setTimeout id can't cancel a chain that's mid-fetch — each chain
3099
3172
  carries the generation it was born under, and stopping bumps it so an
3100
3173
  orphaned chain goes quiet instead of re-arming itself forever */
@@ -3109,7 +3182,7 @@ h1[tabindex="-1"]:focus{outline:none;}
3109
3182
  method: 'POST', credentials: 'same-origin',
3110
3183
  headers: { 'Content-Type': 'application/json' }, body: '{}'
3111
3184
  }).then(function (res) {
3112
- if (res.status === 401) { window.location.assign(API + '/auth/web/start?return=/projects%3Fview%3Dnew'); throw new Error('signing in'); }
3185
+ if (res.status === 401) { window.location.assign(API + '/auth/web/start?return=/projects%3Fview%3Dnew&repos=1'); throw new Error('signing in'); }
3113
3186
  return res.json().catch(function () { return {}; }).then(function (d) {
3114
3187
  if (!res.ok) {
3115
3188
  var e = new Error((d.error && (d.error.message || d.error.code)) || ('HTTP ' + res.status));
@@ -3125,6 +3198,8 @@ h1[tabindex="-1"]:focus{outline:none;}
3125
3198
  form.appendChild(inp); document.body.appendChild(form);
3126
3199
  form.submit(); form.remove();
3127
3200
  live.manifest = 'pending'; live.manifestMsg = '';
3201
+ manifestStartedAt = Date.now(); manifestStale = false;
3202
+ rememberManifest(d.state, 'pending');
3128
3203
  renderSteps();
3129
3204
  pollManifest(d.state);
3130
3205
  }).catch(function (err) {
@@ -3146,9 +3221,10 @@ h1[tabindex="-1"]:focus{outline:none;}
3146
3221
  .then(function (res) { if (!res.ok) throw new Error('soft'); return res.json(); })
3147
3222
  .then(function (d) {
3148
3223
  if (myGen !== manifestGen) return;
3149
- if (d.app_slug && d.app_slug !== live.appSlug) { live.appSlug = d.app_slug; renderSteps(); }
3224
+ if (d.app_slug && d.app_slug !== live.appSlug) { live.appSlug = d.app_slug; rememberManifest(undefined, undefined, d.app_slug); renderSteps(); }
3150
3225
  var next = d.ready ? 'done' : (d.status || 'pending');
3151
- if (next !== live.manifest) { live.manifest = next; renderSteps(); }
3226
+ if (next !== live.manifest) { live.manifest = next; rememberManifest(undefined, next); renderSteps(); }
3227
+ else if (next === 'pending' && !manifestStale && manifestStartedAt && (Date.now() - manifestStartedAt) > 90000) { manifestStale = true; renderSteps(); }
3152
3228
  if (!d.terminal) manifestPollTimer = setTimeout(tick, 4000);
3153
3229
  else manifestPollTimer = null; /* a stale fired id would read as "still polling" and resume a finished poll */
3154
3230
  })
@@ -3206,7 +3282,7 @@ h1[tabindex="-1"]:focus{outline:none;}
3206
3282
  }
3207
3283
  }
3208
3284
  if (s === 'requested') {
3209
- el.innerHTML = progressMarkup(1, 1, 'Waiting for a runner to pick it up. This updates the moment it starts.', '', revive ? 'revive' : 'standup');
3285
+ el.innerHTML = progressMarkup(0, 0, 'Filed — waiting for the platform to pick it up. This updates the moment it starts.', '', revive ? 'revive' : 'standup');
3210
3286
  } else if (s === 'provisioning') {
3211
3287
  el.innerHTML = progressMarkup(1, 1,
3212
3288
  revive ? 'Bringing it back on the newest platform version — nothing needed from you.'
@@ -3225,8 +3301,8 @@ h1[tabindex="-1"]:focus{outline:none;}
3225
3301
  /* domainless instances are never probed — there is no address to
3226
3302
  answer at, so a verified answer can never arrive; say what IS true
3227
3303
  instead of promising the hall (sign-in stays locked without one) */
3228
- el.innerHTML = progressMarkup(-1, 3,
3229
- '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.', 'isLive', flow);
3304
+ el.innerHTML = progressMarkup(-1, 2,
3305
+ '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);
3230
3306
  return true;
3231
3307
  }
3232
3308
  if (inst.last_verified_up === false) {
@@ -3257,7 +3333,7 @@ h1[tabindex="-1"]:focus{outline:none;}
3257
3333
  el.innerHTML = 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');
3258
3334
  return true;
3259
3335
  } else {
3260
- el.innerHTML = progressMarkup(0, 1, 'Filed. Watching for the standup to start…');
3336
+ el.innerHTML = progressMarkup(0, 0, 'Filed. Watching for the standup to start…');
3261
3337
  }
3262
3338
  return false;
3263
3339
  }
@@ -3268,8 +3344,9 @@ h1[tabindex="-1"]:focus{outline:none;}
3268
3344
  watchPending = false;
3269
3345
  var myGen = watchGen;
3270
3346
  var ticks = 0;
3347
+ var watchFlow = 'standup'; /* the vocabulary the strip is speaking (F22) */
3271
3348
  var el = $('liveProgress');
3272
- el.innerHTML = progressMarkup(0, 1, 'Filed. Watching for the standup to start…');
3349
+ el.innerHTML = progressMarkup(0, 0, 'Filed. Watching for the standup to start…');
3273
3350
  function tick() {
3274
3351
  if (myGen !== watchGen) return;
3275
3352
  if (!wizPollActive()) { watchPending = true; stopPolling(); return; }
@@ -3279,14 +3356,18 @@ h1[tabindex="-1"]:focus{outline:none;}
3279
3356
  the long watch to the mine view, the owner's durable surface (task
3280
3357
  1002693). The old "stop quietly" here was a dead-end lie: the hall
3281
3358
  card it assumed was removed in task 1002675. */
3282
- $('liveProgress').innerHTML = progressMarkup(1, 1,
3283
- 'Still building — this tab will stop watching now. Follow it any time on <a href="?view=mine">your projects page</a>.');
3359
+ /* a SNAGGED standup keeps its honest error — "still building" would be
3360
+ a lie exactly when the founder most needs the truth */
3361
+ if (live.instStatus !== 'error') {
3362
+ $('liveProgress').innerHTML = progressMarkup(1, 1,
3363
+ 'Still working — this tab will stop watching now. Follow it any time on <a href="?view=mine">your projects page</a>.', '', watchFlow);
3364
+ }
3284
3365
  stopPolling();
3285
3366
  return;
3286
3367
  }
3287
3368
  fetch(API + '/provisioning/instances/' + encodeURIComponent(id), { credentials: 'same-origin' })
3288
3369
  .then(function (res) {
3289
- if (res.status === 401) { throw new Error('gone:Your session expired — <a href="' + API + '/auth/web/start?return=/projects%3Fview%3Dnew">sign in again</a> to keep watching.'); }
3370
+ if (res.status === 401) { throw new Error('gone:Your session expired — <a href="' + API + '/auth/web/start?return=/projects%3Fview%3Dnew&amp;repos=1">sign in again</a> to keep watching.'); }
3290
3371
  if (res.status === 404) { throw new Error('gone:Can’t see this instance any more — it may have been torn down.'); }
3291
3372
  if (!res.ok) throw new Error('soft');
3292
3373
  return res.json();
@@ -3295,6 +3376,7 @@ h1[tabindex="-1"]:focus{outline:none;}
3295
3376
  if (myGen !== watchGen) return;
3296
3377
  var inst = data.instance;
3297
3378
  if (inst) inst.open_intent = data.open_intent || null; /* F22: the strip reads intents */
3379
+ if (inst) watchFlow = inst.torn_down_at ? 'revive' : 'standup';
3298
3380
  /* the checklist reflects live state (task 1002694): a status transition —
3299
3381
  e.g. the scaffold landing — re-renders the steps (change-only, so the
3300
3382
  5s tick never clobbers an open dropdown or mid-flight button) */
@@ -3307,7 +3389,7 @@ h1[tabindex="-1"]:focus{outline:none;}
3307
3389
  if (myGen !== watchGen) return;
3308
3390
  var m = String(err && err.message || '');
3309
3391
  if (m.indexOf('gone:') === 0) {
3310
- $('liveProgress').innerHTML = progressMarkup(1, 1, m.slice(5), 'isErr');
3392
+ $('liveProgress').innerHTML = progressMarkup(1, 1, m.slice(5), 'isErr', watchFlow);
3311
3393
  stopPolling();
3312
3394
  return;
3313
3395
  }
@@ -3323,10 +3405,13 @@ h1[tabindex="-1"]:focus{outline:none;}
3323
3405
  var again = document.createElement('button');
3324
3406
  again.type = 'button'; again.className = 'again'; again.textContent = 'Start another project';
3325
3407
  again.addEventListener('click', function () {
3408
+ /* a standup still being watched is not abandoned by accident */
3409
+ if (instanceId && pollTimer && !confirm((state.name || state.slug) + ' keeps building — you’ll find it on your projects page. Start a new one?')) return;
3326
3410
  stopPolling();
3327
3411
  stopManifestPoll();
3328
3412
  instanceId = null;
3329
3413
  watchPending = false; manifestPending = false; manifestState = null;
3414
+ manifestStartedAt = 0; manifestStale = false;
3330
3415
  live = { instStatus: null, manifest: null, manifestMsg: '', appSlug: null };
3331
3416
  lastStepsHtml = '';
3332
3417
  try { sessionStorage.removeItem(CREATED_KEY); sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
@@ -3363,6 +3448,11 @@ h1[tabindex="-1"]:focus{outline:none;}
3363
3448
  /* restore the opt-out too — without it resolvedDomain() re-derives the
3364
3449
  assigned address and the done panel promises one that doesn't exist */
3365
3450
  state.noAddress = !!created.noaddr;
3451
+ /* the manifest leg's last known truth, kept across a reload */
3452
+ if (created.manifest) { live.manifest = created.manifest; live.appSlug = created.app_slug || null; }
3453
+ if (created.manifest_state && created.manifest !== 'done' && created.manifest !== 'error' && created.manifest !== 'expired') {
3454
+ manifestState = created.manifest_state; manifestPending = true;
3455
+ }
3366
3456
  renderDone();
3367
3457
  show(6, !!focus);
3368
3458
  startProgress(created.id);
@@ -3511,13 +3601,21 @@ h1[tabindex="-1"]:focus{outline:none;}
3511
3601
  function jsonOrEmpty(res) { return res.json().catch(function () { return {}; }); }
3512
3602
 
3513
3603
  function signInAgain() {
3514
- return 'Your session expired — <a href="' + API + '/auth/web/start?return=/projects">sign in again</a>.';
3604
+ /* from the wizard, the re-sign-in keeps the repo scope it already granted */
3605
+ var back = currentView === 'new' ? '/projects%3Fview%3Dnew&amp;repos=1' : '/projects';
3606
+ return 'Your session expired — <a href="' + API + '/auth/web/start?return=' + back + '">sign in again</a>.';
3515
3607
  }
3516
3608
 
3517
3609
  /* ── status vocabulary (direction §4): the chip only asserts what the
3518
3610
  platform verified, and says when. `last_verified_*` is the heartbeat
3519
3611
  overlay — absent (pre-migration or never probed) reads as unverified. ── */
3520
3612
 
3613
+ /* ONE freshness rule (direction §2 principle 1 / §3): "live" is claimed only
3614
+ from an UP probe within FRESH_MS. Chip, orb and address row all read this,
3615
+ so the page can never say "Answering at …" beside a chip that says "live?". */
3616
+ function verifiedAt(inst) { return inst.last_verified_at ? new Date(inst.last_verified_at).getTime() : 0; }
3617
+ function freshUp(inst) { var at = verifiedAt(inst); return !!(inst.last_verified_up && at && (Date.now() - at) <= FRESH_MS); }
3618
+
3521
3619
  function chipMarkup(cls, label, rec) {
3522
3620
  return '<span class="chip ' + cls + '"><span class="cdot"></span>' + esc(label) +
3523
3621
  (rec ? ' <span class="rec">· ' + esc(rec) + '</span>' : '') + '</span>';
@@ -3525,9 +3623,9 @@ h1[tabindex="-1"]:focus{outline:none;}
3525
3623
  function chipFor(inst) {
3526
3624
  var s = inst.status;
3527
3625
  if (s === 'active') {
3528
- var at = inst.last_verified_at ? new Date(inst.last_verified_at).getTime() : 0;
3626
+ var at = verifiedAt(inst);
3529
3627
  if (inst.last_verified_up === false) return chipMarkup('down', 'unreachable', at ? 'since ' + clockOrDate(at) : '');
3530
- if (inst.last_verified_up && at && (Date.now() - at) <= FRESH_MS) return chipMarkup('live', 'live', 'checked ' + relTime(at));
3628
+ if (freshUp(inst)) return chipMarkup('live', 'live', 'checked ' + relTime(at));
3531
3629
  return chipMarkup('maybe', 'live?', at ? 'last seen ' + relTime(at) : 'not checked yet');
3532
3630
  }
3533
3631
  if (s === 'requested') return chipMarkup('busy', 'queued');
@@ -3585,7 +3683,7 @@ h1[tabindex="-1"]:focus{outline:none;}
3585
3683
  var cls = 'orb';
3586
3684
  if (inst.status === 'torn_down' || inst.status === 'tearing_down') cls += ' ghost';
3587
3685
  else if (inst.status === 'error' || (inst.status === 'active' && inst.last_verified_up === false)) cls += ' ashen';
3588
- else if (inst.status === 'active' && !(inst.last_verified_up && inst.last_verified_at && (Date.now() - new Date(inst.last_verified_at).getTime()) <= FRESH_MS)) cls += ' dim';
3686
+ else if (inst.status === 'active' && !freshUp(inst)) cls += ' dim';
3589
3687
  var style = '--acc:' + ORB_ACCENTS[k] + ';--glow:' + ORB_GLOWS[k];
3590
3688
  if (inst.status === 'active' && inst.domain) {
3591
3689
  return '<a class="' + cls + '" style="' + style + '" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener" title="Open ' + esc(inst.slug) + '" aria-label="Open ' + esc(inst.slug) + '"></a>';
@@ -3616,8 +3714,10 @@ h1[tabindex="-1"]:focus{outline:none;}
3616
3714
  var s = inst.status;
3617
3715
  var revive = !!inst.torn_down_at; /* stood down before this standup → it's a revive */
3618
3716
  if (s === 'requested') {
3619
- return stripMarkup(revive ? 'revive' : 'standup', 1, 1,
3620
- 'Waiting for a runner to pick it up. This card updates the moment it starts.');
3717
+ /* "Request filed" is the CURRENT stage — the standing-up dot must not
3718
+ light before the platform has actually picked the request up */
3719
+ return stripMarkup(revive ? 'revive' : 'standup', 0, 0,
3720
+ 'Filed — waiting for the platform to pick it up. This card updates the moment it starts.');
3621
3721
  }
3622
3722
  if (s === 'provisioning') {
3623
3723
  return stripMarkup(revive ? 'revive' : 'standup', 1, 1,
@@ -3684,26 +3784,61 @@ h1[tabindex="-1"]:focus{outline:none;}
3684
3784
  }
3685
3785
 
3686
3786
  function addressRow(inst) {
3787
+ /* state FIRST, then domain: a torn-down or snagged project still holds its
3788
+ domain (ADR 0181 keeps port + address), and the standup promise below
3789
+ was reaching it — "answers once the instance is live" two lines above
3790
+ "nothing more will happen" (task 1003232) */
3791
+ if (inst.status === 'torn_down' || inst.status === 'tearing_down') {
3792
+ if (!inst.domain) return '';
3793
+ return 'Its address, <code>' + esc(inst.domain) + '</code>, stays reserved for this project — it shows an error page until you bring it back.';
3794
+ }
3687
3795
  if (inst.domain) {
3688
3796
  if (inst.status === 'active') {
3689
- /* "answering" is a VERIFIED claim (direction §3 / F16): only the
3690
- heartbeat earns it — never the runner finishing its script */
3797
+ /* "answering" is a VERIFIED claim (direction §3 / F16): only a FRESH up
3798
+ probe earns it — never the runner finishing its script, and never a
3799
+ probe the chip has already aged into "live?" */
3691
3800
  var link = '<a href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">' + esc(inst.domain) + '</a>';
3692
- if (inst.last_verified_up) return 'Answering at ' + link;
3801
+ if (freshUp(inst)) return 'Answering at ' + link;
3693
3802
  /* the runner had no DNS hook (F16): the CAUSE beats the bare symptom —
3694
3803
  a successful A record flips last_verified_up and retires the note */
3695
3804
  if (inst.dns_note) return 'Its address is ' + link + '. ' + esc(inst.dns_note);
3696
- if (inst.last_verified_up === false) return 'Not answering at ' + link + ' right now.';
3805
+ if (inst.last_verified_up === false) return 'Not answering at ' + link + ' right now. We keep checking every few minutes and update this on its own if it comes back.';
3806
+ if (inst.last_verified_up) return 'Its address is ' + link + ' — last answered ' + esc(relTime(verifiedAt(inst))) + '.';
3697
3807
  return 'Its address is ' + link + ' — confirming it answers.';
3698
3808
  }
3809
+ if (inst.status === 'error') return 'Address <code>' + esc(inst.domain) + '</code> — held for this project, but nothing is answering there yet.';
3699
3810
  return 'Address: <code>' + esc(inst.domain) + '</code> — it answers there once the instance is live.';
3700
3811
  }
3701
- /* a torn-down instance has no address to speak of — the card's own line says so */
3702
- if (inst.status === 'torn_down' || inst.status === 'tearing_down') return '';
3703
3812
  /* the HONEST no-address state: sign-in on the instance needs a real domain */
3704
3813
  return '<span class="none">No address yet</span> — sign-in on this project can’t be set up until it has one.';
3705
3814
  }
3706
3815
 
3816
+ /* ── the bring-your-own-domain fold (owner decision 2, 2026-08-24). A live
3817
+ card's loudest control used to be a full-width domain field + filled
3818
+ button — for a domain the founder can't buy here — while the success
3819
+ moment had only "Open site →". The field now sits behind the disclosure
3820
+ the wizard's address step uses (direction §6): closed, after the card's
3821
+ doors, and only where something is or will be answering — live and
3822
+ queued. A snagged or torn-down card has nothing to point a domain at, so
3823
+ it carries no fold (canAttachDomain stays the server's mirror; this is
3824
+ the card's own call). The re-kick door (F19) keeps its shape — open and
3825
+ pre-filled, one button: Try the address again. ── */
3826
+ function attachFold(inst, iid) {
3827
+ if (!canAttachDomain(inst)) return '';
3828
+ if (inst.status !== 'active' && inst.status !== 'requested') return '';
3829
+ /* the strip already says "Wiring the address" — a second attach would only
3830
+ be refused (intent_conflict), so the fold waits for the run to land */
3831
+ if (inst.open_intent && inst.open_intent.action === 'domain-attach') return '';
3832
+ var rekick = inst.status === 'active' && inst.domain && inst.error_note;
3833
+ return '<details class="attachFold"' + (rekick ? ' open' : '') + '>' +
3834
+ '<summary>' + (rekick ? 'Try the address again' : inst.domain ? 'Use a domain I own' : 'Give it an address') + '</summary>' +
3835
+ '<div class="attach">' +
3836
+ '<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) + '"' +
3837
+ (rekick ? ' value="' + esc(inst.domain) + '"' : '') + '>' +
3838
+ '<button type="button" class="btn small" data-attach="' + iid + '">' +
3839
+ (rekick ? 'Try the address again' : 'Use this domain') + '</button></div></details>';
3840
+ }
3841
+
3707
3842
  function renderCard(inst) {
3708
3843
  /* iid is for MARKUP interpolation only (esc() = HTML escape); the DOM
3709
3844
  lookup goes through liFor(), which CSS-escapes — the two escapes are
@@ -3711,26 +3846,21 @@ h1[tabindex="-1"]:focus{outline:none;}
3711
3846
  var iid = esc(String(inst.id));
3712
3847
  var li = liFor(inst.id);
3713
3848
  if (!li) return;
3849
+ if (cardAsking !== null && cardAsking === String(inst.id)) return; /* keep THIS card's open confirmation */
3714
3850
  var gone = inst.status === 'torn_down';
3715
3851
  li.className = 'proj' + (gone ? ' gone' : '');
3716
3852
  var meta = [];
3717
3853
  if (inst.target_ref) meta.push('repo <code>' + esc(inst.target_ref) + '</code>');
3854
+ /* each item is ONE flex item (span-wrapped below), so the separator can
3855
+ never strand at the end of a wrapped line on a phone */
3718
3856
  if (inst.hosting_shape) meta.push(esc(SHAPE_LABEL[inst.hosting_shape] || inst.hosting_shape));
3719
3857
  if (inst.onboard_mode === 'adopt') meta.push('adopted repo');
3720
3858
  if (inst.status === 'active' && inst.torn_down_at) meta.push('back live since ' + esc(shortDate(inst.provisioned_at || inst.torn_down_at)));
3721
3859
  var body =
3722
3860
  '<div class="projHead"><span class="projName">' + esc(inst.slug) + '</span>' + chipFor(inst) + '</div>' +
3723
- (meta.length ? '<div class="projMeta">' + meta.join('<span aria-hidden="true">·</span>') + '</div>' : '');
3861
+ (meta.length ? '<div class="projMeta">' + meta.map(function (m) { return '<span>' + m + '</span>'; }).join('') + '</div>' : '');
3724
3862
  var addr = addressRow(inst);
3725
3863
  if (addr) body += '<div class="addr">' + addr + '</div>';
3726
- if (canAttachDomain(inst)) {
3727
- var rekick = inst.status === 'active' && inst.domain && inst.error_note;
3728
- body += '<div class="attach">' +
3729
- '<input type="text" id="dom-' + iid + '" placeholder="myproject.example.com" autocomplete="off" spellcheck="false" maxlength="253"' +
3730
- (rekick ? ' value="' + esc(inst.domain) + '"' : '') + '>' +
3731
- '<button type="button" class="btn small" data-attach="' + iid + '">' +
3732
- (rekick ? 'Try the address again' : inst.domain ? 'Use your own domain' : 'Give it an address') + '</button></div>';
3733
- }
3734
3864
  if ((inst.status === 'error' || inst.error_note) && !inst.open_intent) {
3735
3865
  /* once a retry is queued the strip tells the story — the stale error
3736
3866
  paragraph would just contradict it (owner walk, 2026-08-12). Also
@@ -3752,11 +3882,29 @@ h1[tabindex="-1"]:focus{outline:none;}
3752
3882
  /* every owned card opens its manage view (task 1003041) — a real link, so
3753
3883
  middle/ctrl-click works; the delegated handler upgrades a plain click */
3754
3884
  var acts = ['<a class="btn small ghost" data-manage="' + iid + '" href="?view=manage&id=' + iid + '">Manage</a>'];
3885
+ /* the success moment needs a door: a live project opens from its own card
3886
+ (the orb and the address link were the only ways in — task 1003232) */
3887
+ 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>');
3755
3888
  if (canRetry(inst)) acts.push('<button type="button" class="btn small" data-retry="' + iid + '">Try again</button>');
3756
3889
  if (canRevive(inst)) acts.push('<button type="button" class="btn small" data-revive="' + iid + '">Bring it back</button>');
3757
3890
  if (canTeardown(inst)) acts.push('<button type="button" class="btn small danger" data-teardown="' + iid + '">Tear it down</button>');
3758
3891
  if (acts.length) body += '<div class="actions" id="act-' + iid + '">' + acts.join('') + '</div>';
3892
+ body += attachFold(inst, iid);
3893
+ /* sig() buckets recency per minute, so a live card re-renders on the
3894
+ heartbeat — that must not snap an open fold shut, eat a half-typed
3895
+ domain or drop the caret. defaultValue tells a founder's typing from
3896
+ the re-kick pre-fill. */
3897
+ var oldFold = li.querySelector('details.attachFold');
3898
+ var oldInput = oldFold ? oldFold.querySelector('input') : null;
3899
+ var keepOpen = !!(oldFold && oldFold.open);
3900
+ var typed = oldInput && oldInput.value !== oldInput.defaultValue ? oldInput.value : '';
3901
+ var hadFocus = !!oldInput && document.activeElement === oldInput;
3759
3902
  li.innerHTML = orbFor(inst) + '<div class="projBody">' + body + '</div>';
3903
+ var fold = li.querySelector('details.attachFold');
3904
+ var input = fold ? fold.querySelector('input') : null;
3905
+ if (fold && keepOpen) fold.open = true;
3906
+ if (input && typed) input.value = typed;
3907
+ if (input && hadFocus) input.focus();
3760
3908
  }
3761
3909
 
3762
3910
  function render(instances) {
@@ -3943,7 +4091,7 @@ h1[tabindex="-1"]:focus{outline:none;}
3943
4091
  }
3944
4092
  if (inst.hosting_shape) meta.push(esc(SHAPE_LABEL[inst.hosting_shape] || inst.hosting_shape));
3945
4093
  if (inst.onboard_mode === 'adopt') meta.push('adopted repo');
3946
- $('mMeta').innerHTML = meta.join('<span aria-hidden="true">·</span>');
4094
+ $('mMeta').innerHTML = meta.map(function (m) { return '<span>' + m + '</span>'; }).join('');
3947
4095
  $('mMeta').hidden = !meta.length;
3948
4096
  var addr = addressRow(inst);
3949
4097
  $('mAddr').innerHTML = addr;
@@ -3955,9 +4103,75 @@ h1[tabindex="-1"]:focus{outline:none;}
3955
4103
  it: the delete section reads only status + open_intent, both already in
3956
4104
  hand, and a queued teardown must show up without a reload */
3957
4105
  renderDelete(inst);
4106
+ /* the page names its project — in the heading and in the tab */
4107
+ $('mTitle').textContent = inst.slug || 'Manage';
4108
+ document.title = (inst.slug ? inst.slug + ' — ' : '') + BASE_TITLE;
4109
+ /* the card's own story, on the project's own page (task 1003232): the
4110
+ per-flow strip, the honest snag, and the doors the list card has */
4111
+ $('mStrip').innerHTML = stripFor(inst);
4112
+ var snag = (inst.status === 'error' || inst.error_note) && !inst.open_intent;
4113
+ $('mErr').innerHTML = snag ? errMarkup(inst) : '';
4114
+ $('mErr').hidden = !snag;
4115
+ var acts = [];
4116
+ if (inst.status === 'active' && inst.domain) acts.push('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">Open site →</a>');
4117
+ if (canRetry(inst)) acts.push('<button type="button" class="btn small" data-m-retry>Try again</button>');
4118
+ if (canRevive(inst)) acts.push('<button type="button" class="btn small" data-m-revive>Bring it back</button>');
4119
+ $('mActs').innerHTML = acts.join('');
4120
+ $('mActs').hidden = !acts.length;
4121
+ }
4122
+
4123
+ /* Try again / Bring it back from the project's own page — the same POST the
4124
+ list card sends (the server's PROVISION_ENQUEUE_FROM gate is the authority) */
4125
+ function mReProvision(verb) {
4126
+ var inst = manageInst;
4127
+ if (!inst) return;
4128
+ var box = $('mMsg');
4129
+ box.className = 'projMsg'; box.textContent = verb === 'revive' ? 'Asking the platform to bring it back…' : 'Asking the platform to try again…';
4130
+ $('mActs').querySelectorAll('button').forEach(function (b) { b.disabled = true; });
4131
+ var body = { slug: inst.slug, hosting_shape: inst.hosting_shape || 'standalone' };
4132
+ if (inst.onboard_mode) body.onboard_mode = inst.onboard_mode;
4133
+ if (inst.target_ref) body.target_ref = inst.target_ref;
4134
+ if (inst.domain) body.domain = inst.domain;
4135
+ fetch(API + '/provisioning/instances', {
4136
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
4137
+ body: JSON.stringify(body)
4138
+ }).then(function (res) {
4139
+ if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
4140
+ return jsonOrEmpty(res).then(function (data) {
4141
+ if (res.ok) {
4142
+ box.className = 'projMsg ok';
4143
+ box.textContent = data.message || (verb === 'revive' ? 'Revive queued — bringing it back.' : 'Queued again — the platform picks it up from here.');
4144
+ return;
4145
+ }
4146
+ var e = apiError(data, res.status);
4147
+ box.className = 'projMsg err';
4148
+ box.textContent = e.message || 'The request could not be queued — try again in a moment.';
4149
+ });
4150
+ }).catch(function () {
4151
+ box.className = 'projMsg err';
4152
+ box.textContent = 'Couldn’t reach the platform — check your connection and try again.';
4153
+ }).then(function () { loadManage(true, true); });
4154
+ }
4155
+ $('mActs').addEventListener('click', function (ev) {
4156
+ var t = ev.target.closest && ev.target.closest('button');
4157
+ if (!t) return;
4158
+ if (t.hasAttribute('data-m-retry')) return mReProvision('retry');
4159
+ if (t.hasAttribute('data-m-revive')) return mReProvision('revive');
4160
+ });
4161
+
4162
+ /* an IN-FLIGHT project is watched here at the card's cadence — its own copy
4163
+ says "this page updates when it starts", so it must. A settled project
4164
+ keeps the C8 rule: entry + tab return, plus the request-free minute tick. */
4165
+ function manageWatchTick() {
4166
+ if (document.hidden || currentView !== 'manage' || !manageInst || !shouldWatch(manageInst)) return;
4167
+ loadManage(true, true);
3958
4168
  }
4169
+ setInterval(manageWatchTick, 5000);
3959
4170
 
3960
- function loadManage(quiet) {
4171
+ /* keepRepo: the in-flight watch below re-reads the instance every few
4172
+ seconds, and must not re-ask GitHub each time — that read stays on entry
4173
+ and tab return */
4174
+ function loadManage(quiet, keepRepo) {
3961
4175
  if (!manageId) return;
3962
4176
  $('mAlert').classList.remove('on');
3963
4177
  $('mSignin').href = manageSignin();
@@ -3990,7 +4204,7 @@ h1[tabindex="-1"]:focus{outline:none;}
3990
4204
  renderManage(inst);
3991
4205
  /* the SECOND, GitHub-backed read — fired only here (entry + tab
3992
4206
  return), never from renderManage, which the fetch-free tick calls */
3993
- loadRepoVisibility(inst);
4207
+ if (!keepRepo) loadRepoVisibility(inst);
3994
4208
  })
3995
4209
  .catch(function (err) {
3996
4210
  var m = String(err && err.message);
@@ -4027,11 +4241,16 @@ h1[tabindex="-1"]:focus{outline:none;}
4027
4241
 
4028
4242
  function renderRepoVis(repo) {
4029
4243
  if (repo.private) {
4244
+ /* a LIVE project with a private repo is not broken — the deploy key reads
4245
+ it (ADR 0176). The warning stays for a project that actually snagged. */
4246
+ var snagged = !!(manageInst && (manageInst.status === 'error' || manageInst.error_note));
4030
4247
  repoVisMsg(
4031
4248
  '<div class="addr">This repository is <b>private</b> — only accounts it’s shared with can read its code.</div>' +
4032
- '<div class="projMsg err">A private repository deploys through the project’s own read-only deploy key, wired at standup. A project set up before that fix can’t read it yet — retry the standup from your projects page, or make the repository public.</div>' +
4249
+ (snagged
4250
+ ? '<div class="projMsg err">A private repository deploys through the project’s own read-only deploy key, wired at standup. If the standup couldn’t read it, retry it from the card above — or make the repository public.</div>'
4251
+ : '<p class="finePrint">The platform reads it through this project’s own read-only deploy key, set up at standup. Nothing to do here.</p>') +
4033
4252
  (repo.admin
4034
- ? '<div class="actions"><button type="button" class="btn small" data-repo-public>Make it public</button></div>'
4253
+ ? '<div class="actions"><button type="button" class="btn small ghost" data-repo-public>Make it public</button></div>'
4035
4254
  : '<p class="finePrint">Your GitHub account can’t change this repository’s settings.</p>'));
4036
4255
  } else {
4037
4256
  repoVisMsg(
@@ -4234,16 +4453,22 @@ h1[tabindex="-1"]:focus{outline:none;}
4234
4453
  /* ── teardown: ask, confirm (the truth, itemized — direction §5.4), then
4235
4454
  SAY exactly what the server said ── */
4236
4455
 
4456
+ /* the id whose confirmation is open — renderCard skips that card, so the
4457
+ minute re-render (sig() buckets recency) cannot wipe the prompt mid-read
4458
+ (the manage page's mDelAsking rule, task 1003232) */
4459
+ var cardAsking = null;
4237
4460
  function askTeardown(id) {
4238
4461
  var act = $('act-' + id);
4239
4462
  if (!act) return;
4463
+ cardAsking = String(id);
4240
4464
  act.innerHTML = '<div class="confirmRow">' +
4241
4465
  '<span class="plain">Tear it down? ' + teardownTruth(byId[id]) + '</span>' +
4242
4466
  '<button type="button" class="btn small danger" data-teardown-yes="' + esc(id) + '">Yes, tear it down</button>' +
4243
4467
  '<button type="button" class="linklike" data-teardown-no="' + esc(id) + '">Keep it running</button></div>';
4244
4468
  }
4245
- function unAskTeardown(id) { var inst = byId[id]; if (inst) renderCard(inst); }
4469
+ function unAskTeardown(id) { cardAsking = null; var inst = byId[id]; if (inst) renderCard(inst); }
4246
4470
  function doTeardown(id) {
4471
+ cardAsking = null;
4247
4472
  var act = $('act-' + id);
4248
4473
  if (act) act.innerHTML = '<span class="projMsg">Asking…</span>';
4249
4474
  fetch(API + '/provisioning/instances/' + encodeURIComponent(id) + '/teardown', {
@@ -4299,7 +4524,7 @@ h1[tabindex="-1"]:focus{outline:none;}
4299
4524
  if (res.status === 401) { msg(id, signInAgain(), 'err'); return null; }
4300
4525
  return jsonOrEmpty(res).then(function (data) {
4301
4526
  if (res.ok) {
4302
- msg(id, esc(data.message || (verb === 'revive' ? 'Revive queued — bringing it back.' : 'Queued again — a runner picks it up from here.')), 'ok');
4527
+ msg(id, esc(data.message || (verb === 'revive' ? 'Revive queued — bringing it back.' : 'Queued again — the platform picks it up from here.')), 'ok');
4303
4528
  return;
4304
4529
  }
4305
4530
  var e = apiError(data, res.status);