@bongos/core 1.19.83 → 1.19.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,184 +10,64 @@
10
10
  correctly when viewed standalone. -->
11
11
  <link rel="preconnect" href="https://fonts.googleapis.com">
12
12
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700&family=Quicksand:wght@400;500;600;700&display=swap">
13
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700&family=Quicksand:wght@400;500;600;700&family=Fira+Code:wght@400;500&display=swap">
14
+ <!-- The R07 redesign (task 1002696): shared token + component layer — the
15
+ space sky both modes ride on, light/dark governing the cards. The server
16
+ re-injects the branding :root AFTER stylesheets, so the pack stays
17
+ authoritative. Design contract: docs/design/projects-surfaces-redesign-direction.md -->
18
+ <link rel="stylesheet" href="/assets/cosmos.css">
19
+ <script>
20
+ /* apply the saved color mode BEFORE first paint so there's no flash;
21
+ no saved choice = follow the OS (prefers-color-scheme) */
22
+ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')document.documentElement.setAttribute('data-mode',_m);}catch(e){}
23
+ </script>
14
24
  <style>
15
- /* ── Cloud Bongos vanilla look (ADR 0064) — same baked defaults as index.html /
16
- create.html; the server re-injects the branding vars after this stylesheet so
17
- the pack stays authoritative. /projects is the owner's my-projects surface
18
- (goal 1000059, task 1002693): the durable place to find, watch, and manage
19
- your instances after the /create tab closes. Functional-first against the
20
- current look (the R07 redesign applies later). Same trust boundary as
21
- /create — every button here only ASKS (enqueues an intent); the control-plane
22
- runner does the real infra work (ADR 0111 §2). ── */
23
- :root{
24
- --bg:#fff7ed; --bg-deep:#ffedd5;
25
- --ink:#2a1f17; --ink-soft:#6b5d52; --ink-faint:#9c8d80;
26
- --accent:#f97316; --accent-deep:#ea580c; --sea:#38bdf8; --terracotta:#fb7185;
27
- --card:#fffdfa; --line:#f3e1c9; --ok:#16a34a; --err:#dc2626;
28
- --font-display:"Baloo 2","Quicksand",system-ui,"Arial Rounded MT Bold",sans-serif;
29
- --font-body:"Quicksand",system-ui,-apple-system,"Segoe UI",sans-serif;
30
- --font-mono:"Fira Code",ui-monospace,SFMono-Regular,Menlo,monospace;
31
- }
32
- *{box-sizing:border-box;margin:0;padding:0;}
33
- [hidden]{display:none !important;}
34
- html{scroll-behavior:smooth;}
35
- body{
36
- font-family:var(--font-body); color:var(--ink);
37
- background:var(--bg);
38
- min-height:100vh; -webkit-font-smoothing:antialiased; overflow-x:hidden;
39
- }
40
- /* the sky — same fixed dawn layer as /create (gradient here, not on body:
41
- fixed-attachment backgrounds black out on scrolled composited pages) */
42
- .sky{
43
- position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden;
44
- background:linear-gradient(180deg,#e9f5fd 0%,#fde8c8 34%,#fff4e6 62%,#fffdfa 100%);
45
- }
46
- .cloud{position:absolute;border-radius:50%;background:rgba(255,255,255,0.55);filter:blur(38px);}
47
- .cloud.a{width:380px;height:380px;top:-120px;left:-80px;animation:drift 26s ease-in-out infinite alternate;}
48
- .cloud.b{width:300px;height:300px;top:4%;right:-60px;background:rgba(255,224,178,0.5);animation:drift 32s ease-in-out infinite alternate-reverse;}
49
- .cloud.c{width:460px;height:460px;bottom:-200px;left:24%;background:rgba(255,255,255,0.4);animation:drift 40s ease-in-out infinite alternate;}
50
- @keyframes drift{from{transform:translateX(-18px)}to{transform:translateX(26px)}}
51
-
52
- /* ── header ── */
53
- .top{
54
- position:relative;z-index:2;display:flex;align-items:center;justify-content:space-between;
55
- padding:20px clamp(18px,4vw,40px);
56
- }
57
- .top .mark{
58
- font-family:var(--font-display);font-weight:700;font-size:22px;letter-spacing:-0.5px;
59
- text-transform:lowercase;color:var(--ink);text-decoration:none;
60
- }
61
- .top .mark span{color:var(--accent);}
62
- .top-right{display:flex;align-items:center;gap:clamp(10px,2vw,18px);}
63
- .who{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600;color:var(--ink-soft);min-height:34px;}
64
- .who img{width:30px;height:30px;border-radius:50%;border:2px solid #fff;box-shadow:0 2px 8px rgba(42,31,23,0.12);}
65
-
66
- /* ── layout ── */
67
- .wrap{
68
- position:relative;z-index:1;max-width:880px;margin:0 auto;
69
- padding:clamp(8px,2vw,28px) clamp(16px,4vw,40px) 80px;
70
- }
71
- .card{
72
- background:var(--card);border-radius:26px;
73
- box-shadow:0 24px 60px -24px rgba(234,88,12,0.22), 0 6px 22px -8px rgba(42,31,23,0.10);
74
- padding:clamp(22px,3.6vw,40px);
75
- animation:rise .7s ease-out both;
76
- }
77
- @keyframes rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
78
- h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32px);letter-spacing:-0.5px;line-height:1.15;margin-bottom:8px;}
79
- .lede{font-size:15.5px;color:var(--ink-soft);font-weight:500;line-height:1.55;margin-bottom:24px;max-width:60ch;}
80
- .lede a,.projMsg a,.pnote a{color:var(--accent-deep);font-weight:700;text-decoration:none;}
81
- .lede a:hover,.projMsg a:hover,.pnote a:hover{text-decoration:underline;}
82
-
83
- /* ── buttons ── */
84
- .btn{
85
- display:inline-flex;align-items:center;justify-content:center;gap:9px;cursor:pointer;
86
- font-family:var(--font-display);font-weight:700;font-size:15px;color:#fff;
87
- background:var(--accent);border:none;border-radius:999px;padding:11px 22px;text-decoration:none;
88
- box-shadow:0 10px 22px -8px rgba(249,115,22,0.7);
89
- transition:transform .15s ease, box-shadow .15s ease, background .15s ease;
90
- }
91
- .btn:hover{transform:translateY(-1px);box-shadow:0 14px 28px -8px rgba(249,115,22,0.8);}
92
- .btn:disabled{opacity:0.6;cursor:default;transform:none;}
93
- .btn.ghost{background:transparent;color:var(--ink-soft);box-shadow:none;border:2px solid var(--line);}
94
- .btn.ghost:hover{border-color:var(--accent);color:var(--accent-deep);}
95
- .btn.small{font-size:13.5px;padding:8px 16px;}
96
- .btn.danger{background:transparent;color:var(--err);box-shadow:none;border:2px solid rgba(220,38,38,0.35);}
97
- .btn.danger:hover{background:rgba(220,38,38,0.06);border-color:var(--err);}
98
- .btn .gh{width:17px;height:17px;fill:currentColor;}
99
- .linklike{background:none;border:none;cursor:pointer;font-family:var(--font-body);font-weight:700;font-size:13.5px;color:var(--accent-deep);text-decoration:underline;padding:6px 4px;}
100
-
101
- /* ── project cards ── */
102
- .projects{list-style:none;display:flex;flex-direction:column;gap:18px;}
103
- .proj{
104
- border:2px solid var(--line);border-radius:18px;padding:18px 20px;background:#fff;
105
- }
106
- .proj.gone{opacity:0.62;}
107
- .projHead{display:flex;align-items:center;gap:12px;flex-wrap:wrap;}
108
- .projName{font-family:var(--font-display);font-weight:700;font-size:19px;letter-spacing:-0.3px;}
109
- .chip{
110
- font-size:12px;font-weight:700;letter-spacing:0.3px;border-radius:999px;padding:3px 11px;
111
- background:var(--bg-deep);color:var(--ink-soft);
112
- }
113
- .chip.live{background:rgba(22,163,74,0.13);color:var(--ok);}
114
- .chip.busy{background:rgba(249,115,22,0.14);color:var(--accent-deep);}
115
- .chip.bad{background:rgba(220,38,38,0.11);color:var(--err);}
116
- .projMeta{font-size:13px;font-weight:600;color:var(--ink-faint);margin-top:6px;display:flex;gap:14px;flex-wrap:wrap;}
117
- .projMeta code{font-family:var(--font-mono);font-size:12.5px;background:var(--bg-deep);border-radius:6px;padding:1px 6px;color:var(--accent-deep);}
118
- .addr{margin-top:12px;font-size:14.5px;font-weight:600;color:var(--ink-soft);}
119
- .addr a{color:var(--accent-deep);font-weight:700;text-decoration:none;}
120
- .addr a:hover{text-decoration:underline;}
121
- .addr .none{color:var(--ink-faint);font-style:italic;}
122
- .attach{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap;}
123
- .attach input[type=text]{
124
- flex:1;min-width:220px;font-family:var(--font-body);font-size:15px;font-weight:600;color:var(--ink);
125
- background:#fff;border:2px solid var(--line);border-radius:12px;padding:9px 14px;outline:none;
126
- transition:border-color .15s ease, box-shadow .15s ease;
127
- }
128
- .attach input[type=text]::placeholder{color:#c9b8a6;font-weight:500;}
129
- .attach input[type=text]:focus{border-color:var(--accent);box-shadow:0 0 0 4px rgba(249,115,22,0.13);}
130
- .projMsg{font-size:13.5px;font-weight:600;margin-top:10px;min-height:18px;color:var(--ink-faint);}
131
- .projMsg.ok{color:var(--ok);}
132
- .projMsg.err{color:var(--err);}
133
- .actions{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;align-items:center;}
134
- .confirmRow{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:13.5px;font-weight:700;color:var(--err);}
135
-
136
- /* live standup watch — the /create stage line, per card */
137
- .prog{margin-top:12px;border:2px solid var(--line);border-radius:14px;padding:12px 16px;background:var(--bg);}
138
- .prog.isLive{border-color:rgba(22,163,74,0.4);}
139
- .prog.isErr{border-color:rgba(220,38,38,0.35);}
140
- .stages{list-style:none;display:flex;gap:0;margin-bottom:8px;}
141
- .stages li{flex:1;display:flex;align-items:center;gap:7px;font-size:12.5px;font-weight:700;color:var(--ink-faint);}
142
- .stages .sdot{width:11px;height:11px;border-radius:50%;background:var(--line);flex:0 0 auto;}
143
- .stages li.done{color:var(--ink-soft);}
144
- .stages li.done .sdot{background:var(--ok);}
145
- .stages li.now{color:var(--accent-deep);}
146
- .stages li.now .sdot{background:var(--accent);animation:pulseDot 1.4s ease-in-out infinite;}
147
- @keyframes pulseDot{0%,100%{box-shadow:0 0 0 0 rgba(249,115,22,0.35)}50%{box-shadow:0 0 0 6px rgba(249,115,22,0.06)}}
148
- @media (prefers-reduced-motion:reduce){.stages li.now .sdot{animation:none}}
149
- .pnote{font-size:13px;font-weight:600;color:var(--ink-soft);line-height:1.5;}
150
-
151
- /* ── page-level states ── */
152
- .alert{
153
- display:none;background:rgba(220,38,38,0.07);border:2px solid rgba(220,38,38,0.25);
154
- color:var(--err);font-weight:600;font-size:14px;border-radius:14px;padding:12px 16px;margin-bottom:18px;
155
- }
156
- .alert.on{display:block;}
157
- .loading{font-size:14.5px;font-weight:600;color:var(--ink-faint);padding:14px 0;}
158
- .empty{padding:10px 0 4px;}
159
- .empty p{font-size:15px;font-weight:600;color:var(--ink-soft);margin-bottom:16px;max-width:52ch;line-height:1.55;}
160
- .finePrint{font-size:12.5px;color:var(--ink-faint);font-weight:600;margin-top:16px;line-height:1.5;}
161
-
162
- .foot{
163
- position:relative;z-index:1;text-align:center;font-size:13px;font-weight:600;color:var(--ink-faint);
164
- padding:0 20px 34px;
165
- }
166
- @media (max-width:640px){
167
- .projHead{gap:8px;}
168
- .actions{flex-direction:column;align-items:stretch;}
169
- }
25
+ /* page-specific layout. /projects is the owner's manage surface (goal
26
+ 1000059): the durable place to find, watch, and manage your instances.
27
+ Same trust boundary as /create — every button only ASKS (enqueues an
28
+ intent); the control-plane runner does the real infra work (ADR 0111 §2). */
29
+ .wrap{position:relative;z-index:1;max-width:880px;margin:0 auto;padding:0 clamp(16px,4vw,40px) 80px;}
30
+ .card{margin-bottom:26px;}
170
31
  </style>
171
32
  </head>
172
33
  <body>
173
34
  <div class="sky" aria-hidden="true">
174
- <div class="cloud a"></div><div class="cloud b"></div><div class="cloud c"></div>
35
+ <span class="star" style="top:2%;left:12%;width:2px;height:2px;"></span>
36
+ <span class="star" style="top:5%;left:28%;width:1.5px;height:1.5px;animation-delay:.7s;"></span>
37
+ <span class="star" style="top:3%;left:47%;width:2px;height:2px;animation-delay:1.4s;"></span>
38
+ <span class="star" style="top:8%;left:63%;width:1.5px;height:1.5px;animation-delay:.3s;"></span>
39
+ <span class="star" style="top:4%;left:78%;width:2.5px;height:2.5px;animation-delay:2s;"></span>
40
+ <span class="star" style="top:10%;left:88%;width:1.5px;height:1.5px;animation-delay:1s;"></span>
41
+ <span class="star" style="top:14%;left:20%;width:1.5px;height:1.5px;animation-delay:1.8s;"></span>
42
+ <span class="star" style="top:22%;left:55%;width:2px;height:2px;animation-delay:.5s;"></span>
43
+ <span class="star" style="top:34%;left:36%;width:1.5px;height:1.5px;animation-delay:2.4s;"></span>
44
+ <span class="star" style="top:30%;left:78%;width:1.5px;height:1.5px;animation-delay:1.2s;"></span>
45
+ <span class="star" style="top:48%;left:8%;width:2px;height:2px;animation-delay:.9s;"></span>
46
+ <span class="star" style="top:55%;left:90%;width:1.5px;height:1.5px;animation-delay:1.6s;"></span>
47
+ <span class="star" style="top:68%;left:24%;width:1.5px;height:1.5px;animation-delay:2.1s;"></span>
48
+ <span class="star" style="top:76%;left:64%;width:2px;height:2px;animation-delay:.4s;"></span>
49
+ <span class="star" style="top:88%;left:42%;width:1.5px;height:1.5px;animation-delay:1.9s;"></span>
175
50
  </div>
176
51
 
177
52
  <header class="top">
178
53
  <a class="mark" href="/" data-brand-product>cloud bongos</a>
179
54
  <div class="top-right">
180
- <a class="btn small ghost" href="/create">+ New project</a>
55
+ <a class="btn-space" href="/create">+ New project</a>
56
+ <button type="button" class="mode-toggle" id="modeToggle" title="Switch light / dark" aria-label="Switch light or dark mode">☾</button>
181
57
  <div class="who" id="who"></div>
182
58
  </div>
183
59
  </header>
184
60
 
61
+ <div class="hero">
62
+ <h1>Your projects</h1>
63
+ <p class="lede">The same sky as the front door — every project you own is an <b>orb</b> here. Each card says what the platform last verified, and when.</p>
64
+ </div>
65
+
185
66
  <div class="wrap">
186
67
  <main class="card">
187
68
  <!-- signed-out gate: identity-only sign-in (ADR 0143 least-privilege — unlike
188
69
  /create, listing your own instances needs no repo scope) -->
189
70
  <section id="gate" hidden>
190
- <h1>Your projects</h1>
191
71
  <p class="lede">Every project you own, in one place — its live status, its address, and the controls that matter. Sign in with GitHub to see yours.</p>
192
72
  <a class="btn" id="signin" href="/api/bongos/auth/web/start?return=/projects">
193
73
  <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>
@@ -198,16 +78,14 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
198
78
 
199
79
  <!-- signed-in: the list -->
200
80
  <section id="mine" hidden>
201
- <h1>Your projects</h1>
202
- <p class="lede">Everything you own, watched live. Standups keep running even with this tab closed — come back any time.</p>
203
81
  <div class="alert" id="pageAlert" role="alert"></div>
204
82
  <div class="loading" id="loading">Reading your projects…</div>
205
83
  <section class="empty" id="empty" hidden>
206
- <p>No projects yet. Your first one is a few minutes of gentle drumming away — its own repository, its own home on the web, built by agents.</p>
84
+ <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>
207
85
  <a class="btn" href="/create">Create your first project</a>
208
86
  </section>
209
87
  <ul class="projects" id="projectsList"></ul>
210
- <p class="finePrint">Buttons here only <em>ask</em> — teardown and address changes are queued for the platform's own runner; nothing touches servers from this page.</p>
88
+ <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>
211
89
  </section>
212
90
  </main>
213
91
  </div>
@@ -221,14 +99,34 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
221
99
  'use strict';
222
100
 
223
101
  var API = '/api/bongos';
224
- var POLL_MS = 5000; /* the create.html watch cadence */
225
- var POLL_SOFT_MS = 10000; /* transient failure back-off */
102
+ var POLL_MS = 5000; /* the create.html watch cadence (in-flight cards) */
103
+ var POLL_SOFT_MS = 10000; /* transient failure back-off */
104
+ var REFRESH_MS = 60000; /* settled-card refresh: recency + heartbeat overlay
105
+ (direction §3 — an active card must be able to
106
+ self-correct, so the list re-reads once a minute) */
107
+ var FRESH_MS = 12 * 60 * 1000; /* "checked Xm ago" freshness ≈ 2 heartbeat sweeps */
226
108
 
227
109
  var b = (typeof window !== 'undefined' && window.__BRANDING__) || {};
228
110
  var productName = (b.identity && b.identity.productName) || 'Cloud Bongos';
229
111
  document.querySelectorAll('[data-brand-product]').forEach(function (n) { n.textContent = productName.toLowerCase(); });
230
112
 
231
113
  function $(id) { return document.getElementById(id); }
114
+
115
+ /* ── light/dark: the sky is constant, the mode governs the cards. No saved
116
+ choice = follow the OS; the toggle stores an explicit one. ── */
117
+ function resolvedMode() {
118
+ var m = document.documentElement.getAttribute('data-mode');
119
+ if (m === 'light' || m === 'dark') return m;
120
+ return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
121
+ }
122
+ function paintModeToggle() { $('modeToggle').textContent = resolvedMode() === 'dark' ? '☀' : '☾'; }
123
+ $('modeToggle').addEventListener('click', function () {
124
+ var next = resolvedMode() === 'dark' ? 'light' : 'dark';
125
+ document.documentElement.setAttribute('data-mode', next);
126
+ try { localStorage.setItem('cb-color-mode', next); } catch (e) {}
127
+ paintModeToggle();
128
+ });
129
+ paintModeToggle();
232
130
  function esc(s) {
233
131
  return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
234
132
  return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
@@ -240,6 +138,27 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
240
138
  var timers = {}; /* id → watch timer */
241
139
  var lastMsg = {}; /* id → {text, cls} — survives a card re-render */
242
140
 
141
+ /* ── time helpers: every liveness claim carries recency (direction §3) ── */
142
+
143
+ function relTime(ts) {
144
+ var d = Date.now() - ts;
145
+ if (d < 90 * 1000) return 'just now';
146
+ if (d < 60 * 60 * 1000) return Math.round(d / 60000) + 'm ago';
147
+ if (d < 24 * 60 * 60 * 1000) return Math.round(d / 3600000) + 'h ago';
148
+ return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
149
+ }
150
+ function shortDate(iso) {
151
+ return iso ? new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
152
+ }
153
+ /* "since 14:02" today, "since Aug 7" earlier — for the unreachable chip */
154
+ function clockOrDate(ts) {
155
+ var d = new Date(ts);
156
+ if (Date.now() - ts < 24 * 60 * 60 * 1000 && d.getDate() === new Date().getDate()) {
157
+ return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
158
+ }
159
+ return shortDate(d.toISOString());
160
+ }
161
+
243
162
  /* ── API plumbing ─────────────────────────────────────────────────────── */
244
163
 
245
164
  /* Parse the error envelope truthfully: { error: { code, message, details } }.
@@ -255,16 +174,30 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
255
174
  return 'Your session expired — <a href="' + API + '/auth/web/start?return=/projects">sign in again</a>.';
256
175
  }
257
176
 
258
- /* ── status vocabulary ───────────────────────────────────────────────── */
177
+ /* ── status vocabulary (direction §4): the chip only asserts what the
178
+ platform verified, and says when. `last_verified_*` is the heartbeat
179
+ overlay — absent (pre-migration or never probed) reads as unverified. ── */
180
+
181
+ function chipMarkup(cls, label, rec) {
182
+ return '<span class="chip ' + cls + '"><span class="cdot"></span>' + esc(label) +
183
+ (rec ? ' <span class="rec">· ' + esc(rec) + '</span>' : '') + '</span>';
184
+ }
185
+ function chipFor(inst) {
186
+ var s = inst.status;
187
+ if (s === 'active') {
188
+ var at = inst.last_verified_at ? new Date(inst.last_verified_at).getTime() : 0;
189
+ if (inst.last_verified_up === false) return chipMarkup('down', 'unreachable', at ? 'since ' + clockOrDate(at) : '');
190
+ if (inst.last_verified_up && at && (Date.now() - at) <= FRESH_MS) return chipMarkup('live', 'live', 'checked ' + relTime(at));
191
+ return chipMarkup('maybe', 'live?', at ? 'last seen ' + relTime(at) : 'not checked yet');
192
+ }
193
+ if (s === 'requested') return chipMarkup('busy', 'queued');
194
+ if (s === 'provisioning') return chipMarkup('busy', 'standing up');
195
+ if (s === 'error') return chipMarkup('bad', 'snagged', inst.updated_at ? 'since ' + shortDate(inst.updated_at) : '');
196
+ if (s === 'tearing_down') return chipMarkup('busy', 'tearing down');
197
+ if (s === 'torn_down') return chipMarkup('calm', 'torn down', shortDate(inst.torn_down_at));
198
+ return chipMarkup('', inst.status);
199
+ }
259
200
 
260
- var CHIP = {
261
- requested: { cls: 'busy', label: 'queued' },
262
- provisioning: { cls: 'busy', label: 'standing up' },
263
- active: { cls: 'live', label: 'live' },
264
- error: { cls: 'bad', label: 'snagged' },
265
- tearing_down: { cls: 'busy', label: 'tearing down' },
266
- torn_down: { cls: '', label: 'torn down' }
267
- };
268
201
  /* client mirror of provisioning.TEARDOWN_FROM minus tearing_down (already
269
202
  going); no button either while a teardown intent is already queued */
270
203
  function canTeardown(inst) {
@@ -273,55 +206,123 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
273
206
  }
274
207
  /* client mirror of provisioning.DOMAIN_ATTACH_FROM */
275
208
  function canAttachDomain(inst) { return !inst.domain && (inst.status === 'requested' || inst.status === 'error' || inst.status === 'active'); }
209
+ /* re-enqueue doors: Try again on snagged, Bring it back on torn down —
210
+ both re-POST the slug (the server's PROVISION_ENQUEUE_FROM gate) */
211
+ function canRetry(inst) { return inst.status === 'error' && !inst.open_intent; }
212
+ function canRevive(inst) { return inst.status === 'torn_down' && !inst.open_intent; }
276
213
  /* keep watching while the platform is doing something to this instance */
277
214
  function shouldWatch(inst) {
278
- if (inst.status === 'requested' || inst.status === 'provisioning' || inst.status === 'tearing_down' || inst.status === 'error') return true;
215
+ if (inst.status === 'requested' || inst.status === 'provisioning' || inst.status === 'tearing_down') return true;
279
216
  return !!inst.open_intent; /* e.g. a queued teardown or domain-attach on an active instance */
280
217
  }
281
218
 
282
- /* ── the live standup watch (the create.html stage line, per card) ────── */
219
+ /* ── the orb: the gate's project sphere, glow keyed to verification ────── */
220
+
221
+ var ORB_ACCENTS = ['#fb923c', '#38bdf8', '#fb7185', '#f6c976', '#8b7ec8'];
222
+ var ORB_GLOWS = ['rgba(251,146,60,.5)', 'rgba(56,189,248,.5)', 'rgba(251,113,133,.45)', 'rgba(246,201,118,.45)', 'rgba(139,126,200,.45)'];
223
+ function orbFor(inst) {
224
+ var h = 0, slug = String(inst.slug || '');
225
+ for (var i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) >>> 0;
226
+ var k = h % ORB_ACCENTS.length;
227
+ var cls = 'orb';
228
+ if (inst.status === 'torn_down' || inst.status === 'tearing_down') cls += ' ghost';
229
+ else if (inst.status === 'error' || (inst.status === 'active' && inst.last_verified_up === false)) cls += ' ashen';
230
+ 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';
231
+ var style = '--acc:' + ORB_ACCENTS[k] + ';--glow:' + ORB_GLOWS[k];
232
+ if (inst.status === 'active' && inst.domain) {
233
+ 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>';
234
+ }
235
+ return '<span class="' + cls + '" style="' + style + '" aria-hidden="true"></span>';
236
+ }
237
+
238
+ /* ── progress strips: each flow speaks its own stages (direction §4).
239
+ The strip is chosen by the open intent + status, never one-size; a
240
+ torn-down card with a queued revive shows the REVIVE strip (F22). ── */
283
241
 
284
- function progressMarkup(stageNow, stagesDone, note, cls) {
285
- var names = ['Request filed', 'Standing it up', 'Live'];
286
- return '<div class="prog ' + (cls || '') + '" role="status" aria-live="polite">' +
287
- '<ul class="stages">' + names.map(function (n, i) {
242
+ var FLOWS = {
243
+ standup: { names: ['Request filed', 'Standing it up', 'Live'], cls: '' },
244
+ revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
245
+ teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
246
+ address: { names: ['Address queued', 'Wiring the address', 'Answering there'], cls: '' }
247
+ };
248
+ function stripMarkup(flow, stageNow, stagesDone, note) {
249
+ var f = FLOWS[flow];
250
+ return '<div class="strip ' + f.cls + '" role="status" aria-live="polite">' +
251
+ '<ul class="stages">' + f.names.map(function (n, i) {
288
252
  var c = stagesDone > i ? 'done' : (stageNow === i ? 'now' : '');
289
253
  return '<li class="' + c + '"><span class="sdot"></span><span class="slbl">' + n + '</span></li>';
290
254
  }).join('') + '</ul>' +
291
255
  '<p class="pnote">' + note + '</p></div>';
292
256
  }
293
- function progressFor(inst) {
257
+ function stripFor(inst) {
294
258
  var s = inst.status;
295
- if (s === 'requested') return progressMarkup(1, 1, 'Waiting for a runner to pick it up — usually under a minute.');
296
- if (s === 'provisioning') return progressMarkup(1, 1, 'Raising the server, database, and address. A few minutes, nothing needed from you.');
297
- if (s === 'error') return progressMarkup(1, 1, 'The standup hit a snag' + (inst.error_note ? ': ' + esc(inst.error_note) : '') + '. The platform keeps retrying — this card updates as it recovers.', 'isErr');
298
- if (s === 'tearing_down') return progressMarkup(1, 0, 'Tearing it down now — the card settles when it’s done.', 'isErr');
299
- if (s === 'active' && inst.open_intent) {
259
+ var revive = !!inst.torn_down_at; /* stood down before this standup → it's a revive */
260
+ if (s === 'requested') {
261
+ return stripMarkup(revive ? 'revive' : 'standup', 1, 1,
262
+ 'Waiting for a runner to pick it up. This card updates the moment it starts.');
263
+ }
264
+ if (s === 'provisioning') {
265
+ return stripMarkup(revive ? 'revive' : 'standup', 1, 1,
266
+ revive ? 'Bringing it back on the newest platform version — nothing needed from you.'
267
+ : 'Raising the server, database, and address — nothing needed from you.');
268
+ }
269
+ if (s === 'tearing_down') {
270
+ return stripMarkup('teardown', 1, 1, 'Taking the server offline. The repository stays yours; the database is kept.');
271
+ }
272
+ if (inst.open_intent) {
300
273
  var a = inst.open_intent.action;
301
- var doing = a === 'teardown' ? 'A teardown is queued' : a === 'domain-attach' ? 'Its new address is being wired' : 'A ' + esc(a) + ' run is queued';
302
- return progressMarkup(2, 2, doing + ' — this card updates when it lands.');
274
+ if (a === 'teardown') return stripMarkup('teardown', 0, 0, 'A teardown is queued — this card updates when it starts.');
275
+ if (a === 'domain-attach') return stripMarkup('address', 1, 1, 'Setting up DNS and the secure connection' + (inst.domain ? ' for <strong>' + esc(inst.domain) + '</strong>' : '') + '.');
276
+ if (a === 'provision') return stripMarkup(s === 'torn_down' ? 'revive' : 'standup', 0, 0, (s === 'torn_down' ? 'A revive is queued' : 'A standup is queued') + ' — this card updates when it starts.');
277
+ return stripMarkup('standup', 2, 2, 'A ' + esc(a) + ' run is queued — this card updates when it lands.');
303
278
  }
304
279
  return '';
305
280
  }
306
281
 
282
+ /* ── snagged cards: plain sentence first, raw detail folded away, and the
283
+ retry truth — nothing silently "keeps retrying" (direction §5, F19) ── */
284
+
285
+ function errCategory(note) {
286
+ var n = String(note || '');
287
+ if (/repository not found/i.test(n)) return 'We couldn’t push the starter code to the repository — it wasn’t found on GitHub.';
288
+ if (/mkdir|no such file or directory|read-only file system/i.test(n)) return 'The server couldn’t prepare a workspace for the project.';
289
+ if (/(push|clone|github).*permission|permission.*(push|clone|repo)/i.test(n)) return 'We couldn’t reach the repository — the account that set it up doesn’t have permission.';
290
+ if (/permission denied/i.test(n)) return 'The server hit a permission wall while setting up.';
291
+ if (/clone/i.test(n)) return 'We couldn’t fetch the repository from GitHub.';
292
+ if (/econnrefused|healthz|timed? ?out/i.test(n)) return 'The server didn’t come up.';
293
+ if (/dns|domain|caddy|tls|certificate/i.test(n)) return 'The address didn’t wire up.';
294
+ return 'The standup stopped partway.';
295
+ }
296
+ function errMarkup(inst) {
297
+ var when = inst.updated_at ? ' on ' + esc(shortDate(inst.updated_at)) : '';
298
+ var html = errCategory(inst.error_note) +
299
+ ' We stopped trying' + when + ' — nothing more will happen until you try again.';
300
+ if (inst.error_note) {
301
+ html += '<details><summary>Technical details</summary><pre>' + esc(inst.error_note) + '</pre></details>';
302
+ }
303
+ return html;
304
+ }
305
+
307
306
  /* ── rendering ───────────────────────────────────────────────────────── */
308
307
 
309
- function chipFor(inst) {
310
- var c = CHIP[inst.status] || { cls: '', label: inst.status };
311
- return '<span class="chip ' + c.cls + '">' + esc(c.label) + '</span>';
312
- }
308
+ var SHAPE_LABEL = { standalone: 'its own server', 'co-tenant': 'shared server', dedicated: 'its own server' };
313
309
 
314
310
  function addressRow(inst) {
315
311
  if (inst.domain) {
316
312
  if (inst.status === 'active') {
317
- return 'Address: <a href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">' + esc(inst.domain) + '</a>';
313
+ /* "answering" is a VERIFIED claim (direction §3 / F16): only the
314
+ heartbeat earns it — never the runner finishing its script */
315
+ var link = '<a href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">' + esc(inst.domain) + '</a>';
316
+ if (inst.last_verified_up) return 'Answering at ' + link;
317
+ if (inst.last_verified_up === false) return 'Not answering at ' + link + ' right now.';
318
+ return 'Its address is ' + link + ' — confirming it answers.';
318
319
  }
319
320
  return 'Address: <code>' + esc(inst.domain) + '</code> — it answers there once the instance is live.';
320
321
  }
321
322
  /* a torn-down instance has no address to speak of — the card's own line says so */
322
323
  if (inst.status === 'torn_down' || inst.status === 'tearing_down') return '';
323
324
  /* the HONEST no-address state: sign-in on the instance needs a real domain */
324
- return '<span class="none">No address yet — sign-in on your instance only works once it has a domain.</span>';
325
+ return '<span class="none">No address yet</span> — sign-in on this project can’t be set up until it has one.';
325
326
  }
326
327
 
327
328
  function renderCard(inst) {
@@ -334,29 +335,37 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
334
335
  li.className = 'proj' + (gone ? ' gone' : '');
335
336
  var meta = [];
336
337
  if (inst.target_ref) meta.push('repo <code>' + esc(inst.target_ref) + '</code>');
337
- if (inst.hosting_shape) meta.push(esc(inst.hosting_shape));
338
+ if (inst.hosting_shape) meta.push(esc(SHAPE_LABEL[inst.hosting_shape] || inst.hosting_shape));
338
339
  if (inst.onboard_mode === 'adopt') meta.push('adopted repo');
339
- var html =
340
+ if (inst.status === 'active' && inst.torn_down_at) meta.push('back live since ' + esc(shortDate(inst.provisioned_at || inst.torn_down_at)));
341
+ var body =
340
342
  '<div class="projHead"><span class="projName">' + esc(inst.slug) + '</span>' + chipFor(inst) + '</div>' +
341
343
  (meta.length ? '<div class="projMeta">' + meta.join('<span aria-hidden="true">·</span>') + '</div>' : '');
342
344
  var addr = addressRow(inst);
343
- if (addr) html += '<div class="addr">' + addr + '</div>';
345
+ if (addr) body += '<div class="addr">' + addr + '</div>';
344
346
  if (canAttachDomain(inst)) {
345
- html += '<div class="attach">' +
347
+ body += '<div class="attach">' +
346
348
  '<input type="text" id="dom-' + iid + '" placeholder="myproject.example.com" autocomplete="off" spellcheck="false" maxlength="253">' +
347
- '<button type="button" class="btn small" data-attach="' + iid + '">Attach address</button></div>';
349
+ '<button type="button" class="btn small" data-attach="' + iid + '">Give it an address</button></div>';
348
350
  }
349
- html += progressFor(inst);
351
+ if (inst.status === 'error' && !inst.open_intent) {
352
+ /* once a retry is queued the strip tells the story — the stale error
353
+ paragraph would just contradict it (owner walk, 2026-08-12) */
354
+ body += '<div class="projMsg err">' + errMarkup(inst) + '</div>';
355
+ }
356
+ body += stripFor(inst);
350
357
  if (gone) {
351
- html += '<div class="projMsg">Torn down' + (inst.torn_down_at ? ' ' + esc(new Date(inst.torn_down_at).toLocaleDateString()) : '') + '. Its handle stays yours — <a href="/create">start another</a> whenever you like.</div>';
358
+ body += '<div class="projMsg">Torn down' + (inst.torn_down_at ? ' ' + esc(shortDate(inst.torn_down_at)) : '') +
359
+ '. The repository is still yours, and its database is kept — bringing it back takes one click. Or <a href="/create">start another</a>.</div>';
352
360
  }
353
361
  var lm = lastMsg[inst.id];
354
- html += '<div class="projMsg' + (lm && lm.cls ? ' ' + lm.cls : '') + '" id="msg-' + iid + '">' + (lm ? lm.text : '') + '</div>';
355
- if (canTeardown(inst)) {
356
- html += '<div class="actions" id="act-' + iid + '">' +
357
- '<button type="button" class="btn small danger" data-teardown="' + iid + '">Tear it down</button></div>';
358
- }
359
- li.innerHTML = html;
362
+ body += '<div class="projMsg' + (lm && lm.cls ? ' ' + lm.cls : '') + '" id="msg-' + iid + '">' + (lm ? lm.text : '') + '</div>';
363
+ var acts = [];
364
+ if (canRetry(inst)) acts.push('<button type="button" class="btn small" data-retry="' + iid + '">Try again</button>');
365
+ if (canRevive(inst)) acts.push('<button type="button" class="btn small" data-revive="' + iid + '">Bring it back</button>');
366
+ if (canTeardown(inst)) acts.push('<button type="button" class="btn small danger" data-teardown="' + iid + '">Tear it down</button>');
367
+ if (acts.length) body += '<div class="actions" id="act-' + iid + '">' + acts.join('') + '</div>';
368
+ li.innerHTML = orbFor(inst) + '<div class="projBody">' + body + '</div>';
360
369
  }
361
370
 
362
371
  function render(instances) {
@@ -392,13 +401,25 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
392
401
  function stopWatch(id) { if (timers[id]) { clearTimeout(timers[id]); delete timers[id]; } }
393
402
  /* What the card RENDERS from — re-render only when this changes, so a quiet
394
403
  poll tick never clobbers in-flight UI (an open teardown confirm, a
395
- half-typed domain, a fresh action message). */
404
+ half-typed domain, a fresh action message). The heartbeat overlay is part
405
+ of the signature: recency is bucketed per-minute so "checked 2m ago"
406
+ stays current without thrashing. */
396
407
  function sig(inst) {
408
+ var at = inst.last_verified_at ? new Date(inst.last_verified_at).getTime() : 0;
397
409
  return JSON.stringify([inst.status, inst.domain, inst.error_note,
398
- inst.open_intent && inst.open_intent.action, inst.open_intent && inst.open_intent.state]);
410
+ inst.open_intent && inst.open_intent.action, inst.open_intent && inst.open_intent.state,
411
+ inst.last_verified_up, at ? Math.floor((Date.now() - at) / 60000) : -1]);
399
412
  }
400
413
  function takeUpdate(id, inst) {
401
- var changed = !byId[id] || sig(byId[id]) !== sig(inst);
414
+ var prev = byId[id];
415
+ var changed = !prev || sig(prev) !== sig(inst);
416
+ /* an action message ("Queued again…") describes the moment it was sent —
417
+ once the lifecycle moves past it (status flip, intent consumed) it
418
+ becomes a stale claim; clear it (owner walk, 2026-08-12) */
419
+ if (prev && (prev.status !== inst.status ||
420
+ ((prev.open_intent && prev.open_intent.action) !== (inst.open_intent && inst.open_intent.action)))) {
421
+ lastMsg[id] = null;
422
+ }
402
423
  byId[id] = inst;
403
424
  if (changed) renderCard(inst);
404
425
  }
@@ -440,14 +461,36 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
440
461
  .catch(function () { if (byId[id] && shouldWatch(byId[id])) watch(id); });
441
462
  }
442
463
 
443
- /* ── teardown: ask, confirm, then SAY exactly what the server said ────── */
464
+ /* ── settled-card refresh: the heartbeat overlay must reach the page.
465
+ A card at 'active' has no fast poll; once a minute (and on tab return)
466
+ the whole list re-reads, so "live · checked 2m ago" stays true and an
467
+ instance that died shows up as unreachable without a reload. ── */
468
+
469
+ function refreshList() {
470
+ fetch(API + '/provisioning/instances', { credentials: 'same-origin' })
471
+ .then(function (res) { if (!res.ok) throw new Error('soft'); return res.json(); })
472
+ .then(function (data) {
473
+ var instances = data.instances || [];
474
+ var known = Object.keys(byId).length;
475
+ var sameSet = instances.length === known && instances.every(function (i) { return byId[i.id]; });
476
+ if (!sameSet) { render(instances); return; }
477
+ instances.forEach(function (inst) { takeUpdate(inst.id, inst); });
478
+ })
479
+ .catch(function () { /* transient — next tick will try again */ });
480
+ }
481
+ setInterval(function () { if (!document.hidden && me) refreshList(); }, REFRESH_MS);
482
+ document.addEventListener('visibilitychange', function () { if (!document.hidden && me) refreshList(); });
483
+
484
+ /* ── teardown: ask, confirm (the truth, itemized — direction §5.4), then
485
+ SAY exactly what the server said ── */
444
486
 
445
487
  function askTeardown(id) {
446
488
  var act = $('act-' + id);
447
489
  if (!act) return;
448
- act.innerHTML = '<div class="confirmRow"><span>Really tear it down? The server and database go away; the repo stays yours.</span>' +
490
+ act.innerHTML = '<div class="confirmRow">' +
491
+ '<span class="plain">Tear it down? The server goes away and the address stops answering. Your repository is untouched. The database is kept, so bringing it back later restores its memory.</span>' +
449
492
  '<button type="button" class="btn small danger" data-teardown-yes="' + esc(id) + '">Yes, tear it down</button>' +
450
- '<button type="button" class="linklike" data-teardown-no="' + esc(id) + '">Keep it</button></div>';
493
+ '<button type="button" class="linklike" data-teardown-no="' + esc(id) + '">Keep it running</button></div>';
451
494
  }
452
495
  function unAskTeardown(id) { var inst = byId[id]; if (inst) renderCard(inst); }
453
496
  function doTeardown(id) {
@@ -486,6 +529,46 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
486
529
  });
487
530
  }
488
531
 
532
+ /* ── re-enqueue: Try again (snagged) and Bring it back (torn down) both
533
+ re-POST the slug — the server's PROVISION_ENQUEUE_FROM gate decides.
534
+ Same truth-telling as every other action: say what the server said. ── */
535
+
536
+ function reProvision(id, verb) {
537
+ var inst = byId[id];
538
+ if (!inst) return;
539
+ var act = $('act-' + id);
540
+ if (act) act.innerHTML = '<span class="projMsg">Asking…</span>';
541
+ var body = { slug: inst.slug, hosting_shape: inst.hosting_shape || 'standalone' };
542
+ if (inst.onboard_mode) body.onboard_mode = inst.onboard_mode;
543
+ if (inst.target_ref) body.target_ref = inst.target_ref;
544
+ if (inst.domain) body.domain = inst.domain;
545
+ fetch(API + '/provisioning/instances', {
546
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
547
+ body: JSON.stringify(body)
548
+ }).then(function (res) {
549
+ if (res.status === 401) { msg(id, signInAgain(), 'err'); return null; }
550
+ return jsonOrEmpty(res).then(function (data) {
551
+ if (res.ok) {
552
+ msg(id, esc(data.message || (verb === 'revive' ? 'Revive queued — bringing it back.' : 'Queued again — a runner picks it up from here.')), 'ok');
553
+ return;
554
+ }
555
+ var e = apiError(data, res.status);
556
+ if (e.code === 'intent_conflict') {
557
+ msg(id, esc(e.message || 'A previous run is still open — retry once it settles.'), 'err');
558
+ } else if (e.code === 'rate_limited') {
559
+ msg(id, esc(e.message || 'Too many requests right now — give it a minute and try again.'), 'err');
560
+ } else {
561
+ msg(id, esc(e.message || 'The request could not be queued — try again in a moment.'), 'err');
562
+ }
563
+ });
564
+ }).catch(function () {
565
+ msg(id, 'Couldn’t reach the platform — check your connection and try again.', 'err');
566
+ }).then(function () {
567
+ if (byId[id]) renderCard(byId[id]);
568
+ watchOnce(id);
569
+ });
570
+ }
571
+
489
572
  /* ── domain attach: same truth-telling for every refusal (task 1002692) ── */
490
573
 
491
574
  function doAttach(id) {
@@ -534,6 +617,8 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
534
617
  if (t.hasAttribute('data-teardown-yes')) return doTeardown(t.getAttribute('data-teardown-yes'));
535
618
  if (t.hasAttribute('data-teardown-no')) return unAskTeardown(t.getAttribute('data-teardown-no'));
536
619
  if (t.hasAttribute('data-attach')) return doAttach(t.getAttribute('data-attach'));
620
+ if (t.hasAttribute('data-retry')) return reProvision(t.getAttribute('data-retry'), 'retry');
621
+ if (t.hasAttribute('data-revive')) return reProvision(t.getAttribute('data-revive'), 'revive');
537
622
  });
538
623
 
539
624
  /* ── boot: who am I → my instances ────────────────────────────────────── */