@bongos/core 1.19.113 → 1.19.115

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.
@@ -37,7 +37,6 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
37
37
  Same trust boundary as /create — every button only ASKS (enqueues an
38
38
  intent); the control-plane runner does the real infra work (ADR 0111 §2). */
39
39
  .viewWrap{padding-bottom:40px;}
40
- .viewWrap.narrow{max-width:820px;}
41
40
  .card{margin-bottom:26px;}
42
41
 
43
42
  /* The bongo pair on the New-project view. Component-scoped custom properties,
@@ -51,6 +50,207 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
51
50
  --skin-hi:#fffdf7;--skin-mid:#f4e6cd;--skin-lo:#cdb896;--drum-shadow:var(--ink);}
52
51
  :root[data-mode="dark"] .bongos{--skin-hi:#fdf7ea;--skin-mid:#efe1c8;--skin-lo:#c3ae90;--drum-shadow:#000;}
53
52
  @media (prefers-color-scheme:dark){:root:not([data-mode="light"]) .bongos{--skin-hi:#fdf7ea;--skin-mid:#efe1c8;--skin-lo:#c3ae90;--drum-shadow:#000;}}
53
+
54
+ /* ── the create wizard (task 1002763, absorbed from /create) ──────────────
55
+ Tokens, the reset, the sky and the shared card/button/rail vocabulary all
56
+ come from cosmos.css; what lands here is the wizard's own structure. No
57
+ literal color anywhere: every surface is a cosmos token or a color-mix
58
+ over one, so both modes and a re-brand carry it for free (C1/C2/C4).
59
+ Naming: the wizard's field wrapper is .wizField — cosmos owns .field (the
60
+ orb map) — and its done checklist is .humanSteps — cosmos owns .steps
61
+ (the four-beat rail). Its inputs are scoped to the view so the glass
62
+ search pills (#mapQ, #mineQ) keep their own look. */
63
+ .panel{display:none;}
64
+ .panel.on{display:block;animation:fadeStep .3s ease both;}
65
+ @keyframes fadeStep{from{opacity:0;transform:translateX(10px)}to{opacity:1;transform:none}}
66
+ /* show() moves focus to each panel's h1 for keyboard/SR context — keep the
67
+ focus, lose the ring (a black rectangle around a heading; owner walk) */
68
+ h1[tabindex="-1"]:focus{outline:none;}
69
+ /* the done step needs the full card width — the split collapses and the
70
+ bongo pair steps aside for the checklist */
71
+ .createSplit.wizDone{display:block;}
72
+ .createSplit.wizDone .bongoCell{display:none;}
73
+
74
+ .wizField{display:block;margin-bottom:18px;}
75
+ .wizField>span{display:block;font-size:13.5px;font-weight:700;color:var(--ink-soft);margin-bottom:7px;letter-spacing:0.2px;}
76
+ .wizField small{font-weight:500;color:var(--ink-faint);}
77
+ #view-new input[type=text]{
78
+ width:100%;font-family:var(--font-body);font-size:17px;font-weight:600;color:var(--ink);
79
+ background:var(--bg-card);border:2px solid var(--line);border-radius:14px;padding:13px 16px;outline:none;
80
+ transition:border-color .15s ease, box-shadow .15s ease;
81
+ }
82
+ /* no fade toward the card — a 70% mix washed placeholders to 2.89:1 (task 1002749) */
83
+ #view-new input[type=text]::placeholder{color:var(--ink-faint);font-weight:500;}
84
+ #view-new input[type=text]:focus{border-color:var(--accent);box-shadow:0 0 0 4px color-mix(in srgb,var(--accent) 13%,transparent);}
85
+ #view-new input[type=text].bad{border-color:var(--err);}
86
+ .hint{font-size:13px;font-weight:600;color:var(--ink-faint);margin-top:7px;min-height:18px;}
87
+ .hint.ok{color:var(--ok);}
88
+ .hint.err{color:var(--err);}
89
+ .hint code{font-family:var(--font-mono);font-size:12.5px;background:var(--bg-deep);border-radius:6px;padding:1px 6px;color:var(--accent-deep);}
90
+
91
+ .row2{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
92
+
93
+ /* choice cards (greenfield / adopt) */
94
+ .choices{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:18px;}
95
+ .choice{
96
+ text-align:left;cursor:pointer;background:var(--bg-card);border:2px solid var(--line);border-radius:18px;
97
+ padding:16px;font-family:var(--font-body);transition:all .15s ease;
98
+ }
99
+ .choice:hover{border-color:var(--rule);transform:translateY(-1px);}
100
+ .choice[aria-pressed=true]{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 5%,var(--bg-card));box-shadow:0 8px 24px -12px color-mix(in srgb,var(--accent) 35%,transparent);}
101
+ .choice svg{display:block;margin-bottom:9px;}
102
+ .choice b{display:block;font-size:15.5px;font-weight:700;margin-bottom:3px;}
103
+ .choice p{font-size:13px;font-weight:500;color:var(--ink-soft);line-height:1.45;}
104
+ .reassure{
105
+ display:none;background:color-mix(in srgb,var(--sea) 8%,var(--bg-card));
106
+ border:1.5px solid color-mix(in srgb,var(--sea) 25%,var(--bg-card));border-radius:14px;
107
+ padding:12px 15px;font-size:13.5px;font-weight:500;color:color-mix(in srgb,var(--sea) 40%,var(--ink));line-height:1.5;margin-bottom:18px;
108
+ }
109
+ .reassure.on{display:block;}
110
+
111
+ /* one-click deep link to GitHub's own "New repository" page with the name
112
+ pre-filled (greenfield only). No permissions — the visitor creates the empty
113
+ repo in GitHub's own UI; we just fill in the blanks. The GitHub brand black
114
+ is intentional, not a token (it must not follow a re-brand). */
115
+ .ghbtn{
116
+ display:inline-flex;align-items:center;gap:9px;text-decoration:none;cursor:pointer;
117
+ background:#24292f;color:#fff;border:none;border-radius:999px;
118
+ font-family:var(--font-body);font-weight:700;font-size:15px;padding:12px 22px;
119
+ box-shadow:0 12px 26px -12px rgba(36,41,47,0.55);
120
+ transition:transform .12s ease, background .12s ease;
121
+ }
122
+ .ghbtn:hover{background:#1b1f24;transform:translateY(-1px);}
123
+ .ghbtn:active{transform:translateY(0) scale(.98);}
124
+ .ghbtn .gh{width:18px;height:18px;fill:currentColor;}
125
+ .humanSteps .ghbtn{font-size:14px;padding:9px 16px;margin-top:10px;}
126
+
127
+ .nav{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:26px;}
128
+ .btn.big{width:100%;justify-content:center;font-size:18px;padding:17px 30px;}
129
+
130
+ /* review list */
131
+ .review{list-style:none;border:2px solid var(--line);border-radius:18px;overflow:hidden;margin-bottom:6px;}
132
+ .review li{display:flex;justify-content:space-between;gap:16px;padding:13px 17px;font-size:14.5px;}
133
+ .review li+li{border-top:1.5px solid var(--line);}
134
+ .review .k{font-weight:600;color:var(--ink-faint);flex:0 0 auto;}
135
+ .review .v{font-weight:700;text-align:right;word-break:break-word;}
136
+ .review .v code{font-family:var(--font-mono);font-size:13px;background:var(--bg-deep);border-radius:6px;padding:1px 7px;color:var(--accent-deep);}
137
+
138
+ /* ── done / human steps ── */
139
+ .doneHead{display:flex;align-items:center;gap:12px;margin-bottom:8px;}
140
+ .doneHead .tick{
141
+ flex:0 0 44px;width:44px;height:44px;border-radius:50%;background:var(--ok);color:var(--on-ok);
142
+ display:flex;align-items:center;justify-content:center;
143
+ animation:pop .45s cubic-bezier(.34,1.56,.64,1) both;
144
+ }
145
+ @keyframes pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
146
+ .humanSteps{list-style:none;counter-reset:hs;margin-top:20px;}
147
+ .humanSteps>li{
148
+ position:relative;background:var(--bg-card);border:2px solid var(--line);border-radius:18px;
149
+ padding:16px 18px 16px 58px;margin-bottom:12px;counter-increment:hs;
150
+ }
151
+ .humanSteps>li::before{
152
+ content:counter(hs);position:absolute;left:16px;top:16px;width:28px;height:28px;border-radius:50%;
153
+ background:var(--bg-deep);color:var(--accent-deep);font-family:var(--font-display);font-weight:700;
154
+ font-size:14px;display:flex;align-items:center;justify-content:center;
155
+ }
156
+ .humanSteps b{font-size:15px;}
157
+ .humanSteps p{font-size:13.5px;font-weight:500;color:var(--ink-soft);line-height:1.55;margin-top:4px;}
158
+ /* live checklist states (task 1002694): a step the platform has verified done, and a
159
+ step that can't be performed yet (its prerequisite — usually the domain — is missing) */
160
+ .humanSteps>li.sdone{border-color:color-mix(in srgb,var(--ok) 30%,var(--bg-card));background:linear-gradient(180deg,color-mix(in srgb,var(--ok) 8%,var(--bg-card)) 0%,var(--bg-card) 100%);}
161
+ .humanSteps>li.sdone::before{content:"✓";background:var(--ok);color:var(--on-ok);}
162
+ .humanSteps>li.slocked{opacity:.6;}
163
+ .humanSteps details{margin-top:10px;}
164
+ .humanSteps summary{cursor:pointer;font-weight:700;font-size:12.5px;color:var(--ink-faint);}
165
+ .humanSteps details p{margin-top:8px;}
166
+ .kv{
167
+ display:flex;align-items:center;gap:8px;margin-top:9px;background:var(--bg);border:1.5px solid var(--line);
168
+ border-radius:11px;padding:8px 8px 8px 12px;font-size:12.5px;
169
+ }
170
+ .kv .kk{font-weight:700;color:var(--ink-faint);flex:0 0 auto;}
171
+ .kv code{font-family:var(--font-mono);font-size:12px;color:var(--accent-deep);word-break:break-all;flex:1;}
172
+ .copy{
173
+ flex:0 0 auto;cursor:pointer;border-radius:8px;background:var(--bg-card);border:1.5px solid var(--line);
174
+ font-family:var(--font-body);font-size:11.5px;font-weight:700;color:var(--ink-soft);padding:5px 10px;
175
+ transition:all .12s ease;
176
+ }
177
+ .copy:hover{border-color:var(--accent);color:var(--accent-deep);}
178
+ .copy.did{background:var(--ok);border-color:var(--ok);color:var(--on-ok);}
179
+ .samePath{font-size:12.5px;font-weight:500;color:var(--ink-faint);margin-top:16px;}
180
+ #liveProgress{margin-top:4px;}
181
+
182
+ /* ── live build progress (task 2083) ── */
183
+ .prog{
184
+ background:linear-gradient(180deg,color-mix(in srgb,var(--sea) 8%,var(--bg-card)) 0%,var(--bg-card) 100%);
185
+ border:2px solid var(--sea-soft);border-radius:18px;padding:18px 20px 16px;margin-top:14px;
186
+ }
187
+ .prog .stages{display:flex;list-style:none;margin-bottom:4px;}
188
+ .prog .stages li{flex:1;display:flex;flex-direction:column;align-items:center;gap:7px;position:relative;}
189
+ .prog .stages li:not(:first-child)::before{
190
+ content:"";position:absolute;top:11px;right:50%;width:100%;height:3px;border-radius:2px;
191
+ background:var(--sea-soft);z-index:0;margin-right:12px;
192
+ }
193
+ .prog .stages li.done:not(:first-child)::before{background:var(--sea);}
194
+ .prog .sdot{
195
+ position:relative;z-index:1;width:24px;height:24px;border-radius:50%;
196
+ border:3px solid var(--sea-soft);background:var(--bg-card);display:flex;align-items:center;justify-content:center;
197
+ }
198
+ .prog .stages li.done .sdot{border-color:var(--sea);background:var(--sea);}
199
+ .prog .stages li.done .sdot::after{content:"";width:8px;height:5px;border-left:2.5px solid #fff;border-bottom:2.5px solid #fff;transform:rotate(-45deg) translate(1px,-1px);}
200
+ .prog .stages li.now .sdot{border-color:var(--sea);}
201
+ .prog .stages li.now .sdot::after{
202
+ content:"";width:8px;height:8px;border-radius:50%;background:var(--sea);
203
+ animation:beatPulse 1.2s ease-in-out infinite;
204
+ }
205
+ /* idle labels match cosmos's .strip .slbl — the 55% sea mix washed to 4.15:1 in dark */
206
+ .prog .slbl{font-size:12px;font-weight:700;color:var(--ink-faint);}
207
+ .prog .stages li.now .slbl,.prog .stages li.done .slbl{color:color-mix(in srgb,var(--sea) 40%,var(--ink));}
208
+ .prog .pnote{text-align:center;font-size:13px;font-weight:600;color:color-mix(in srgb,var(--sea) 45%,var(--ink-soft));min-height:18px;}
209
+ .prog .pnote a{color:var(--accent-deep);font-weight:700;text-decoration:none;}
210
+ .prog .pnote a:hover{text-decoration:underline;}
211
+ .prog.isLive{background:linear-gradient(180deg,color-mix(in srgb,var(--ok) 8%,var(--bg-card)) 0%,var(--bg-card) 100%);border-color:color-mix(in srgb,var(--ok) 30%,var(--bg-card));}
212
+ .prog.isLive .stages li.done:not(:first-child)::before{background:var(--ok);}
213
+ .prog.isLive .stages li.done .sdot{border-color:var(--ok);background:var(--ok);}
214
+ .prog.isLive .slbl{color:var(--ok);}
215
+ .prog.isLive .stages li.done .slbl{color:color-mix(in srgb,var(--ok) 75%,var(--ink));}
216
+ .prog.isLive .pnote{color:color-mix(in srgb,var(--ok) 75%,var(--ink));font-size:14px;}
217
+ .prog.isErr{background:var(--err-soft);border-color:color-mix(in srgb,var(--err) 25%,var(--bg-card));}
218
+ .prog.isErr .pnote{color:var(--err);}
219
+ .again{
220
+ display:inline-block;margin-top:14px;font-size:13px;font-weight:700;color:var(--ink-faint);
221
+ background:none;border:none;cursor:pointer;font-family:var(--font-body);text-decoration:underline;
222
+ text-underline-offset:3px;padding:0;
223
+ }
224
+ .again:hover{color:var(--accent-deep);}
225
+
226
+ /* the collapsed address step — a confirmation, not a form (task 1002704) */
227
+ .addrConfirm{border:2px solid color-mix(in srgb,var(--ok) 30%,var(--bg-card));border-radius:18px;padding:18px 20px;background:linear-gradient(180deg,color-mix(in srgb,var(--ok) 8%,var(--bg-card)) 0%,var(--bg-card) 100%);margin-bottom:16px;}
228
+ .addrConfirm.optedOut{border-color:var(--line);background:var(--bg);opacity:.75;}
229
+ .addrBig{font-family:var(--font-display);font-weight:700;font-size:19px;letter-spacing:-0.3px;line-height:1.4;}
230
+ .addrBig code{font-family:var(--font-mono);font-size:15px;background:var(--bg-deep);border-radius:8px;padding:2px 9px;color:var(--accent-deep);word-break:break-all;}
231
+ .addrConfirm>p{font-size:13.5px;font-weight:500;color:var(--ink-soft);line-height:1.5;margin-top:6px;}
232
+ .addrConfirm details{margin-top:12px;}
233
+ .addrConfirm summary{cursor:pointer;font-size:13px;font-weight:700;color:var(--ink-faint);}
234
+ .addrConfirm details .inner{margin-top:10px;}
235
+ .addrOpt{display:flex;align-items:flex-start;gap:8px;margin-top:12px;font-size:12.5px;font-weight:600;color:var(--warn);line-height:1.5;}
236
+ .addrOpt input{margin-top:2px;}
237
+
238
+ /* GitHub repo picker (task 1002099) */
239
+ #ghConnect .btn{margin-top:4px;}
240
+ .ghlist{margin-top:8px;max-height:220px;overflow-y:auto;border:1px solid var(--line);border-radius:10px;background:var(--bg-deep);}
241
+ .ghlist:empty{display:none;}
242
+ /* rows sit on --bg-deep, so their tints mix over THAT, not the card */
243
+ .ghrow{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:none;border:0;border-bottom:1px solid color-mix(in srgb,var(--ink) 8%,transparent);padding:9px 12px;font:inherit;font-size:13.5px;color:var(--ink);cursor:pointer;}
244
+ .ghrow:last-child{border-bottom:0;}
245
+ .ghrow:hover{background:color-mix(in srgb,var(--accent) 6%,var(--bg-deep));}
246
+ .ghrow[aria-selected=true]{background:color-mix(in srgb,var(--accent) 12%,var(--bg-deep));box-shadow:inset 3px 0 0 var(--accent);font-weight:600;}
247
+ /* --ink-soft, not --ink-faint: the badge tint is the darkest ground on the page,
248
+ and faint ink only reaches 3.96:1 on it (task 1002749). */
249
+ .ghrow .priv{margin-left:auto;font-size:11px;font-weight:600;color:var(--ink-soft);background:color-mix(in srgb,var(--ink) 8%,transparent);border-radius:5px;padding:1px 6px;}
250
+
251
+ /* 840px matches cosmos's createSplit collapse, the breakpoint the old /create
252
+ used (860) — the two-up grids and the bongo cell fold together */
253
+ @media (max-width:840px){.row2,.choices{grid-template-columns:1fr;}}
54
254
  </style>
55
255
  </head>
56
256
  <body>
@@ -117,8 +317,8 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
117
317
  </header>
118
318
 
119
319
  <noscript>
120
- <div class="viewWrap wrap"><div class="fieldNote">The project map needs JavaScript. You can still
121
- <a href="/create">start a project</a> or read what this is on the <a href="/">home page</a>.</div></div>
320
+ <div class="viewWrap wrap"><div class="fieldNote">The project hub needs JavaScript — the map, your
321
+ projects, and starting a new one all live here. You can read what this is on the <a href="/">home page</a>.</div></div>
122
322
  </noscript>
123
323
 
124
324
  <!-- ══════════════════ VIEW · MAP ══════════════════════════════════════════
@@ -147,6 +347,13 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
147
347
  <div class="field" id="orbField">
148
348
  <div class="ground"></div>
149
349
  <div class="stars" id="mapStars" aria-hidden="true"></div>
350
+ <!-- The camera (task 1002762). Pan and zoom are ONE transform on this
351
+ layer; orbs write only their own physics delta, so the two compose
352
+ for free and the ground, stars, empty state and hintbar stay put.
353
+ It opens BEFORE the apex sentinel: stripApexOnly() deletes whole
354
+ sentinel spans, and a tag opened inside one would leave the child
355
+ instance an unbalanced tree. -->
356
+ <div class="skyCam" id="skyCam">
150
357
  <!-- The platform's own orb. Static markup, not JS-built, precisely so the
151
358
  apex-only sentinels can remove it: a child instance has no "this
152
359
  platform" to point at, and stripApexOnly() is a string transform that
@@ -158,12 +365,17 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
158
365
  <span class="sub">this platform</span>
159
366
  </a>
160
367
  <!--apex-only:end-->
368
+ </div>
161
369
  <div class="fieldEmpty">
162
370
  <p id="emptyMsg">You don’t own or build on anything here yet.</p>
163
371
  <span class="sub">Turn the filter off to see what everyone else is building — or start your own.</span>
164
372
  <a class="btn small" href="?view=new" data-nav-to="new">Start a project</a>
165
373
  </div>
166
- <div class="hintbar"><span><kbd>click a project</kbd> open it</span></div>
374
+ <div class="hintbar"><span><kbd>drag</kbd> pan</span><span><kbd>scroll</kbd> zoom</span><span><kbd>click a project</kbd> open it</span></div>
375
+ <!-- The hover card: a glance, not a dialog. Outside the camera so it
376
+ never scales, aria-hidden because the orb itself is the accessible
377
+ link, and chip-free — the orb's own chip is the ONE liveness site. -->
378
+ <div class="hoverCard" id="hoverCard" hidden aria-hidden="true"></div>
167
379
  </div>
168
380
  </div>
169
381
 
@@ -284,7 +496,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
284
496
 
285
497
  <section class="empty" id="empty" hidden>
286
498
  <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>
287
- <a class="btn" href="/create">Create your first project</a>
499
+ <a class="btn" href="?view=new" data-nav-to="new">Create your first project</a>
288
500
  </section>
289
501
  <ul class="projects" id="projectsList"></ul>
290
502
  <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>
@@ -294,31 +506,194 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
294
506
  </section>
295
507
 
296
508
  <!-- ══════════════════ VIEW · NEW PROJECT ══════════════════════════════════
297
- Step 1 of the four-beat rail. Naming it here hands straight into the
298
- wizard with the name already seeded (/create?name=…), which is the entry
299
- point the wizard already reads. -->
509
+ The whole create wizard, absorbed from /create (task 1002763; direction
510
+ §4 — that route now 302s here). Six panels behind the four-beat rail; the
511
+ wizard block in the script below drives them. Same trust boundary as
512
+ ever: every button here only ENQUEUES a provisioning request (ADR 0111).
513
+ The name panel carries a static `on` so a no-script visitor still reads
514
+ step 1 rather than an empty card. -->
300
515
  <section id="view-new" hidden>
301
- <div class="wrap viewWrap narrow">
516
+ <div class="wrap viewWrap">
302
517
  <div class="card">
303
- <ol class="steps">
304
- <li class="now"><span class="snum">1</span><span class="slbl">Name</span></li>
305
- <li><span class="snum">2</span><span class="slbl">Home</span></li>
306
- <li><span class="snum">3</span><span class="slbl">Address</span></li>
307
- <li><span class="snum">4</span><span class="slbl">Create</span></li>
518
+ <ol class="steps" aria-label="Creation steps">
519
+ <li class="now" data-step="1"><span class="snum">1</span><span class="slbl">Name</span></li>
520
+ <li data-step="2"><span class="snum">2</span><span class="slbl">Home</span></li>
521
+ <li data-step="3"><span class="snum">3</span><span class="slbl">Address</span></li>
522
+ <li data-step="4"><span class="snum">4</span><span class="slbl">Create</span></li>
308
523
  </ol>
309
524
 
310
525
  <div class="createSplit">
311
526
  <div>
312
- <h1>Start a project</h1>
313
- <p class="lede">Your own project: its own repository, its own home on the web, and a hall of AI builders
314
- who do the building. You set the direction; agents lay down the code.</p>
315
- <form class="attach" id="newForm">
316
- <input type="text" id="newName" placeholder="What should it be called?" maxlength="80" autocomplete="off" aria-label="Project name">
317
- <button class="btn" type="submit">Continue →</button>
318
- </form>
319
- <p class="finePrint">Free while we find our feet. Your project’s repo and server are yours —
320
- <span data-brand-product-inline>Cloud Bongos</span> is <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">open source (AGPL-3.0)</a>
321
- and capture-resistant by design.</p>
527
+ <!-- step 0 · sign-in gate -->
528
+ <section class="panel" id="panel-gate" aria-labelledby="gate-h">
529
+ <h1 id="gate-h">Create a project</h1>
530
+ <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>
531
+ <!-- The wizard's own sign-in opts into repo scope (read:user + public_repo) up
532
+ front (&repos=1) — anyone here intends to create a project, which needs repo
533
+ access, so we ask once at sign-in instead of a second "Connect" step at Home.
534
+ Hall + CLI sign-in stay identity-only (ADR 0143 least-privilege default). -->
535
+ <a class="btn big" id="wizSignin" href="/api/bongos/auth/web/start?return=/projects%3Fview%3Dnew&amp;repos=1">
536
+ <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>
537
+ Sign in with GitHub
538
+ </a>
539
+ <!-- Trust link → the AGPL license text (task 1002705): the old private-repo URL
540
+ 404ed, and no public code artifact exists until the ADR 0099 mirror flips
541
+ (~2026-11; npm scopes are private too) — the license is the one live,
542
+ instance-neutral artifact behind the claim today. -->
543
+ <p class="finePrint">Free while we find our feet. Your project’s repo and server are yours —
544
+ <span data-brand-product-inline>Cloud Bongos</span> is <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">open source (AGPL-3.0)</a>
545
+ and capture-resistant by design.</p>
546
+ <div class="alert" id="wizGateAlert" role="alert"></div>
547
+ </section>
548
+
549
+ <!-- step 1 · name (the hub's original step-1 form, now the wizard's) -->
550
+ <section class="panel on" id="panel-name" aria-labelledby="name-h">
551
+ <h1 id="name-h">Start a project</h1>
552
+ <p class="lede">Your own project: its own repository, its own home on the web, and a hall of AI builders
553
+ who do the building. You set the direction; agents lay down the code.</p>
554
+ <form id="newForm" data-next="2">
555
+ <label class="wizField"><span>Project name</span>
556
+ <input type="text" id="newName" placeholder="What should it be called?" autocomplete="off" maxlength="80" aria-label="Project name">
557
+ <div class="hint" id="h-name">&nbsp;</div>
558
+ </label>
559
+ <div class="nav">
560
+ <span></span>
561
+ <button type="submit" class="btn">Continue →</button>
562
+ </div>
563
+ </form>
564
+ <p class="finePrint">Free while we find our feet. Your project’s repo and server are yours —
565
+ <span data-brand-product-inline>Cloud Bongos</span> is <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">open source (AGPL-3.0)</a>
566
+ and capture-resistant by design.</p>
567
+ </section>
568
+
569
+ <!-- step 2 · home (repo + mode) -->
570
+ <section class="panel" id="panel-home" aria-labelledby="home-h">
571
+ <h1 id="home-h">Where the code lives</h1>
572
+ <p class="lede">Every project is a real GitHub repository that you own. Start from a clean slate, or bring a repo you already have.</p>
573
+ <form data-next="3">
574
+ <div class="choices" role="group" aria-label="Starting point">
575
+ <button type="button" class="choice" id="c-green" aria-pressed="true">
576
+ <!-- token-built, not platform orange — public-landing/ ships wholesale to
577
+ every instance (C5), so a re-branded accent must carry these too -->
578
+ <svg width="30" height="30" viewBox="0 0 30 30" fill="none" aria-hidden="true"><path d="M15 26V14" stroke="var(--accent)" stroke-width="2.6" stroke-linecap="round"/><path d="M15 15c0-5 3.5-8.5 9-8.5 0 5.5-3.5 8.5-9 8.5Z" fill="color-mix(in srgb,var(--accent) 45%,var(--bg-card))" stroke="var(--accent)" stroke-width="2"/><path d="M15 18c0-3.8-2.6-6.5-7-6.5 0 4.3 2.7 6.5 7 6.5Z" fill="color-mix(in srgb,var(--accent) 22%,var(--bg-card))" stroke="var(--accent)" stroke-width="2"/></svg>
579
+ <b>Start fresh</b>
580
+ <p>A new, empty <strong>public</strong> repo. We scaffold everything: config, first version, kickoff tasks.</p>
581
+ </button>
582
+ <button type="button" class="choice" id="c-adopt" aria-pressed="false">
583
+ <svg width="30" height="30" viewBox="0 0 30 30" fill="none" aria-hidden="true"><rect x="4" y="9" width="22" height="16" rx="3" fill="color-mix(in srgb,var(--accent) 22%,var(--bg-card))" stroke="var(--accent)" stroke-width="2"/><path d="M4 13h22" stroke="var(--accent)" stroke-width="2"/><path d="M10 9V6.5A1.5 1.5 0 0 1 11.5 5h7A1.5 1.5 0 0 1 20 6.5V9" stroke="var(--accent)" stroke-width="2"/><path d="M12 19h6" stroke="var(--accent-deep)" stroke-width="2.4" stroke-linecap="round"/></svg>
584
+ <b>Adopt a repo I have</b>
585
+ <p>Layer <span data-brand-product-inline>Cloud Bongos</span> onto an existing repository — history, code, and backlog intact. Public or private, we never change it.</p>
586
+ </button>
587
+ </div>
588
+ <div class="reassure" id="adoptNote">Adopting never overwrites your files: you get a conflict pre-flight first, nothing is written until you accept, and your existing backlog (issues, TODOs) is imported as real tasks.</div>
589
+
590
+ <!-- GitHub-integrated repo picker (adopt) + auto-create (fresh) — task 1002099.
591
+ Progressive enhancement: when the signed-in user grants repo access we let
592
+ them PICK a repo (adopt) or NAME one we auto-create (fresh). Without that
593
+ access — not granted yet, or the server can't store the token — we fall back
594
+ to the manual owner/repo inputs below (#manualHome), which stay authoritative. -->
595
+ <div id="ghConnect" class="reassure">
596
+ <p id="ghConnectMsg">&nbsp;</p>
597
+ <button type="button" class="btn" id="ghConnectBtn">Allow repo access</button>
598
+ <button type="button" class="linklike" id="ghManualToggle">or enter it manually</button>
599
+ </div>
600
+ <div id="ghBusy" class="hint" hidden>Reading your GitHub repositories…</div>
601
+ <div id="ghAdopt" class="wizField" hidden>
602
+ <span>Pick one of your repositories</span>
603
+ <input type="text" id="ghSearch" placeholder="Search your repos…" autocomplete="off">
604
+ <div id="ghList" class="ghlist" role="listbox" aria-label="Your repositories"></div>
605
+ <div class="hint" id="ghAdoptHint">&nbsp;</div>
606
+ <!-- visibility stated on EVERY repo path (task 1002721) — the picker
607
+ marks private repos, and this says what adoption does about it -->
608
+ <div class="hint">We never change a repo’s visibility — public stays public, private stays private.</div>
609
+ </div>
610
+ <div id="ghFresh" class="wizField" hidden>
611
+ <span>New repository name</span>
612
+ <input type="text" id="ghNewName" placeholder="mercury" autocomplete="off" maxlength="100">
613
+ <!-- visibility is stated on EVERY repo path (task 1002721) — this baked
614
+ line is the floor; updateFreshPreview() replaces it with the same
615
+ claim plus the resolved repo URL once a name is typed -->
616
+ <div class="hint" id="ghFreshHint">We’ll create an empty <strong>public</strong> repo — projects here are open by design. Want private code? Create the repo yourself on GitHub and “adopt” it instead.</div>
617
+ </div>
618
+
619
+ <div class="row2" id="manualHome">
620
+ <label class="wizField"><span>GitHub owner <small>(user or org)</small></span>
621
+ <input type="text" id="f-owner" placeholder="acme" autocomplete="off" maxlength="60">
622
+ </label>
623
+ <label class="wizField"><span>Repository name</span>
624
+ <input type="text" id="f-repo" placeholder="mercury" autocomplete="off" maxlength="120">
625
+ </label>
626
+ <!-- visibility stated BEFORE GitHub's own radio is the first place it
627
+ appears (task 1002721, owner firsthand in the QA walk) -->
628
+ <div class="hint" style="grid-column:1 / -1;">Creating the repo yourself on GitHub? Public or private is your choice there — both work here, and we never change it.</div>
629
+ </div>
630
+ <div class="hint" id="h-home">&nbsp;</div>
631
+ <div class="nav">
632
+ <button type="button" class="btn ghost" data-back="1">← Back</button>
633
+ <button type="submit" class="btn">Next →</button>
634
+ </div>
635
+ </form>
636
+ </section>
637
+
638
+ <!-- step 3 · address — a CONFIRMATION, not a form (task 1002704: every project
639
+ gets its slug subdomain assigned up front; opt-out lives in the disclosure).
640
+ The domainless default was a proven dead-end (QA walk, task 1002702). -->
641
+ <section class="panel" id="panel-address" aria-labelledby="addr-h">
642
+ <h1 id="addr-h">Its address in the sky</h1>
643
+ <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>
644
+ <form data-next="4">
645
+ <label class="wizField"><span>Project handle</span>
646
+ <input type="text" id="f-slug" placeholder="mercury" autocomplete="off" maxlength="40" spellcheck="false">
647
+ <div class="hint" id="h-slug">&nbsp;</div>
648
+ </label>
649
+ <div class="addrConfirm" id="addrConfirm">
650
+ <div class="addrBig" id="addrAssigned"></div>
651
+ <p id="addrAssignedNote">Yours from the moment it stands up. You can point a domain you own at it any time.</p>
652
+ <details id="addrCustom">
653
+ <summary>Use a domain I own instead</summary>
654
+ <div class="inner">
655
+ <label class="wizField"><span>Domain you own</span>
656
+ <input type="text" id="f-domain" placeholder="mercury.example.com" autocomplete="off" maxlength="253" spellcheck="false">
657
+ <div class="hint" id="h-domain">&nbsp;</div>
658
+ </label>
659
+ <label class="addrOpt"><input type="checkbox" id="f-noaddr">
660
+ <span>Start without an address — possible, but sign-in on your project can’t be set up until it has one.</span>
661
+ </label>
662
+ </div>
663
+ </details>
664
+ </div>
665
+ <div class="nav">
666
+ <button type="button" class="btn ghost" data-back="2">← Back</button>
667
+ <button type="submit" class="btn">Next →</button>
668
+ </div>
669
+ </form>
670
+ </section>
671
+
672
+ <!-- step 4 · review + create -->
673
+ <section class="panel" id="panel-review" aria-labelledby="rev-h">
674
+ <h1 id="rev-h">Ready to roll</h1>
675
+ <p class="lede">One tap files the request and the platform starts standing your project up — server, database, address. Here's the beat you're about to drop:</p>
676
+ <ul class="review" id="reviewList"></ul>
677
+ <div class="alert" id="createAlert" role="alert"></div>
678
+ <div class="nav" style="flex-direction:column;align-items:stretch;">
679
+ <button type="button" class="btn big" id="doCreate">Create my project</button>
680
+ <button type="button" class="btn ghost" data-back="3" style="align-self:flex-start;margin-top:10px;">← Back</button>
681
+ </div>
682
+ <p class="finePrint">This files the request; the control plane does the real standup and nothing touches servers from this page. You'll get the few steps only you can do right after.</p>
683
+ </section>
684
+
685
+ <!-- done · the human steps -->
686
+ <section class="panel" id="panel-done" aria-labelledby="done-h">
687
+ <div class="doneHead">
688
+ <span class="tick" aria-hidden="true"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M4 11.5 9 16.5 18 6" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
689
+ <h1 id="done-h">The drums are rolling</h1>
690
+ </div>
691
+ <p class="lede" id="doneLede"></p>
692
+ <div id="liveProgress"></div>
693
+ <h2 style="font-family:var(--font-display);font-size:19px;margin-top:22px;">Your steps — the parts only you can play</h2>
694
+ <ol class="humanSteps" id="humanSteps"></ol>
695
+ <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>
696
+ </section>
322
697
  </div>
323
698
 
324
699
  <!-- the bongo pair — one mark on every surface (owner, 2026-08-13).
@@ -469,14 +844,26 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
469
844
  else a.removeAttribute('aria-current');
470
845
  });
471
846
  try { history.replaceState(null, '', '?view=' + v); } catch (e) {}
847
+ /* the shell's sign-in door returns the visitor to the view they left, and
848
+ carries the repo scope when that view is the wizard (ADR 0143: the
849
+ create path asks once up front instead of a second consent at Home) */
850
+ $('signinTop').href = '/api/bongos/auth/web/start?return=/projects%3Fview%3D' + v + (v === 'new' ? '&repos=1' : '');
472
851
  /* the mine view is the only one that polls; it re-reads on entry so a
473
852
  card that changed while you were on the map is not stale */
474
853
  if (v === 'mine' && me) refreshList();
475
854
  /* owned chips refresh on re-entry, never on a timer — C8 keeps the map quiet */
476
855
  if (v === 'map' && me) loadOwnedInstances().then(applyMapFilter);
477
- /* focus the name field only on a deliberate move into the view — never on
478
- boot, where it would steal focus from whatever the visitor arrived at */
479
- if (v === 'new' && focus) $('newName').focus();
856
+ /* the living sky runs only on stage (C8) — start and stop with the view;
857
+ anchors that went unreadable while the map was hidden re-adopt first */
858
+ if (v === 'map') {
859
+ if (skyNeedsRebuild) rebuildSkyBodies();
860
+ startSky();
861
+ } else { stopSky(); hideHover(); }
862
+ /* the wizard runs only on stage too (C8): enter() paints the right step
863
+ (focusing only on a deliberate move — never on boot, where it would
864
+ steal focus from whatever the visitor arrived at) and leave() parks
865
+ its watch + manifest polls */
866
+ if (v === 'new') wizard.enter(focus); else wizard.leave();
480
867
  }
481
868
 
482
869
  document.querySelectorAll('[data-nav]').forEach(function (a) {
@@ -651,16 +1038,24 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
651
1038
  return hay.toLowerCase().indexOf(q) !== -1;
652
1039
  }
653
1040
 
654
- function renderOrbs(list) {
655
- var field = $('orbField');
1041
+ function renderOrbs(list, dimmed) {
1042
+ var layer = $('skyCam');
656
1043
  /* replace only the generated orbs — the platform's own orb is static
657
1044
  apex-only markup and must survive every re-render */
658
- field.querySelectorAll('.worldOrb[data-generated]').forEach(function (n) { n.remove(); });
659
- var hint = field.querySelector('.hintbar');
660
- list.forEach(function (p, i) {
1045
+ layer.querySelectorAll('.worldOrb[data-generated]').forEach(function (n) { n.remove(); });
1046
+ /* the spotlight (task 1002762): while a query is live the sky DIMS what
1047
+ does not match instead of removing it — the cluster keeps its shape and
1048
+ the matches glow. The table still filters, and the count counts matches. */
1049
+ var spot = !!(dimmed && dimmed.length);
1050
+ (spot ? list.concat(dimmed) : list).forEach(function (p, i) {
661
1051
  var slot = ORB_SLOTS[i % ORB_SLOTS.length];
1052
+ var isDim = spot && i >= list.length;
662
1053
  var a = document.createElement('a');
663
- a.className = 'worldOrb';
1054
+ a.className = 'worldOrb' + (spot ? (isDim ? ' dim' : ' match') : '');
1055
+ /* a dimmed orb is BACKDROP: at .25 opacity its label cannot carry
1056
+ interaction honestly, so it leaves the tab order and the pointer
1057
+ (pointer-events:none in css) — the matches and the table do the work */
1058
+ if (isDim) { a.setAttribute('aria-hidden', 'true'); a.tabIndex = -1; }
664
1059
  a.setAttribute('data-generated', '');
665
1060
  var rel = relOf(p);
666
1061
  a.setAttribute('data-rel', rel);
@@ -695,7 +1090,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
695
1090
  project can never look alive in the sky and unreachable in the list
696
1091
  (C6: two views of one truth) */
697
1092
  (inst ? chipFor(inst) : '');
698
- field.insertBefore(a, hint);
1093
+ layer.appendChild(a);
699
1094
  });
700
1095
  }
701
1096
 
@@ -766,6 +1161,11 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
766
1161
  var list = browse.filter(function (p) {
767
1162
  return matchesMapQuery(p, q) && (!onlyMine || relOf(p) !== 'none');
768
1163
  });
1164
+ /* what the query rules OUT still exists — the sky dims it (spotlight);
1165
+ the "only mine" switch is a filter, not a search, and stays a removal */
1166
+ var dimmedOrbs = q ? browse.filter(function (p) {
1167
+ return !matchesMapQuery(p, q) && (!onlyMine || relOf(p) !== 'none');
1168
+ }) : [];
769
1169
  list.sort(function (x, y) {
770
1170
  if (mapSort.key === 'name') return String(x.name || '').localeCompare(String(y.name || '')) * mapSort.dir;
771
1171
  /* unknown (null) sorts to the BOTTOM in both directions — it is not a low
@@ -779,7 +1179,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
779
1179
  }
780
1180
  return 0;
781
1181
  });
782
- renderOrbs(list);
1182
+ renderOrbs(list, dimmedOrbs);
783
1183
  renderRows(list);
784
1184
 
785
1185
  /* The platform's own entry, in BOTH views from one predicate — it used to
@@ -788,11 +1188,19 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
788
1188
  It is somebody's: see selfRel(). It still answers the search box, and it
789
1189
  still vanishes for a signed-out visitor, who owns nothing here. */
790
1190
  var rel = selfRel();
791
- var selfShown = (rel !== 'none' || !onlyMine) && matchesMapQuery(SELF_PROJECT, q);
1191
+ var selfPass = (rel !== 'none' || !onlyMine);
1192
+ var selfShown = selfPass && matchesMapQuery(SELF_PROJECT, q);
792
1193
  var badge = rel === 'owner' ? 'yours' : rel === 'builder' ? 'you build here' : '';
793
1194
  var orb = $('selfOrb'), row = $('selfRow');
794
1195
  if (orb) {
795
- orb.hidden = !selfShown;
1196
+ /* in the sky a query DIMS the platform like everyone else; the "only
1197
+ mine" filter still removes it for a signed-out visitor. Dimmed = pure
1198
+ backdrop, same as a generated dim: no focus, no pointer. */
1199
+ var selfDim = selfPass && !selfShown;
1200
+ orb.hidden = !selfPass;
1201
+ orb.classList.toggle('dim', selfDim);
1202
+ if (selfDim) { orb.setAttribute('aria-hidden', 'true'); orb.tabIndex = -1; }
1203
+ else { orb.removeAttribute('aria-hidden'); orb.removeAttribute('tabindex'); }
796
1204
  orb.setAttribute('data-rel', rel);
797
1205
  var b = orb.querySelector('.badge');
798
1206
  if (badge && !b) { b = document.createElement('span'); b.className = 'badge'; orb.insertBefore(b, orb.firstChild); }
@@ -804,11 +1212,18 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
804
1212
  }
805
1213
 
806
1214
  var shown = list.length + (selfShown ? 1 : 0);
807
- $('orbField').classList.toggle('isEmpty', shown === 0 && (onlyMine || !!q));
1215
+ /* a sky full of DIMMED orbs is not empty — the message would paint over
1216
+ them and eat their pointer events; it shows only when nothing renders */
1217
+ $('orbField').classList.toggle('isEmpty', shown === 0 && !dimmedOrbs.length && (onlyMine || !!q));
808
1218
  $('emptyMsg').textContent = onlyMine
809
1219
  ? 'You don’t own or build on anything here yet.'
810
1220
  : 'Nothing here matches that.';
811
1221
  $('mapCount').textContent = shown + (shown === 1 ? ' project' : ' projects');
1222
+
1223
+ /* every re-render replaces the orb nodes — the sim re-adopts them,
1224
+ carrying position + velocity for orbs that persisted (see the sky) */
1225
+ rebuildSkyBodies();
1226
+ startSky();
812
1227
  }
813
1228
 
814
1229
  /* ══ THE PROJECT CARD ══════════════════════════════════════════════════════
@@ -904,6 +1319,9 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
904
1319
  $('orbField').addEventListener('click', function (ev) {
905
1320
  var a = ev.target.closest && ev.target.closest('.worldOrb');
906
1321
  if (!a || !isPlainClick(ev)) return;
1322
+ /* a drag that ended on an orb is a pan, not a click (task 1002762) —
1323
+ and the link underneath must not fire either */
1324
+ if (skyPanned) { ev.preventDefault(); return; }
907
1325
  ev.preventDefault();
908
1326
  if (a.id === 'selfOrb') return openCard(SELF_PROJECT, selfRel(), a);
909
1327
  /* an owned orb's job is MANAGE (direction §5) — before the card path */
@@ -1049,11 +1467,439 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
1049
1467
  });
1050
1468
  });
1051
1469
 
1470
+ /* ══ THE LIVING SKY (task 1002762) ═════════════════════════════════════════
1471
+ The retired gate's cluster, rewritten for the hub. Physics is the gate's
1472
+ verified core (core-v1.19.93 space.js: softened Newtonian pairs, fixed-dt
1473
+ kick-drift-kick leapfrog, soft collisions, a KE band thermostat, per-step
1474
+ drift removal — stable over a 60-min headless run), re-tuned for this
1475
+ roster: the containment well's radius derives from the actual spread
1476
+ instead of the gate's 520px constant, and mass comes from disc size.
1477
+
1478
+ Layers: the CAMERA is one transform on #skyCam (pan + zoom-toward-cursor,
1479
+ the gate's exact algebra); each orb writes only its own physics delta over
1480
+ its slot anchor, so the two compose for free. The loop is C8-guarded —
1481
+ it stops DEAD off the map view, on the table sub-view, in a hidden tab —
1482
+ and never starts under prefers-reduced-motion (C9): reduced and no-JS
1483
+ both get the mock's static field. Zero requests anywhere in the loop. ══ */
1484
+
1485
+ var SKY_GRAV = {
1486
+ R_REF: 50, /* mass = (radius / R_REF)^3 — size IS gravity */
1487
+ G: 5200, /* px^3 / mass·s^2 */
1488
+ SOFTEN: 48, /* Plummer softening ~ a typical orb radius here */
1489
+ SWIRL: 0.09, /* initial solid-body swirl (rad/s) */
1490
+ SWIRL_JITTER: 0.15,
1491
+ REST: 0.92, /* near-elastic contacts (rare) */
1492
+ CONTAIN_K: 6.0, /* inward accel per px beyond the well */
1493
+ MAXV: 260, /* last-resort speed cap (px/s) */
1494
+ THERMO_LO: 0.55, THERMO_HI: 1.9, THERMO_MAX: 0.01,
1495
+ PHYS_HZ: 120
1496
+ };
1497
+ var skyBodies = [], keTarget = 0, containR = 460, containAspect = 1;
1498
+ var skyView = { x: 0, y: 0, z: 1 };
1499
+ var SKY_ZMIN = 0.55, SKY_ZMAX = 2.4;
1500
+ var SKY_DEADZONE = 5; /* px of movement before a press becomes a pan */
1501
+ var skyPanned = false; /* consumed by the delegated click handler */
1502
+ var skyPtr = { x: 0, y: 0 };
1503
+
1504
+ function skyBarycenter() {
1505
+ var M = 0, x = 0, y = 0;
1506
+ for (var i = 0; i < skyBodies.length; i++) { var b = skyBodies[i]; M += b.m; x += b.m * b.x; y += b.m * b.y; }
1507
+ return M > 0 ? { x: x / M, y: y / M } : { x: 0, y: 0 };
1508
+ }
1509
+ function skyKE() {
1510
+ var e = 0;
1511
+ for (var i = 0; i < skyBodies.length; i++) { var b = skyBodies[i]; e += 0.5 * b.m * (b.vx * b.vx + b.vy * b.vy); }
1512
+ return e;
1513
+ }
1514
+ function killSkyDrift() {
1515
+ var M = 0, px = 0, py = 0;
1516
+ for (var i = 0; i < skyBodies.length; i++) { var b = skyBodies[i]; M += b.m; px += b.m * b.vx; py += b.m * b.vy; }
1517
+ if (M <= 0) return;
1518
+ var dvx = px / M, dvy = py / M;
1519
+ for (var j = 0; j < skyBodies.length; j++) { skyBodies[j].vx -= dvx; skyBodies[j].vy -= dvy; }
1520
+ }
1521
+ function computeSkyForces(c) {
1522
+ var n = skyBodies.length, soft2 = SKY_GRAV.SOFTEN * SKY_GRAV.SOFTEN, i, j;
1523
+ for (i = 0; i < n; i++) { skyBodies[i].fx = 0; skyBodies[i].fy = 0; }
1524
+ for (i = 0; i < n; i++) {
1525
+ for (j = i + 1; j < n; j++) {
1526
+ var a = skyBodies[i], b = skyBodies[j];
1527
+ var dx = b.x - a.x, dy = b.y - a.y;
1528
+ var d2 = dx * dx + dy * dy + soft2;
1529
+ var f = SKY_GRAV.G / (d2 * Math.sqrt(d2));
1530
+ a.fx += f * b.m * dx; a.fy += f * b.m * dy;
1531
+ b.fx -= f * a.m * dx; b.fy -= f * a.m * dy;
1532
+ }
1533
+ }
1534
+ /* soft containment well — a gentle inward pull only beyond containR.
1535
+ ELLIPTICAL, matched to the field: the gate's circular well lived in an
1536
+ unbounded camera world, but this field clips, and it is much wider than
1537
+ it is tall — a circular well sized to the x-spread would let vertical
1538
+ orbits sail past the clipped edge. y-distance is scaled by the aspect,
1539
+ so the well's shape is the field's shape. */
1540
+ var a2 = containAspect * containAspect;
1541
+ for (i = 0; i < n; i++) {
1542
+ var it = skyBodies[i], ex = it.x - c.x, ey = it.y - c.y;
1543
+ var d = Math.hypot(ex, ey * containAspect);
1544
+ if (d > containR && d > 1e-6) {
1545
+ var k = -SKY_GRAV.CONTAIN_K * (d - containR) / d;
1546
+ it.fx += k * ex; it.fy += k * ey * a2;
1547
+ }
1548
+ }
1549
+ }
1550
+ function resolveSkyCollisions() {
1551
+ for (var i = 0; i < skyBodies.length; i++) {
1552
+ for (var j = i + 1; j < skyBodies.length; j++) {
1553
+ var a = skyBodies[i], b = skyBodies[j];
1554
+ var dx = b.x - a.x, dy = (b.y + b.discOff) - (a.y + a.discOff);
1555
+ var dist = Math.hypot(dx, dy) || 1e-6, min = a.r + b.r;
1556
+ if (dist >= min) continue;
1557
+ var nx = dx / dist, ny = dy / dist;
1558
+ var ima = 1 / a.m, imb = 1 / b.m;
1559
+ var corr = (min - dist) / (ima + imb);
1560
+ a.x -= nx * corr * ima; a.y -= ny * corr * ima;
1561
+ b.x += nx * corr * imb; b.y += ny * corr * imb;
1562
+ var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
1563
+ if (vn < 0) {
1564
+ var imp = -(1 + SKY_GRAV.REST) * vn / (ima + imb);
1565
+ a.vx -= nx * imp * ima; a.vy -= ny * imp * ima;
1566
+ b.vx += nx * imp * imb; b.vy += ny * imp * imb;
1567
+ }
1568
+ }
1569
+ }
1570
+ }
1571
+ function skyThermostat() {
1572
+ var lo = keTarget * SKY_GRAV.THERMO_LO, hi = keTarget * SKY_GRAV.THERMO_HI, ke = skyKE();
1573
+ if (ke < 1e-6) return;
1574
+ var target = ke < lo ? lo : (ke > hi ? hi : 0);
1575
+ if (!target) return; /* inside the band: pure physics */
1576
+ var f = Math.sqrt(target / ke);
1577
+ f = Math.max(1 - SKY_GRAV.THERMO_MAX, Math.min(1 + SKY_GRAV.THERMO_MAX, f));
1578
+ for (var i = 0; i < skyBodies.length; i++) { skyBodies[i].vx *= f; skyBodies[i].vy *= f; }
1579
+ }
1580
+ function clampSkyVelocities() {
1581
+ for (var i = 0; i < skyBodies.length; i++) {
1582
+ var b = skyBodies[i], v = Math.hypot(b.vx, b.vy);
1583
+ if (v > SKY_GRAV.MAXV) { var s = SKY_GRAV.MAXV / v; b.vx *= s; b.vy *= s; }
1584
+ }
1585
+ }
1586
+ function physStep(dt) {
1587
+ var i, it, c = skyBarycenter();
1588
+ for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.px = it.x; it.py = it.y; }
1589
+ computeSkyForces(c); /* leapfrog kick-drift-kick — the order is load-bearing */
1590
+ for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.vx += 0.5 * it.fx * dt; it.vy += 0.5 * it.fy * dt; }
1591
+ for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.x += it.vx * dt; it.y += it.vy * dt; }
1592
+ computeSkyForces(c);
1593
+ for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.vx += 0.5 * it.fx * dt; it.vy += 0.5 * it.fy * dt; }
1594
+ resolveSkyCollisions();
1595
+ skyThermostat();
1596
+ killSkyDrift();
1597
+ clampSkyVelocities();
1598
+ }
1599
+
1600
+ /* bodies re-adopt the DOM after every render: an orb that persisted keeps
1601
+ its position AND momentum across filter keystrokes (visual position is
1602
+ x/y itself — the anchor is only the subtraction base for the transform) */
1603
+ var skyNeedsRebuild = false;
1604
+ function rebuildSkyBodies() {
1605
+ var layer = $('skyCam'), fieldEl = $('orbField');
1606
+ if (!layer) { skyBodies = []; return; }
1607
+ /* display:none zeroes offsetLeft/Top AND clientWidth: anchoring to zeros
1608
+ paints every orb at slot + physics position on return, degrades the
1609
+ elliptical well to circular, and breaks the camera clamp (review
1610
+ CRITICAL — reachable from the Table view's own sort/search controls).
1611
+ Defer instead: the fresh orb nodes sit at their CSS slots — the static
1612
+ mock — until the sky is back on stage and the anchors are real. */
1613
+ if (!fieldEl || !fieldEl.clientWidth) { skyNeedsRebuild = true; return; }
1614
+ skyNeedsRebuild = false;
1615
+ var prev = {};
1616
+ skyBodies.forEach(function (b) { prev[b.key] = b; });
1617
+ var next = [];
1618
+ layer.querySelectorAll('.worldOrb').forEach(function (el) {
1619
+ if (el.hidden) return;
1620
+ var d = parseFloat(getComputedStyle(el).getPropertyValue('--d')) || 60;
1621
+ var r = d / 2;
1622
+ /* identity must survive re-sorts: an origin-less orb keys on its NAME,
1623
+ never its list index (an index key hands its momentum to whichever
1624
+ orb lands on that index after the next sort) */
1625
+ var nmEl = el.querySelector('.nm');
1626
+ var key = el.id === 'selfOrb' ? 'self'
1627
+ : (el.getAttribute('data-origin') || 'nm:' + (nmEl ? nmEl.textContent : String(next.length)));
1628
+ var ax = el.offsetLeft, ay = el.offsetTop;
1629
+ /* the body's anchor is the label-stack CENTER, but the disc — the thing
1630
+ that must not interpenetrate — sits above it; collisions measure from
1631
+ the disc's own center */
1632
+ var disc = el.querySelector('.disc');
1633
+ var discOff = disc ? (disc.offsetTop + r) - el.offsetHeight / 2 : 0;
1634
+ /* reduced motion IS the mock: no carry-over, every orb sits exactly on
1635
+ its slot (C9) — carrying frozen positions across a re-sort would
1636
+ leave zero-motion orbs parked off their own anchors forever */
1637
+ var old = REDUCED ? null : prev[key];
1638
+ next.push({
1639
+ key: key, el: el, r: r, ax: ax, ay: ay, discOff: discOff,
1640
+ m: Math.pow(r / SKY_GRAV.R_REF, 3),
1641
+ x: old ? old.x : ax, y: old ? old.y : ay,
1642
+ px: old ? old.x : ax, py: old ? old.y : ay,
1643
+ vx: old ? old.vx : 0, vy: old ? old.vy : 0,
1644
+ fx: 0, fy: 0, seeded: !!old
1645
+ });
1646
+ });
1647
+ skyBodies = next;
1648
+ /* new arrivals join the field swirl at their own position */
1649
+ var c = skyBarycenter();
1650
+ var jit = SKY_GRAV.SWIRL * 60 * SKY_GRAV.SWIRL_JITTER;
1651
+ skyBodies.forEach(function (b) {
1652
+ if (b.seeded) return;
1653
+ var dx = b.x - c.x, dy = b.y - c.y;
1654
+ b.vx = -dy * SKY_GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1655
+ b.vy = dx * SKY_GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1656
+ });
1657
+ killSkyDrift();
1658
+ keTarget = skyKE();
1659
+ /* the well hugs the roster: sized from the real spread in ASPECT-SCALED
1660
+ space (the gate's 520px assumed its own scatter; ours is the slot
1661
+ layout in THIS viewport, which is wide and short) */
1662
+ var fieldEl = $('orbField');
1663
+ containAspect = fieldEl
1664
+ ? Math.min(3, Math.max(1, fieldEl.clientWidth / Math.max(1, fieldEl.clientHeight)))
1665
+ : 1;
1666
+ var maxD = 0;
1667
+ skyBodies.forEach(function (b) {
1668
+ maxD = Math.max(maxD, Math.hypot(b.x - c.x, (b.y - c.y) * containAspect));
1669
+ });
1670
+ containR = Math.max(380, maxD * 1.15);
1671
+ /* the camera may still be aimed at a cluster that no longer exists */
1672
+ clampSkyCam();
1673
+ applySkyCam();
1674
+ renderSky(1);
1675
+ }
1676
+
1677
+ function renderSky(alpha) {
1678
+ var dpr = window.devicePixelRatio || 1;
1679
+ for (var k = 0; k < skyBodies.length; k++) {
1680
+ var b = skyBodies[k];
1681
+ var ix = b.px + (b.x - b.px) * alpha, iy = b.py + (b.y - b.py) * alpha;
1682
+ /* snap the offset to the device-pixel grid: motion stays smooth but
1683
+ glyphs rasterize identically each frame, so labels never shimmer */
1684
+ var tx = Math.round((ix - b.ax) * dpr) / dpr, ty = Math.round((iy - b.ay) * dpr) / dpr;
1685
+ b.el.style.transform = 'translate(-50%,-50%) translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px)';
1686
+ }
1687
+ followHover();
1688
+ }
1689
+
1690
+ /* the loop: fixed 120Hz physics under an accumulator, interpolated render.
1691
+ The 250ms frame clamp + 8-substep guard + drop-on-overrun keep a slow
1692
+ machine in slow motion rather than a spiral. */
1693
+ var SKY_DT = 1000 / SKY_GRAV.PHYS_HZ, skyAcc = 0, skyLast = 0, skyRaf = 0;
1694
+ function skyActive() {
1695
+ return !REDUCED && !document.hidden && currentView === 'map' &&
1696
+ !$('sub-sky-body').hidden && skyBodies.length > 1;
1697
+ }
1698
+ function skyTick(now) {
1699
+ skyRaf = 0;
1700
+ if (!skyActive()) return;
1701
+ var ft = now - skyLast; skyLast = now; if (ft > 250) ft = 250;
1702
+ skyAcc += ft;
1703
+ var guard = 0;
1704
+ while (skyAcc >= SKY_DT && guard < 8) { physStep(SKY_DT / 1000); skyAcc -= SKY_DT; guard++; }
1705
+ if (guard >= 8) skyAcc = 0;
1706
+ renderSky(Math.max(0, Math.min(1, skyAcc / SKY_DT)));
1707
+ skyRaf = requestAnimationFrame(skyTick);
1708
+ }
1709
+ function startSky() {
1710
+ if (skyRaf || !skyActive()) return;
1711
+ skyLast = performance.now(); skyAcc = 0;
1712
+ skyRaf = requestAnimationFrame(skyTick);
1713
+ }
1714
+ function stopSky() { if (skyRaf) { cancelAnimationFrame(skyRaf); skyRaf = 0; } }
1715
+ document.addEventListener('visibilitychange', function () { if (!document.hidden) startSky(); });
1716
+ window.addEventListener('resize', function () {
1717
+ clearTimeout(skyResizeT);
1718
+ skyResizeT = setTimeout(function () {
1719
+ /* percent anchors moved with the field — re-seed rather than let stale
1720
+ pixel anchors shear the cluster */
1721
+ skyBodies = [];
1722
+ rebuildSkyBodies();
1723
+ startSky();
1724
+ }, 200);
1725
+ });
1726
+ var skyResizeT = 0;
1727
+
1728
+ /* ── the camera: the gate's exact pan/zoom algebra on one layer ── */
1729
+ function applySkyCam() {
1730
+ var el = $('skyCam');
1731
+ if (el) el.style.transform = 'translate(' + skyView.x.toFixed(2) + 'px,' + skyView.y.toFixed(2) + 'px) scale(' + skyView.z.toFixed(4) + ')';
1732
+ }
1733
+ function clampSkyCam() {
1734
+ var wrap = $('sub-sky-body');
1735
+ if (!wrap || !skyBodies.length) return;
1736
+ var W = wrap.clientWidth, H = wrap.clientHeight, m = 160;
1737
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1738
+ skyBodies.forEach(function (b) {
1739
+ if (b.x < minX) minX = b.x; if (b.x > maxX) maxX = b.x;
1740
+ if (b.y < minY) minY = b.y; if (b.y > maxY) maxY = b.y;
1741
+ });
1742
+ var sxMin = skyView.x + minX * skyView.z, sxMax = skyView.x + maxX * skyView.z;
1743
+ var syMin = skyView.y + minY * skyView.z, syMax = skyView.y + maxY * skyView.z;
1744
+ if (sxMax < m) skyView.x += m - sxMax; else if (sxMin > W - m) skyView.x -= sxMin - (W - m);
1745
+ if (syMax < m) skyView.y += m - syMax; else if (syMin > H - m) skyView.y -= syMin - (H - m);
1746
+ }
1747
+ (function skyPanZoom() {
1748
+ var wrap = $('sub-sky-body');
1749
+ if (!wrap) return;
1750
+ var down = false, moved = false, middle = false, sx = 0, sy = 0, startX = 0, startY = 0;
1751
+ var panId = null; /* the ONE pointer that owns the drag — a second finger
1752
+ (pinch, a stray touch) must never hijack the camera */
1753
+ wrap.addEventListener('mousedown', function (e) { if (e.button === 1) e.preventDefault(); });
1754
+ wrap.addEventListener('pointerdown', function (e) {
1755
+ if (down) return;
1756
+ if (e.button !== 0 && e.button !== 1) return;
1757
+ /* middle-press on an orb belongs to the browser (open in a new tab);
1758
+ middle on empty sky is pan-anywhere, like the gate */
1759
+ middle = e.button === 1 && !(e.target.closest && e.target.closest('.worldOrb'));
1760
+ if (e.button === 1 && !middle) return;
1761
+ down = true; moved = false; skyPanned = false; panId = e.pointerId;
1762
+ sx = e.clientX; sy = e.clientY; startX = skyView.x; startY = skyView.y;
1763
+ if (middle) { moved = true; skyPanned = true; wrap.classList.add('panning'); hideHover(); e.preventDefault(); }
1764
+ });
1765
+ window.addEventListener('pointermove', function (e) {
1766
+ skyPtr.x = e.clientX; skyPtr.y = e.clientY;
1767
+ if (!down || (panId !== null && e.pointerId !== panId)) return;
1768
+ if (!moved && Math.hypot(e.clientX - sx, e.clientY - sy) < SKY_DEADZONE) return;
1769
+ if (!moved) { moved = true; skyPanned = true; wrap.classList.add('panning'); hideHover(); }
1770
+ skyView.x = startX + (e.clientX - sx);
1771
+ skyView.y = startY + (e.clientY - sy);
1772
+ clampSkyCam(); applySkyCam();
1773
+ });
1774
+ function endPan(e) {
1775
+ if (!down || (panId !== null && e && e.pointerId !== undefined && e.pointerId !== panId)) return;
1776
+ down = false; middle = false; panId = null; wrap.classList.remove('panning');
1777
+ /* the click this pan suppresses fires synchronously after pointerup —
1778
+ clear the flag a tick later, so a keyboard Enter is never eaten */
1779
+ setTimeout(function () { skyPanned = false; }, 0);
1780
+ }
1781
+ window.addEventListener('pointerup', endPan);
1782
+ window.addEventListener('pointercancel', endPan);
1783
+ wrap.addEventListener('wheel', function (e) {
1784
+ var d = e.deltaY;
1785
+ /* line-mode wheels (Firefox) report ~3 lines, not ~100 px */
1786
+ if (e.deltaMode === 1) d *= 16; else if (e.deltaMode === 2) d *= 300;
1787
+ var factor = Math.exp(-d * 0.0016);
1788
+ var z = Math.max(SKY_ZMIN, Math.min(SKY_ZMAX, skyView.z * factor));
1789
+ /* at the clamp the wheel would do nothing — hand it back to the page
1790
+ instead of trapping scroll over the sky */
1791
+ if (z === skyView.z) return;
1792
+ e.preventDefault();
1793
+ var rect = wrap.getBoundingClientRect();
1794
+ var cx = e.clientX - rect.left, cy = e.clientY - rect.top;
1795
+ /* un-project the cursor at the old zoom, re-pin it at the new — the
1796
+ world point under the wheel never moves */
1797
+ var vx = (cx - skyView.x) / skyView.z, vy = (cy - skyView.y) / skyView.z;
1798
+ skyView.z = z;
1799
+ skyView.x = cx - vx * z;
1800
+ skyView.y = cy - vy * z;
1801
+ clampSkyCam(); applySkyCam(); hideHover();
1802
+ }, { passive: false });
1803
+ })();
1804
+
1805
+ /* ── the hover card: a glance under the cursor, never a second chip ── */
1806
+ var hoverOrb = null, hoverFrames = 0;
1807
+ function showHover(a) {
1808
+ if (a === hoverOrb) return;
1809
+ hoverOrb = a;
1810
+ var card = $('hoverCard');
1811
+ if (!card) return;
1812
+ card.textContent = '';
1813
+ var nm = document.createElement('span');
1814
+ nm.className = 'hn';
1815
+ var n = a.querySelector('.nm');
1816
+ nm.textContent = n ? n.textContent : '';
1817
+ card.appendChild(nm);
1818
+ var tag = a.title || (a.querySelector('.sub') ? a.querySelector('.sub').textContent : '');
1819
+ if (tag && tag !== nm.textContent) {
1820
+ var ht = document.createElement('span');
1821
+ ht.className = 'ht';
1822
+ ht.textContent = tag;
1823
+ card.appendChild(ht);
1824
+ }
1825
+ card.hidden = false;
1826
+ placeHover();
1827
+ }
1828
+ function hideHover() {
1829
+ hoverOrb = null;
1830
+ var c = $('hoverCard');
1831
+ if (c) c.hidden = true;
1832
+ }
1833
+ function placeHover() {
1834
+ if (!hoverOrb) return;
1835
+ var card = $('hoverCard'), field = $('orbField');
1836
+ var fr = field.getBoundingClientRect(), or = hoverOrb.getBoundingClientRect();
1837
+ var x = or.left + or.width / 2 - fr.left, y = or.top - fr.top;
1838
+ card.style.left = Math.max(8, Math.min(fr.width - card.offsetWidth - 8, x - card.offsetWidth / 2)) + 'px';
1839
+ var top = y - card.offsetHeight - 10;
1840
+ card.style.top = (top < 8 ? y + or.height + 10 : top) + 'px';
1841
+ }
1842
+ function followHover() {
1843
+ if (!hoverOrb) return;
1844
+ placeHover();
1845
+ /* the sim can slide an orb out from under a stationary cursor and the
1846
+ browser never fires the leave — re-check what is under the pointer
1847
+ every few frames (a gate lesson, not optional polish) */
1848
+ if (++hoverFrames % 4 === 0) {
1849
+ var under = document.elementFromPoint(skyPtr.x, skyPtr.y);
1850
+ var overOrb = under && under.closest ? under.closest('.worldOrb') : null;
1851
+ if (overOrb !== hoverOrb) hideHover();
1852
+ }
1853
+ }
1854
+ $('orbField').addEventListener('pointerover', function (ev) {
1855
+ /* keep the pointer sample fresh from here too: a hover can arrive before
1856
+ any move (a touch tap, a page-load hover) and the frame check would
1857
+ otherwise measure a stale point and hide the card it just showed */
1858
+ skyPtr.x = ev.clientX; skyPtr.y = ev.clientY;
1859
+ var a = ev.target.closest && ev.target.closest('.worldOrb');
1860
+ if (a && !$('sub-sky-body').classList.contains('panning')) showHover(a);
1861
+ });
1862
+ $('orbField').addEventListener('pointerout', function (ev) {
1863
+ if (!hoverOrb) return;
1864
+ var to = ev.relatedTarget;
1865
+ if (!to || !(to.closest && to.closest('.worldOrb') === hoverOrb)) hideHover();
1866
+ });
1867
+
1868
+ /* a tapped Shift lights the search — the gate's spotlight key, kept: a tap
1869
+ with no other key while held focuses the query box in map view */
1870
+ (function shiftSpot() {
1871
+ var solo = false;
1872
+ document.addEventListener('keydown', function (e) {
1873
+ if (e.key === 'Shift') { solo = !e.repeat; return; }
1874
+ solo = false;
1875
+ });
1876
+ /* Shift used as a MOUSE modifier is not a tap: a shift-click or
1877
+ shift-drag between the keydown and keyup must not light the search */
1878
+ document.addEventListener('pointerdown', function () { solo = false; });
1879
+ document.addEventListener('wheel', function () { solo = false; }, { passive: true });
1880
+ document.addEventListener('keyup', function (e) {
1881
+ if (e.key !== 'Shift' || !solo) return;
1882
+ solo = false;
1883
+ /* never steal focus out of the open project card (it is aria-modal) */
1884
+ if (currentView !== 'map' || !$('cardScrim').hidden) return;
1885
+ var t = document.activeElement;
1886
+ if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return;
1887
+ var q = $('mapQ');
1888
+ if (q) q.focus();
1889
+ });
1890
+ })();
1891
+
1052
1892
  function setSub(which) {
1053
1893
  $('sub-sky').setAttribute('aria-pressed', String(which === 'sky'));
1054
1894
  $('sub-table').setAttribute('aria-pressed', String(which === 'table'));
1055
1895
  $('sub-sky-body').hidden = which !== 'sky';
1056
1896
  $('sub-table-body').hidden = which !== 'table';
1897
+ /* the sim runs only while the sky is on stage (C8); anchors that were
1898
+ unreadable while hidden are re-adopted before the first frame */
1899
+ if (which === 'sky') {
1900
+ if (skyNeedsRebuild) rebuildSkyBodies();
1901
+ startSky();
1902
+ } else { stopSky(); hideHover(); }
1057
1903
  }
1058
1904
  $('sub-sky').addEventListener('click', function () { setSub('sky'); });
1059
1905
  $('sub-table').addEventListener('click', function () { setSub('table'); });
@@ -1071,14 +1917,984 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
1071
1917
  $('mapStars').appendChild(frag);
1072
1918
  })();
1073
1919
 
1074
- /* ══ NEW PROJECT — step 1 of the four-beat rail. Naming it here hands into
1075
- the wizard with the name already seeded; /create?name= is the entry point
1076
- the wizard already reads, so nothing about the flow changes. ══ */
1077
- $('newForm').addEventListener('submit', function (ev) {
1078
- ev.preventDefault();
1079
- var name = String($('newName').value || '').trim();
1080
- location.assign('/create' + (name ? '?name=' + encodeURIComponent(name) : ''));
1081
- });
1920
+ /* ══ THE CREATE WIZARD (task 1002763) — absorbed whole from /create, which
1921
+ now 302s here. An inner scope so its ~40 helpers stay out of the hub's
1922
+ namespace; it shares the hub's $, esc, API and `me`, so there is one
1923
+ GET /me, one mode toggle and one identity chip on the page. The hub talks
1924
+ to it through exactly three doors: enter(focus) / leave() from setView —
1925
+ the same start/stop contract the sky obeys (C8) — and onAuth() from the
1926
+ boot's identity settle. Same trust boundary as ever: every call here only
1927
+ ENQUEUES; the control-plane runner does the real infra work (ADR 0111). ══ */
1928
+ var wizard = (function () {
1929
+ var root = $('view-new');
1930
+
1931
+ /* ── state ── */
1932
+ var DRAFT_KEY = 'cb-create-draft';
1933
+ var CREATED_KEY = 'cb-created-instance';
1934
+ var state = { step: 0, name: '', mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
1935
+ try { Object.assign(state, JSON.parse(sessionStorage.getItem(DRAFT_KEY) || '{}')); } catch (e) {}
1936
+ var authKnown = false; /* flips true when the boot's /me settles, either way */
1937
+
1938
+ /* A name handed over by URL (the /create?name= redirect still carries it,
1939
+ task 2087→1002763): seed the draft BEFORE boot's setView rewrites the
1940
+ URL to its bare '?view=' form, so the seed survives the GitHub sign-in
1941
+ round-trip and a later reload can't re-seed over the visitor's edits. */
1942
+ try {
1943
+ var handedName = new URLSearchParams(location.search).get('name');
1944
+ if (handedName && handedName.trim()) {
1945
+ state.name = handedName.trim().slice(0, 80);
1946
+ state.slug = deriveSlug(state.name); state.slugTouched = false;
1947
+ state.repo = deriveRepo(state.name); state.repoTouched = false;
1948
+ state.step = state.step >= 1 && state.step <= 4 ? state.step : 1;
1949
+ try { sessionStorage.setItem(DRAFT_KEY, JSON.stringify(state)); } catch (e) {}
1950
+ }
1951
+ } catch (e) {}
1952
+
1953
+ /* Mirror provisioning.isValidSlug (DNS/db-safe): 2–40 of [a-z0-9-], starts+ends
1954
+ alnum, no double dash. Client-side pre-check; the server re-validates. */
1955
+ function validSlug(s) {
1956
+ var v = String(s || '');
1957
+ return v.length >= 2 && v.length <= 40 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(v) && v.indexOf('--') === -1;
1958
+ }
1959
+ function slugify(name, maxLen) {
1960
+ return String(name || '').toLowerCase()
1961
+ .replace(/['’]/g, '')
1962
+ .replace(/[^a-z0-9]+/g, '-')
1963
+ .replace(/-+/g, '-')
1964
+ .replace(/^-|-$/g, '')
1965
+ .slice(0, maxLen);
1966
+ }
1967
+ function deriveSlug(name) { return slugify(name, 40); }
1968
+ /* Repo-name default from the project name — the same slug-safe transform (GitHub
1969
+ repo names accept [A-Za-z0-9._-], so a lowercase-dashed name is always valid and
1970
+ matches the placeholder). Wider cap than the handle; the visitor can still edit. */
1971
+ function deriveRepo(name) { return slugify(name, 100); }
1972
+ function looksLikeDomain(d) {
1973
+ return /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/i.test(String(d || ''));
1974
+ }
1975
+ /* ── the assigned address (task 1002704): every project gets <slug>.<apex> up
1976
+ front, derived from the host this wizard runs on — the platform apex assigns
1977
+ slug.<apex>; a self-hosted apex assigns under its own name. On a
1978
+ dotless/local host (localhost, an IP) there is nothing sane to assign, so
1979
+ the step degrades to the old optional-domain form. ── */
1980
+ function apexBase() {
1981
+ var h = String(location.hostname || '').toLowerCase().replace(/^www\./, '');
1982
+ if (!h || h.indexOf('.') < 0) return null;
1983
+ if (/^(localhost|127\.|\d+\.\d+\.\d+\.\d+$|\[)/.test(h)) return null;
1984
+ return h;
1985
+ }
1986
+ function assignedDomain() {
1987
+ var base = apexBase();
1988
+ return base && validSlug(state.slug) ? state.slug + '.' + base : null;
1989
+ }
1990
+ /* What the create actually files: a custom domain wins; opting out files none;
1991
+ otherwise the assigned subdomain. Everything downstream (review, submit,
1992
+ the done-panel steps) reads THIS, never state.domain directly. */
1993
+ function resolvedDomain() {
1994
+ if (state.domain) return state.domain;
1995
+ if (state.noAddress) return '';
1996
+ return assignedDomain() || '';
1997
+ }
1998
+ function renderAddrPanel() {
1999
+ var box = $('addrConfirm');
2000
+ if (!box) return;
2001
+ var assigned = assignedDomain();
2002
+ var custom = state.domain && looksLikeDomain(state.domain) ? state.domain : '';
2003
+ box.classList.toggle('optedOut', state.noAddress && !custom);
2004
+ var big = $('addrAssigned');
2005
+ var note = $('addrAssignedNote');
2006
+ if (custom) {
2007
+ big.innerHTML = '<code>' + esc(custom) + '</code> — your own domain, wired up for you.';
2008
+ note.textContent = 'DNS pointing happens after the standup; the card on your projects page walks you through it.';
2009
+ } else if (state.noAddress) {
2010
+ big.textContent = 'Starting without an address.';
2011
+ note.textContent = 'Sign-in on your project can’t be set up until it has one — add it any time from your projects page.';
2012
+ } else if (assigned) {
2013
+ big.innerHTML = '<code>' + esc(assigned) + '</code> — yours from the moment it stands up.';
2014
+ note.textContent = 'Sign-in, sharing, everything just works. You can point a domain you own at it any time.';
2015
+ } else if (!validSlug(state.slug)) {
2016
+ big.textContent = 'Pick a handle above and the address comes with it.';
2017
+ note.textContent = '';
2018
+ } else {
2019
+ /* dotless host (local dev): nothing sane to auto-assign — the domain
2020
+ field IS the step here, so unfold it (a folded disclosure made the
2021
+ opt-out checkbox unreachable-by-default; owner walk, 2026-08-12) */
2022
+ big.textContent = 'No address is assigned on this host.';
2023
+ note.textContent = 'Add a domain you own below, or start without one.';
2024
+ $('addrCustom').open = true;
2025
+ }
2026
+ }
2027
+ /* Pinned GitHub OAuth-app values for a domain (auth.js WEB_REDIRECT_ORIGIN —
2028
+ callback is the apex /api/gds/auth/web/callback). Mirrors onboard.oauthAppInstructions. */
2029
+ function oauthAppValues(domain) {
2030
+ var origin = 'https://' + domain;
2031
+ return { homepageUrl: origin, callbackUrl: origin + '/api/gds/auth/web/callback' };
2032
+ }
2033
+ /* GitHub's own "New repository" page, pre-filled with the owner + name. No auth or
2034
+ permissions on our side — the visitor creates the empty repo in GitHub's UI, which
2035
+ is exactly what the scaffold then clones. `owner` selects the account/org; omitting
2036
+ a blank param just leaves that field empty on GitHub. */
2037
+ function githubNewRepoUrl(owner, repo) {
2038
+ var u = new URL('https://github.com/new');
2039
+ if (repo) u.searchParams.set('name', repo);
2040
+ if (owner) u.searchParams.set('owner', owner);
2041
+ return u.toString();
2042
+ }
2043
+ function saveDraft() {
2044
+ try { sessionStorage.setItem(DRAFT_KEY, JSON.stringify(state)); } catch (e) {}
2045
+ }
2046
+
2047
+ /* ── step navigation ── */
2048
+ var panels = { 0: 'panel-gate', 1: 'panel-name', 2: 'panel-home', 3: 'panel-address', 4: 'panel-review', 5: 'panel-done' };
2049
+ function show(step, moveFocus) {
2050
+ state.step = step;
2051
+ Object.keys(panels).forEach(function (k) {
2052
+ $(panels[k]).classList.toggle('on', Number(k) === step);
2053
+ });
2054
+ root.querySelectorAll('.steps li').forEach(function (li) {
2055
+ var n = Number(li.getAttribute('data-step'));
2056
+ li.classList.toggle('now', n === step);
2057
+ li.classList.toggle('done', n < step || step === 5);
2058
+ });
2059
+ saveDraft();
2060
+ /* the done step takes the whole card — the bongo pair steps aside */
2061
+ root.querySelector('.createSplit').classList.toggle('wizDone', step === 5);
2062
+ /* focus moves ONLY on a deliberate step (the hub rule setView already
2063
+ applies to this view: never on boot, never on a background settle) */
2064
+ if (moveFocus) {
2065
+ var h = $(panels[step]).querySelector('h1');
2066
+ if (h) { h.setAttribute('tabindex', '-1'); h.focus({ preventScroll: false }); }
2067
+ }
2068
+ /* the repo probe is authenticated — never fire it before /me settles
2069
+ (a pre-auth probe 401s for strangers and, worse, could seed the
2070
+ draft's owner with myLogin()'s placeholder) */
2071
+ if (step === 2) { syncHome(); if (authKnown) ensureRepoAccess(); }
2072
+ if (step === 3) renderAddrPanel();
2073
+ if (step === 4) renderReview();
2074
+ }
2075
+
2076
+ /* ── field wiring ── */
2077
+ $('newName').value = state.name;
2078
+ $('f-owner').value = state.owner;
2079
+ $('f-repo').value = state.repo;
2080
+ $('f-slug').value = state.slug;
2081
+ $('f-domain').value = state.domain;
2082
+ $('f-noaddr').checked = !!state.noAddress;
2083
+
2084
+ /* Auto-fill the repo name from the project name until the visitor edits it
2085
+ directly (mirrors the handle's auto-derive). The owner is filled once we know
2086
+ who's signed in — see onAuth below. */
2087
+ if (!state.repoTouched && state.name && !state.repo) {
2088
+ state.repo = deriveRepo(state.name);
2089
+ $('f-repo').value = state.repo;
2090
+ }
2091
+
2092
+ $('newName').addEventListener('input', function () {
2093
+ state.name = this.value;
2094
+ if (!state.slugTouched) {
2095
+ state.slug = deriveSlug(state.name);
2096
+ $('f-slug').value = state.slug;
2097
+ }
2098
+ if (!state.repoTouched) {
2099
+ state.repo = deriveRepo(state.name);
2100
+ $('f-repo').value = state.repo;
2101
+ }
2102
+ var h = $('h-name');
2103
+ if (state.slug) { h.innerHTML = 'its handle on the platform: <code>' + esc(state.slug) + '</code>'; h.className = 'hint'; }
2104
+ else { h.innerHTML = '&nbsp;'; }
2105
+ saveDraft();
2106
+ });
2107
+ if (state.name && state.slug) $('h-name').innerHTML = 'its handle on the platform: <code>' + esc(state.slug) + '</code>';
2108
+
2109
+ function setMode(mode) {
2110
+ state.mode = mode;
2111
+ $('c-green').setAttribute('aria-pressed', String(mode === 'greenfield'));
2112
+ $('c-adopt').setAttribute('aria-pressed', String(mode === 'adopt'));
2113
+ $('adoptNote').classList.toggle('on', mode === 'adopt');
2114
+ saveDraft();
2115
+ syncHome();
2116
+ }
2117
+ $('c-green').addEventListener('click', function () { setMode('greenfield'); });
2118
+ $('c-adopt').addEventListener('click', function () { setMode('adopt'); });
2119
+ setMode(state.mode);
2120
+
2121
+ /* ── GitHub-integrated repo picker (adopt) + auto-create (fresh) — task 1002099 / ADR 0143.
2122
+ Progressive enhancement: probe GET /github/repos once we reach step 2.
2123
+ 200 → we hold repo access → show the picker (adopt) / name field (fresh);
2124
+ 403 → offer to grant repo access (a ?repos=1 re-sign-in);
2125
+ 503 (server can't store the token) or any error → the manual inputs.
2126
+ #manualHome (the classic owner/repo fields) is the always-safe fallback. ── */
2127
+ var repoAccess = null; /* null=unknown/loading · true · 'need-connect' · 'unavailable' · 'manual' */
2128
+ var repoList = []; /* the user's repos (from the probe) — feeds the picker + the fresh-name clash hint */
2129
+ var ghProbed = false;
2130
+
2131
+ function isManualHome() { return repoAccess === 'manual' || repoAccess === 'unavailable'; }
2132
+ function validRepoName(s) { return /^[A-Za-z0-9._-]{1,100}$/.test(String(s || '')); }
2133
+ function myLogin() { return (me && me.github_login) || 'you'; }
2134
+
2135
+ function connectRepos(e) {
2136
+ if (e) e.preventDefault();
2137
+ /* Same-tab navigation so the sessionStorage draft round-trips back to step 2. */
2138
+ window.location.assign(API + '/auth/web/start?return=/projects%3Fview%3Dnew&repos=1');
2139
+ }
2140
+ $('ghConnectBtn').addEventListener('click', connectRepos);
2141
+ $('ghManualToggle').addEventListener('click', function () { repoAccess = 'manual'; syncHome(); $('f-owner').focus(); });
2142
+
2143
+ function syncHome() {
2144
+ var need = repoAccess === 'need-connect';
2145
+ var ok = repoAccess === true;
2146
+ var adopt = state.mode === 'adopt';
2147
+ $('ghBusy').hidden = !!repoAccess; /* busy only while still probing (null) */
2148
+ $('ghConnect').classList.toggle('on', need); /* .reassure box: shown via .on (not hidden — .reassure sets display:none) */
2149
+ $('ghAdopt').hidden = !(ok && adopt);
2150
+ $('ghFresh').hidden = !(ok && !adopt);
2151
+ $('manualHome').hidden = !isManualHome();
2152
+ if (need) {
2153
+ /* Informed consent (ADR 0155): this grants `repo`, which GitHub's own screen
2154
+ words as full control of ALL your repositories — so say plainly why we ask
2155
+ and what we do with it, rather than letting GitHub's wording be the first
2156
+ the user hears of it. Private repos are the reason the scope is this wide. */
2157
+ $('ghConnectMsg').textContent = adopt
2158
+ ? 'You’re signed in — creating your project needs one more GitHub permission. GitHub will ask for repository access (it words this as access to all your repositories — that is the only scope it offers that can write to a private repo). We use it to read the repo you pick and push the setup commit to it. Nothing else is touched.'
2159
+ : 'You’re signed in — creating your project needs one more GitHub permission. GitHub will ask for repository access (it words this as access to all your repositories — the narrower option cannot write to private repos). We use it to create your new empty repo and push the setup commit to it. Nothing else is touched.';
2160
+ }
2161
+ if (ok && adopt) initAdoptPicker();
2162
+ if (ok && !adopt) syncFresh();
2163
+ }
2164
+
2165
+ function syncFresh() {
2166
+ /* Fresh + access: the repo is created under the signed-in account. Seed owner for
2167
+ the review/target now; the create response confirms the real full_name.
2168
+ Never before identity settles — myLogin()'s 'you' is display copy, and a
2169
+ draft that stored it would file target_ref "you/<repo>". */
2170
+ if (!me) return;
2171
+ state.owner = myLogin();
2172
+ $('ghNewName').value = state.repo || '';
2173
+ updateFreshPreview();
2174
+ }
2175
+ function updateFreshPreview() {
2176
+ var hint = $('ghFreshHint');
2177
+ var clash = state.repo && repoList.some(function (r) { return String(r.name).toLowerCase() === state.repo.toLowerCase(); });
2178
+ if (state.repo && !validRepoName(state.repo)) {
2179
+ hint.className = 'hint err'; hint.textContent = 'Use letters, numbers, and . _ - (1–100 chars).';
2180
+ } else if (clash) {
2181
+ hint.className = 'hint err'; hint.innerHTML = 'You already have a repo named <strong>' + esc(state.repo) + '</strong> — pick another name, or adopt it instead.';
2182
+ } else {
2183
+ /* Visibility stated up front (task 1002694 / idea 1000684): repos WE create are
2184
+ public by design (github-repos.js — open-source, capture-resistant); the
2185
+ private path is adopting a private repo the owner makes themselves. */
2186
+ hint.className = 'hint'; hint.innerHTML = 'We’ll create an empty <strong>public</strong> repo at <code style="font-family:var(--font-mono)">github.com/' + esc(myLogin()) + '/' + esc(state.repo || 'name') + '</code> — projects here are open by design. Want private code? Create the repo yourself on GitHub and “adopt” it instead.';
2187
+ }
2188
+ }
2189
+ $('ghNewName').addEventListener('input', function () { state.repo = this.value.trim(); updateFreshPreview(); saveDraft(); });
2190
+
2191
+ /* The adopt picker behaves like a standard combobox (owner design review, task
2192
+ 1002247): the dropdown is open only while the visitor is choosing. Selecting a
2193
+ repo fills the search box with its full_name and collapses the list; focusing or
2194
+ typing reopens it. */
2195
+ function openRepoList() { $('ghList').hidden = false; renderRepoList(); }
2196
+ function closeRepoList() { $('ghList').hidden = true; }
2197
+ function renderRepoList() {
2198
+ var q = ($('ghSearch').value || '').trim().toLowerCase();
2199
+ var sel = state.owner && state.repo ? (state.owner + '/' + state.repo) : '';
2200
+ /* When the box just shows the current selection verbatim, list everything (so the
2201
+ visitor can switch) instead of filtering down to the one selected repo. */
2202
+ var effectiveQ = q === sel.toLowerCase() ? '' : q;
2203
+ var rows = repoList.filter(function (r) { return !effectiveQ || String(r.full_name).toLowerCase().indexOf(effectiveQ) >= 0; }).slice(0, 100);
2204
+ $('ghList').innerHTML = rows.length
2205
+ ? rows.map(function (r) {
2206
+ return '<button type="button" class="ghrow" role="option" data-full="' + esc(r.full_name) + '" aria-selected="' + (r.full_name === sel) + '">' +
2207
+ '<span>' + esc(r.full_name) + '</span>' + (r.private ? '<span class="priv">Private</span>' : '') + '</button>';
2208
+ }).join('')
2209
+ : '<div class="hint" style="padding:10px 12px">No repositories match “' + esc(effectiveQ) + '”.</div>';
2210
+ }
2211
+ /* Fill the search box with the current selection (if any) and collapse the list;
2212
+ with nothing chosen yet, open it so the visitor sees their repos right away. */
2213
+ function initAdoptPicker() {
2214
+ var sel = state.owner && state.repo ? state.owner + '/' + state.repo : '';
2215
+ /* Only treat it as a confirmed pick if it's actually one of the visitor's repos.
2216
+ owner/repo are auto-derived from the project name, so a bare derived value must
2217
+ NOT masquerade as a chosen repo — that would collapse the list onto a repo that
2218
+ may not even exist. */
2219
+ var picked = sel && repoList.some(function (r) { return String(r.full_name).toLowerCase() === sel.toLowerCase(); });
2220
+ var h = $('ghAdoptHint');
2221
+ if (picked) {
2222
+ $('ghSearch').value = sel;
2223
+ h.className = 'hint ok'; h.innerHTML = 'Adopting <strong>' + esc(sel) + '</strong>.';
2224
+ closeRepoList();
2225
+ } else {
2226
+ $('ghSearch').value = '';
2227
+ h.className = 'hint'; h.innerHTML = '&nbsp;';
2228
+ openRepoList();
2229
+ }
2230
+ }
2231
+ $('ghSearch').addEventListener('focus', function () { this.select(); openRepoList(); });
2232
+ $('ghSearch').addEventListener('input', openRepoList);
2233
+ $('ghList').addEventListener('click', function (ev) {
2234
+ var row = ev.target.closest('.ghrow'); if (!row) return;
2235
+ var full = row.getAttribute('data-full'); var slash = full.indexOf('/');
2236
+ state.owner = full.slice(0, slash); state.repo = full.slice(slash + 1);
2237
+ state.ownerTouched = true; state.repoTouched = true; /* a real pick — don't let name-derive clobber it */
2238
+ saveDraft();
2239
+ $('ghSearch').value = full; /* pull the choice into the search box … */
2240
+ var h = $('ghAdoptHint'); h.className = 'hint ok'; h.innerHTML = 'Adopting <strong>' + esc(full) + '</strong>.';
2241
+ closeRepoList(); /* … and collapse the dropdown (typical UX) */
2242
+ });
2243
+
2244
+ function ensureRepoAccess() {
2245
+ if (ghProbed && repoAccess !== null) return; /* already known this session */
2246
+ if (isManualHome()) return; /* user chose manual — don't nag */
2247
+ repoAccess = null; syncHome(); /* show "reading your repos…" */
2248
+ fetch(API + '/github/repos', { credentials: 'same-origin' })
2249
+ .then(function (res) {
2250
+ ghProbed = true;
2251
+ if (res.status === 200) return res.json().then(function (d) { repoList = (d && d.repos) || []; repoAccess = true; });
2252
+ if (res.status === 403 || res.status === 401) { repoAccess = 'need-connect'; return; }
2253
+ repoAccess = 'unavailable'; return; /* 503 or anything else → manual fallback */
2254
+ })
2255
+ .catch(function () { ghProbed = true; repoAccess = 'unavailable'; })
2256
+ .then(syncHome);
2257
+ }
2258
+
2259
+ $('f-owner').addEventListener('input', function () { state.owner = this.value.trim(); state.ownerTouched = true; saveDraft(); });
2260
+ $('f-repo').addEventListener('input', function () { state.repo = this.value.trim(); state.repoTouched = true; saveDraft(); });
2261
+
2262
+ $('f-slug').addEventListener('input', function () {
2263
+ state.slugTouched = true;
2264
+ state.slug = this.value.trim().toLowerCase();
2265
+ this.value = state.slug;
2266
+ var h = $('h-slug');
2267
+ if (!state.slug) { h.innerHTML = '&nbsp;'; h.className = 'hint'; this.classList.remove('bad'); }
2268
+ else if (validSlug(state.slug)) { h.textContent = 'Nice — that works.'; h.className = 'hint ok'; this.classList.remove('bad'); }
2269
+ else { h.textContent = '2–40 lowercase letters, numbers, or dashes; no leading/trailing/double dash.'; h.className = 'hint err'; this.classList.add('bad'); }
2270
+ renderAddrPanel();
2271
+ saveDraft();
2272
+ });
2273
+ $('f-domain').addEventListener('input', function () {
2274
+ state.domain = this.value.trim().toLowerCase();
2275
+ var h = $('h-domain');
2276
+ if (!state.domain) { h.innerHTML = '&nbsp;'; h.className = 'hint'; this.classList.remove('bad'); }
2277
+ else if (looksLikeDomain(state.domain)) { h.textContent = 'Looks like a domain.'; h.className = 'hint ok'; this.classList.remove('bad'); }
2278
+ else { h.textContent = 'That doesn’t look like a domain yet (e.g. mercury.example.com).'; h.className = 'hint err'; this.classList.add('bad'); }
2279
+ renderAddrPanel();
2280
+ saveDraft();
2281
+ });
2282
+ $('f-noaddr').addEventListener('change', function () {
2283
+ state.noAddress = !!this.checked;
2284
+ renderAddrPanel();
2285
+ saveDraft();
2286
+ });
2287
+
2288
+ /* per-panel validation on Next */
2289
+ function validateStep(step) {
2290
+ if (step === 1) {
2291
+ if (!state.name.trim()) { $('h-name').textContent = 'Give it a name to start.'; $('h-name').className = 'hint err'; return false; }
2292
+ return true;
2293
+ }
2294
+ if (step === 2) {
2295
+ var homeErr = function (msg) { $('h-home').textContent = msg; $('h-home').className = 'hint err'; return false; };
2296
+ if (repoAccess === 'need-connect') return homeErr('Allow GitHub repo access to pick or create your repository — or choose “enter it manually”.');
2297
+ if (repoAccess === true && state.mode !== 'adopt') {
2298
+ /* Fresh with access: just the new repo NAME (owner is the signed-in account). */
2299
+ if (!validRepoName(state.repo)) return homeErr('Name the new repository (letters, numbers, and . _ -).');
2300
+ } else {
2301
+ /* Adopt (picker or manual) and the manual fallback: need a concrete owner + repo. */
2302
+ if (!state.owner || !state.repo) {
2303
+ return homeErr(state.mode === 'adopt' && repoAccess === true ? 'Pick one of your repositories.' : 'Both owner and repository name are needed.');
2304
+ }
2305
+ }
2306
+ $('h-home').innerHTML = '&nbsp;'; $('h-home').className = 'hint';
2307
+ return true;
2308
+ }
2309
+ if (step === 3) {
2310
+ if (!validSlug(state.slug)) {
2311
+ $('h-slug').textContent = '2–40 lowercase letters, numbers, or dashes; no leading/trailing/double dash.';
2312
+ $('h-slug').className = 'hint err'; $('f-slug').classList.add('bad');
2313
+ return false;
2314
+ }
2315
+ if (state.domain && !looksLikeDomain(state.domain)) return false;
2316
+ /* no auto-assigned address (dotless host): going domainless must be an
2317
+ EXPLICIT choice — a domain, or the opt-out box. Never silent. */
2318
+ if (!resolvedDomain() && !state.noAddress) {
2319
+ $('addrCustom').open = true;
2320
+ var hd = $('h-domain');
2321
+ hd.textContent = 'Add a domain you own — or tick “start without an address” to go without one.';
2322
+ hd.className = 'hint err';
2323
+ return false;
2324
+ }
2325
+ return true;
2326
+ }
2327
+ return true;
2328
+ }
2329
+
2330
+ root.querySelectorAll('.panel form').forEach(function (form) {
2331
+ form.addEventListener('submit', function (ev) {
2332
+ ev.preventDefault();
2333
+ var next = Number(form.getAttribute('data-next'));
2334
+ if (validateStep(next - 1)) show(next, true);
2335
+ });
2336
+ });
2337
+ root.querySelectorAll('[data-back]').forEach(function (btn) {
2338
+ btn.addEventListener('click', function () { show(Number(btn.getAttribute('data-back')), true); });
2339
+ });
2340
+
2341
+ /* ── review ── */
2342
+ function renderReview() {
2343
+ /* every road back to this step gets a live button — the create path
2344
+ disables it and only the failure branch used to restore it, which
2345
+ left "Start another project" walking into a dead Create */
2346
+ var cb = $('doCreate');
2347
+ cb.disabled = false; cb.textContent = 'Create my project';
2348
+ var rows = [
2349
+ ['Project', esc(state.name)],
2350
+ ['Starting from', state.mode === 'adopt' ? 'A repo you already have' : 'A fresh, empty repo'],
2351
+ ['Repository', '<code>' + esc(state.owner + '/' + state.repo) + '</code>'],
2352
+ ['Handle', '<code>' + esc(state.slug) + '</code>'],
2353
+ ['Address', resolvedDomain()
2354
+ ? '<code>' + esc(resolvedDomain()) + '</code>'
2355
+ : '<em style="font-weight:600;color:var(--warn)">none — sign-in stays locked until it has one</em>'],
2356
+ ['Shape', 'Standalone — your own repo, pinned platform core']
2357
+ ];
2358
+ $('reviewList').innerHTML = rows.map(function (r) {
2359
+ return '<li><span class="k">' + r[0] + '</span><span class="v">' + r[1] + '</span></li>';
2360
+ }).join('');
2361
+ }
2362
+
2363
+ /* ── create ── */
2364
+ function friendlyCreateError(err) {
2365
+ var code = err && err.data && err.data.error && (err.data.error.code || '');
2366
+ if (code === 'slug_taken') return 'That handle is already taken by another project. Pick a different one.';
2367
+ if (code === 'bad_slug') return 'The handle didn’t pass the server’s checks — 2–40 lowercase letters, numbers, or dashes.';
2368
+ return 'Couldn’t file the request: ' + (err && err.message ? err.message : 'something went sideways. Try again.');
2369
+ }
2370
+
2371
+ /* Fresh + repo access: auto-create the empty GitHub repo NOW (the commit moment),
2372
+ then hand its real full_name to provisioning. No-op for adopt / manual / no-access.
2373
+ Idempotent across a retry — once created we reuse the stashed full_name so a second
2374
+ click doesn't trip GitHub's 422 "name already exists". Errors set err.gotoHome so
2375
+ the caller bounces the user back to the Home step to fix the name / reconnect. */
2376
+ function ensureFreshRepo() {
2377
+ if (state.mode === 'adopt' || repoAccess !== true) return Promise.resolve();
2378
+ if (state.repoAutoCreated && state.owner && state.repo) return Promise.resolve();
2379
+ return fetch(API + '/github/repos', {
2380
+ method: 'POST', credentials: 'same-origin',
2381
+ headers: { 'Content-Type': 'application/json' },
2382
+ body: JSON.stringify({ name: state.repo })
2383
+ }).then(function (res) {
2384
+ return res.json().catch(function () { return {}; }).then(function (data) {
2385
+ if (res.status === 401 || res.status === 403) {
2386
+ repoAccess = 'need-connect';
2387
+ var e0 = new Error('reconnect'); e0.gotoHome = true;
2388
+ e0.homeMsg = 'Your GitHub authorization expired — reconnect on the Home step to create the repo.';
2389
+ throw e0;
2390
+ }
2391
+ if (!res.ok) {
2392
+ var e = new Error((data.error && (data.error.message || data.error.code)) || ('HTTP ' + res.status));
2393
+ e.data = data;
2394
+ if (data.error && data.error.code === 'name_taken') { e.gotoHome = true; e.homeMsg = data.error.message; }
2395
+ throw e;
2396
+ }
2397
+ var full = data.repo && data.repo.full_name;
2398
+ if (full && full.indexOf('/') > 0) {
2399
+ state.owner = full.slice(0, full.indexOf('/'));
2400
+ state.repo = full.slice(full.indexOf('/') + 1);
2401
+ }
2402
+ state.repoAutoCreated = true; saveDraft();
2403
+ });
2404
+ });
2405
+ }
2406
+
2407
+ $('doCreate').addEventListener('click', function () {
2408
+ var btn = this;
2409
+ var alertBox = $('createAlert');
2410
+ alertBox.classList.remove('on');
2411
+ btn.disabled = true; btn.textContent = 'Raising it into the sky…';
2412
+ ensureFreshRepo().then(function () {
2413
+ return fetch(API + '/provisioning/instances', {
2414
+ method: 'POST', credentials: 'same-origin',
2415
+ headers: { 'Content-Type': 'application/json' },
2416
+ body: JSON.stringify({
2417
+ slug: state.slug,
2418
+ hosting_shape: 'standalone',
2419
+ domain: resolvedDomain() || undefined,
2420
+ target_ref: state.owner + '/' + state.repo,
2421
+ /* LOAD-BEARING for the control-plane runner, not just for the checklist we
2422
+ render (task 1002562): 'adopt' makes it clone + layer onto the repo's
2423
+ existing history instead of pushing a fresh tree, which a repo that already
2424
+ has commits rejects as unrelated history. state.mode already tracked
2425
+ greenfield|adopt for the UI — it has to reach the server too. */
2426
+ onboard_mode: state.mode === 'adopt' ? 'adopt' : 'greenfield'
2427
+ })
2428
+ });
2429
+ }).then(function (res) {
2430
+ if (res.status === 401) { window.location.assign(API + '/auth/web/start?return=/projects%3Fview%3Dnew'); throw new Error('signing in'); }
2431
+ return res.json().catch(function () { return {}; }).then(function (data) {
2432
+ if (!res.ok) {
2433
+ var e = new Error((data.error && (data.error.message || data.error.code)) || ('HTTP ' + res.status));
2434
+ e.data = data; throw e;
2435
+ }
2436
+ return data;
2437
+ });
2438
+ }).then(function (data) {
2439
+ try {
2440
+ sessionStorage.setItem(CREATED_KEY, JSON.stringify({
2441
+ id: data.instance && data.instance.id, slug: state.slug, domain: resolvedDomain(),
2442
+ mode: state.mode, name: state.name, at: Date.now()
2443
+ }));
2444
+ sessionStorage.removeItem(DRAFT_KEY);
2445
+ } catch (e) {}
2446
+ renderDone();
2447
+ show(5, true);
2448
+ startProgress(data.instance && data.instance.id);
2449
+ }).catch(function (err) {
2450
+ if (String(err && err.message) === 'signing in') return;
2451
+ btn.disabled = false; btn.textContent = 'Create my project';
2452
+ if (err && err.gotoHome) {
2453
+ /* A repo-creation problem (name taken / reconnect) — bounce back to the Home
2454
+ step with the reason, so the user fixes it where the repo is chosen. */
2455
+ show(2, true);
2456
+ $('h-home').textContent = err.homeMsg || 'Please revisit the repository step.';
2457
+ $('h-home').className = 'hint err';
2458
+ return;
2459
+ }
2460
+ alertBox.textContent = friendlyCreateError(err);
2461
+ alertBox.classList.add('on');
2462
+ });
2463
+ });
2464
+
2465
+ /* ── done panel: rendered FROM the canonical onboard plan (task 1002694, gap 2 /
2466
+ idea 1000675). GET /provisioning/onboard-plan is the single source the
2467
+ /new-project skill + `bongos onboard` read too, so the checklist can't
2468
+ drift. A per-key renderer adds this page's rich UI; an unknown human step
2469
+ falls back to the plan's own label, so a new canonical step surfaces here
2470
+ by default instead of silently vanishing. ── */
2471
+ function copyBtn(text) {
2472
+ return '<button type="button" class="copy" data-copy="' + esc(text) + '">Copy</button>';
2473
+ }
2474
+ var GH_ICON = '<svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>';
2475
+
2476
+ var instanceId = null; /* set when a create lands / a resumed watch starts */
2477
+ var live = { instStatus: null, manifest: null, manifestMsg: '' };
2478
+ var planPromises = {}; /* mode|domain → Promise<steps[]> */
2479
+ var lastStepsHtml = '';
2480
+ var FALLBACK_HUMAN_KEYS = ['github-repo', 'oauth-app', 'first-sign-in', 'enter-hall'];
2481
+
2482
+ function fetchPlan() {
2483
+ var domain = resolvedDomain() || '';
2484
+ var key = state.mode + '|' + domain;
2485
+ if (!planPromises[key]) {
2486
+ /* shape=standalone: this wizard only creates control-plane standalone
2487
+ instances, and the plan is shape-filtered server-side (direction §7) */
2488
+ planPromises[key] = fetch(API + '/provisioning/onboard-plan?mode=' + encodeURIComponent(state.mode) + '&shape=standalone' + (domain ? '&domain=' + encodeURIComponent(domain) : ''))
2489
+ .then(function (res) { if (!res.ok) throw new Error('plan'); return res.json(); })
2490
+ .then(function (d) { return (d && d.steps) || []; })
2491
+ .catch(function () {
2492
+ delete planPromises[key]; /* transient — retried on the next render */
2493
+ return FALLBACK_HUMAN_KEYS.map(function (k) { return { key: k, kind: 'human', label: '' }; });
2494
+ });
2495
+ }
2496
+ return planPromises[key];
2497
+ }
2498
+
2499
+ /* github-repo — adopt / auto-created / manual-create, each aware of whether the
2500
+ scaffold has landed (instance active ⇒ the build already pushed). The wizard
2501
+ files onboard_mode to the server, so for adopt the RUNNER clones + layers
2502
+ (task 1002562) — the owner no longer runs `bongos init --adopt` here. */
2503
+ function renderStepGithubRepo() {
2504
+ var landed = live.instStatus === 'active';
2505
+ var repoLabel = state.owner && state.repo ? '<strong>' + esc(state.owner + '/' + state.repo) + '</strong>' : 'your new repository';
2506
+ if (state.mode === 'adopt') {
2507
+ return landed
2508
+ ? '<li class="sdone" data-onboard-step="github-repo"><b>Your repo carries the platform</b><p>The build layered the platform onto ' + repoLabel + ' — existing files, history, and backlog kept. Pull to see it.</p></li>'
2509
+ : '<li data-onboard-step="github-repo"><b>Your existing repo</b><p>The build clones ' + repoLabel + ' and layers the platform onto its existing history — nothing is created or emptied, and your backlog is imported. Nothing for you to do here.</p></li>';
2510
+ }
2511
+ if (state.repoAutoCreated) {
2512
+ return landed
2513
+ ? '<li class="sdone" data-onboard-step="github-repo"><b>Repo ready — scaffold landed</b><p>We created ' + repoLabel + ' (public, by design) and the build pushed your project’s starting files to it.</p></li>'
2514
+ : '<li data-onboard-step="github-repo"><b>Your empty repo is ready</b><p>We created ' + repoLabel + ' on GitHub for you — public (projects here are open by design), empty, and waiting. The scaffold lands there when the build runs; nothing for you to do here.</p></li>';
2515
+ }
2516
+ var ghNewUrl = githubNewRepoUrl(state.owner, state.repo);
2517
+ return '<li' + (landed ? ' class="sdone"' : '') + ' data-onboard-step="github-repo"><b>Create the empty GitHub repo</b><p>Do this first — the build needs the repo to exist. Click below (GitHub opens with the name already filled in), press <strong>Create repository</strong>, and leave it completely empty. GitHub will ask public or private — your choice; both work here, and we never change it. Seeing “repository not found” above? That just means the repo isn’t created yet — make it here.</p>' +
2518
+ '<a class="ghbtn" href="' + esc(ghNewUrl) + '" target="_blank" rel="noopener">' + GH_ICON + 'Create ' + repoLabel + ' on GitHub</a></li>';
2519
+ }
2520
+
2521
+ /* The manual OAuth-app path stays as a documented fallback (ADR 0133), but the
2522
+ control-plane placement is named as the operator's, never handed to the owner
2523
+ as a command to run (gap 4). */
2524
+ function manualOauthDetails() {
2525
+ var oa = oauthAppValues(resolvedDomain());
2526
+ return '<details><summary>Prefer to create an OAuth app by hand?</summary>' +
2527
+ '<p>GitHub → <em>your account’s</em> Settings → Developer settings → OAuth Apps → New OAuth App. (Developer settings live under your account, not the repo.)</p>' +
2528
+ '<div class="kv"><span class="kk">Homepage URL</span><code>' + esc(oa.homepageUrl) + '</code>' + copyBtn(oa.homepageUrl) + '</div>' +
2529
+ '<div class="kv"><span class="kk">Callback URL</span><code>' + esc(oa.callbackUrl) + '</code>' + copyBtn(oa.callbackUrl) + '</div>' +
2530
+ '<p>The generated credentials are placed on the platform’s control plane by an operator — that part isn’t yours to run (the one-click path above does it all automatically). It’s done when <code style="font-family:var(--font-mono)">' + esc(oa.homepageUrl) + '/healthz</code> reports <code style="font-family:var(--font-mono)">auth_configured: true</code>.</p></details>';
2531
+ }
2532
+
2533
+ /* oauth-app — the ADR 0133 one-click GitHub-App manifest flow is the primary path
2534
+ (gap 3 / idea 1000676); locked until a domain exists (the App needs the real
2535
+ callback host — the server refuses domainless with domain_required). */
2536
+ function renderStepOauthApp() {
2537
+ var domain = resolvedDomain();
2538
+ if (!domain) {
2539
+ return '<li class="slocked" data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>Unlocks once your project has an address — sign-in needs a real domain. Attach one any time from <a href="?view=mine">your projects page</a>.</p></li>';
2540
+ }
2541
+ /* honesty about the venue (owner walk, 2026-08-12): GitHub rejects app
2542
+ manifests whose hook URL isn't publicly reachable, so a locally-hosted
2543
+ platform can't complete the one-click flow — say it BEFORE the bounce. */
2544
+ if (!apexBase()) {
2545
+ return '<li class="slocked" data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>This platform is running on a local address, and GitHub refuses app setups it can’t reach over the public internet — so the one-click flow can’t complete here. On a publicly-addressed platform this is one click.</p>' + manualOauthDetails() + '</li>';
2546
+ }
2547
+ if (live.manifest === 'done') {
2548
+ return '<li class="sdone" data-onboard-step="oauth-app"><b>GitHub sign-in is wired</b><p>Credentials placed and verified — sign-in on <code style="font-family:var(--font-mono)">' + esc(domain) + '</code> is live.</p></li>';
2549
+ }
2550
+ if (live.manifest === 'error' || live.manifest === 'expired') {
2551
+ return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>' +
2552
+ (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>' +
2553
+ '<button type="button" class="ghbtn" data-action="manifest-start">' + GH_ICON + 'Set up GitHub sign-in</button>' + manualOauthDetails() + '</li>';
2554
+ }
2555
+ if (live.manifest) { /* pending / code_received — GitHub or the runner is mid-flight */
2556
+ 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>';
2557
+ }
2558
+ 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>' +
2559
+ '<button type="button" class="ghbtn" data-action="manifest-start">' + GH_ICON + 'Set up GitHub sign-in</button>' + manualOauthDetails() + '</li>';
2560
+ }
2561
+
2562
+ function renderStepFirstSignIn() {
2563
+ var domain = resolvedDomain();
2564
+ if (!domain) {
2565
+ 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>';
2566
+ }
2567
+ 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>';
2568
+ }
2569
+
2570
+ function renderStepEnterHall() {
2571
+ var domain = resolvedDomain();
2572
+ return domain
2573
+ ? '<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>'
2574
+ : '<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>';
2575
+ }
2576
+
2577
+ var STEP_RENDERERS = {
2578
+ 'github-repo': renderStepGithubRepo,
2579
+ 'oauth-app': renderStepOauthApp,
2580
+ 'first-sign-in': renderStepFirstSignIn,
2581
+ 'enter-hall': renderStepEnterHall
2582
+ };
2583
+
2584
+ /* The newborn-instance welcome + account-nag suppression (direction §5.6,
2585
+ idea 1000701) is NOT implemented here by design: it lives on the CHILD
2586
+ instance's own surfaces (its hall greeting + needs roster), which this
2587
+ control-plane wizard cannot reach. Spun out per the direction's §9
2588
+ delivery-split as task 1002739. */
2589
+
2590
+ function renderSteps() {
2591
+ fetchPlan().then(function (steps) {
2592
+ var html = steps.filter(function (s) { return s && s.kind === 'human'; }).map(function (s) {
2593
+ var r = STEP_RENDERERS[s.key];
2594
+ return r ? r(s) : '<li data-onboard-step="' + esc(s.key) + '"><b>' + esc(s.label || s.key) + '</b></li>';
2595
+ }).join('');
2596
+ if (html === lastStepsHtml) return; /* change-only re-render — polls never clobber open UI */
2597
+ lastStepsHtml = html;
2598
+ $('humanSteps').innerHTML = html;
2599
+ });
2600
+ }
2601
+
2602
+ function renderDone() {
2603
+ var adopt = state.mode === 'adopt';
2604
+ $('doneLede').innerHTML = 'Provisioning is requested for <strong>' + esc(state.name || state.slug) + '</strong>' +
2605
+ (adopt ? ' — adopting your existing repo' : '') +
2606
+ '. The platform stands up the server, database, and address on its own — watch it right here, or any time on <a href="?view=mine">your projects page</a>.';
2607
+ renderSteps();
2608
+ }
2609
+
2610
+ /* The wizard's own copy links between views ("your projects page", the
2611
+ give-up hand-off) — delegated so innerHTML swaps keep them working, and
2612
+ routed through setView so a click can't tear down the page (and with it
2613
+ the very watch the copy promises to keep). Plain-click only: modified
2614
+ clicks stay with the browser, matching the hub's isPlainClick rule. */
2615
+ root.addEventListener('click', function (ev) {
2616
+ if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
2617
+ var a = ev.target.closest && ev.target.closest('a[href^="?view="]');
2618
+ if (!a || !root.contains(a)) return;
2619
+ ev.preventDefault();
2620
+ setView((a.getAttribute('href').match(/view=(\w+)/) || [])[1], true);
2621
+ });
2622
+
2623
+ /* One delegated listener — renderSteps swaps innerHTML, so handlers live on the list. */
2624
+ $('humanSteps').addEventListener('click', function (ev) {
2625
+ var mBtn = ev.target.closest('[data-action="manifest-start"]');
2626
+ if (mBtn) { startManifest(mBtn); return; }
2627
+ var t = ev.target.closest('.copy');
2628
+ if (!t) return;
2629
+ navigator.clipboard.writeText(t.getAttribute('data-copy')).then(function () {
2630
+ t.classList.add('did'); t.textContent = 'Copied';
2631
+ setTimeout(function () { t.classList.remove('did'); t.textContent = 'Copy'; }, 1600);
2632
+ });
2633
+ });
2634
+
2635
+ /* ── C8: the wizard's polls run only on stage, exactly like the sky.
2636
+ Off-view or hidden-tab, a due tick parks itself instead of fetching;
2637
+ enter() and the visibility flip resume whatever was parked. ── */
2638
+ function wizPollActive() {
2639
+ return !document.hidden && currentView === 'new';
2640
+ }
2641
+ function resumePolls() {
2642
+ if (watchPending && instanceId) { watchPending = false; startProgress(instanceId); }
2643
+ if (manifestPending && manifestState) { manifestPending = false; pollManifest(manifestState); }
2644
+ }
2645
+ document.addEventListener('visibilitychange', function () {
2646
+ if (wizPollActive()) resumePolls();
2647
+ });
2648
+
2649
+ /* ── GitHub-App manifest flow (ADR 0133): mint a one-time state for OUR instance,
2650
+ form-POST the pre-filled manifest to GitHub in a new tab (opened inside the
2651
+ click gesture so popup blockers allow it; blocked → same-tab, the callback
2652
+ page carries the rest), then poll the public status until the control-plane
2653
+ runner has placed + verified the credentials. ── */
2654
+ var manifestPollTimer = null;
2655
+ var manifestState = null; /* the one-time state token — kept so a parked poll can resume */
2656
+ var manifestPending = false;
2657
+ /* a cleared setTimeout id can't cancel a chain that's mid-fetch — each chain
2658
+ carries the generation it was born under, and stopping bumps it so an
2659
+ orphaned chain goes quiet instead of re-arming itself forever */
2660
+ var manifestGen = 0;
2661
+ function stopManifestPoll() { manifestGen += 1; if (manifestPollTimer) { clearTimeout(manifestPollTimer); manifestPollTimer = null; } }
2662
+
2663
+ function startManifest(btn) {
2664
+ if (!instanceId) return;
2665
+ btn.disabled = true; btn.textContent = 'Preparing GitHub…';
2666
+ var tab = window.open('about:blank', 'ghAppSetup');
2667
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(instanceId) + '/github-app', {
2668
+ method: 'POST', credentials: 'same-origin',
2669
+ headers: { 'Content-Type': 'application/json' }, body: '{}'
2670
+ }).then(function (res) {
2671
+ if (res.status === 401) { window.location.assign(API + '/auth/web/start?return=/projects%3Fview%3Dnew'); throw new Error('signing in'); }
2672
+ return res.json().catch(function () { return {}; }).then(function (d) {
2673
+ if (!res.ok) {
2674
+ var e = new Error((d.error && (d.error.message || d.error.code)) || ('HTTP ' + res.status));
2675
+ throw e;
2676
+ }
2677
+ return d;
2678
+ });
2679
+ }).then(function (d) {
2680
+ var form = document.createElement('form');
2681
+ form.method = 'POST'; form.action = d.post_url; form.target = tab ? 'ghAppSetup' : '_self';
2682
+ var inp = document.createElement('input');
2683
+ inp.type = 'hidden'; inp.name = 'manifest'; inp.value = JSON.stringify(d.manifest);
2684
+ form.appendChild(inp); document.body.appendChild(form);
2685
+ form.submit(); form.remove();
2686
+ live.manifest = 'pending'; live.manifestMsg = '';
2687
+ renderSteps();
2688
+ pollManifest(d.state);
2689
+ }).catch(function (err) {
2690
+ if (String(err && err.message) === 'signing in') return;
2691
+ if (tab) { try { tab.close(); } catch (e) {} }
2692
+ live.manifest = 'error'; live.manifestMsg = String((err && err.message) || 'could not start');
2693
+ renderSteps();
2694
+ });
2695
+ }
2696
+
2697
+ function pollManifest(stateToken) {
2698
+ stopManifestPoll();
2699
+ manifestState = stateToken;
2700
+ var myGen = manifestGen;
2701
+ function tick() {
2702
+ if (myGen !== manifestGen) return;
2703
+ if (!wizPollActive()) { manifestPending = true; stopManifestPoll(); return; }
2704
+ fetch(API + '/provisioning/github-app/status?state=' + encodeURIComponent(stateToken))
2705
+ .then(function (res) { if (!res.ok) throw new Error('soft'); return res.json(); })
2706
+ .then(function (d) {
2707
+ if (myGen !== manifestGen) return;
2708
+ var next = d.ready ? 'done' : (d.status || 'pending');
2709
+ if (next !== live.manifest) { live.manifest = next; renderSteps(); }
2710
+ if (!d.terminal) manifestPollTimer = setTimeout(tick, 4000);
2711
+ else manifestPollTimer = null; /* a stale fired id would read as "still polling" and resume a finished poll */
2712
+ })
2713
+ .catch(function () { if (myGen === manifestGen) manifestPollTimer = setTimeout(tick, 8000); });
2714
+ }
2715
+ tick();
2716
+ }
2717
+
2718
+ /* ── live build progress (task 2083): poll the instance until it's up.
2719
+ Read-only — GET /provisioning/instances/:id (own-scoped) every 5s. ── */
2720
+ var pollTimer = null;
2721
+ var watchPending = false; /* a parked progress watch, resumed on re-entry */
2722
+ var watchGen = 0; /* same orphaning contract as manifestGen above */
2723
+ function stopPolling() { watchGen += 1; if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } }
2724
+ function progressMarkup(stageNow, stagesDone, note, cls) {
2725
+ var names = ['Request filed', 'Standing it up', 'Live'];
2726
+ return '<div class="prog ' + (cls || '') + '" role="status" aria-live="polite">' +
2727
+ '<ul class="stages">' + names.map(function (n, i) {
2728
+ var c = stagesDone > i ? 'done' : (stageNow === i ? 'now' : '');
2729
+ return '<li class="' + c + '"><span class="sdot"></span><span class="slbl">' + n + '</span></li>';
2730
+ }).join('') + '</ul>' +
2731
+ '<p class="pnote">' + note + '</p></div>';
2732
+ }
2733
+ /* Renders the current instance state; returns true when the state is terminal
2734
+ (no further polling wanted). */
2735
+ function renderProgress(inst) {
2736
+ var el = $('liveProgress');
2737
+ if (!el) return true;
2738
+ var s = inst && inst.status;
2739
+ if (s === 'requested') {
2740
+ el.innerHTML = progressMarkup(1, 1, 'Waiting for a runner to pick it up. This updates the moment it starts.');
2741
+ } else if (s === 'provisioning') {
2742
+ el.innerHTML = progressMarkup(1, 1, 'Raising the server, database, and address — nothing needed from you.');
2743
+ } else if (s === 'active') {
2744
+ var addr = inst.domain ? 'https://' + inst.domain : null;
2745
+ el.innerHTML = progressMarkup(-1, 3,
2746
+ 'Your project is up' + (addr ? ' — <a href="' + esc(addr) + '" target="_blank" rel="noopener">' + esc(inst.domain) + '</a>' : '') +
2747
+ '. Finish the steps below and the hall is yours.', 'isLive');
2748
+ return true;
2749
+ } else if (s === 'error') {
2750
+ /* the honest snag (direction §5 / F19): no raw stderr as the headline, no
2751
+ false "keeps retrying" — retries stop, and the retry door is the mine view */
2752
+ el.innerHTML = progressMarkup(1, 1,
2753
+ 'The standup stopped partway — it won’t continue on its own. Retry it (and see the technical details) from <a href="?view=mine">your projects page</a>.', 'isErr');
2754
+ } else if (s === 'tearing_down' || s === 'torn_down') {
2755
+ /* a deliberate end is calm, never styled as a failure (direction §5) */
2756
+ el.innerHTML = progressMarkup(-1, 0, 'This instance was torn down. Bring it back any time from <a href="?view=mine">your projects page</a>, or start another below.');
2757
+ return true;
2758
+ } else {
2759
+ el.innerHTML = progressMarkup(0, 1, 'Filed. Watching for the standup to start…');
2760
+ }
2761
+ return false;
2762
+ }
2763
+ function startProgress(id) {
2764
+ if (!id) return;
2765
+ instanceId = id; /* the manifest flow mints against this instance */
2766
+ stopPolling();
2767
+ watchPending = false;
2768
+ var myGen = watchGen;
2769
+ var ticks = 0;
2770
+ var el = $('liveProgress');
2771
+ el.innerHTML = progressMarkup(0, 1, 'Filed. Watching for the standup to start…');
2772
+ function tick() {
2773
+ if (myGen !== watchGen) return;
2774
+ if (!wizPollActive()) { watchPending = true; stopPolling(); return; }
2775
+ ticks += 1;
2776
+ if (ticks > 400) {
2777
+ /* ~33 min: this tab stops watching, but the standup keeps going — hand
2778
+ the long watch to the mine view, the owner's durable surface (task
2779
+ 1002693). The old "stop quietly" here was a dead-end lie: the hall
2780
+ card it assumed was removed in task 1002675. */
2781
+ $('liveProgress').innerHTML = progressMarkup(1, 1,
2782
+ 'Still building — this tab will stop watching now. Follow it any time on <a href="?view=mine">your projects page</a>.');
2783
+ stopPolling();
2784
+ return;
2785
+ }
2786
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(id), { credentials: 'same-origin' })
2787
+ .then(function (res) {
2788
+ 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.'); }
2789
+ if (res.status === 404) { throw new Error('gone:Can’t see this instance any more — it may have been torn down.'); }
2790
+ if (!res.ok) throw new Error('soft');
2791
+ return res.json();
2792
+ })
2793
+ .then(function (data) {
2794
+ if (myGen !== watchGen) return;
2795
+ var inst = data.instance;
2796
+ /* the checklist reflects live state (task 1002694): a status transition —
2797
+ e.g. the scaffold landing — re-renders the steps (change-only, so the
2798
+ 5s tick never clobbers an open dropdown or mid-flight button) */
2799
+ if (inst && inst.status !== live.instStatus) { live.instStatus = inst.status; renderSteps(); }
2800
+ var terminal = renderProgress(inst);
2801
+ if (!terminal) pollTimer = setTimeout(tick, 5000);
2802
+ else stopPolling();
2803
+ })
2804
+ .catch(function (err) {
2805
+ if (myGen !== watchGen) return;
2806
+ var m = String(err && err.message || '');
2807
+ if (m.indexOf('gone:') === 0) {
2808
+ $('liveProgress').innerHTML = progressMarkup(1, 1, m.slice(5), 'isErr');
2809
+ stopPolling();
2810
+ return;
2811
+ }
2812
+ /* transient (network / 5xx): keep the last view, try again a bit later */
2813
+ pollTimer = setTimeout(tick, 10000);
2814
+ });
2815
+ }
2816
+ tick();
2817
+ }
2818
+
2819
+ /* start another project: clear the stored instance + draft, back to step 1 */
2820
+ function wireAgain() {
2821
+ var again = document.createElement('button');
2822
+ again.type = 'button'; again.className = 'again'; again.textContent = 'Start another project';
2823
+ again.addEventListener('click', function () {
2824
+ stopPolling();
2825
+ stopManifestPoll();
2826
+ instanceId = null;
2827
+ watchPending = false; manifestPending = false; manifestState = null;
2828
+ live = { instStatus: null, manifest: null, manifestMsg: '' };
2829
+ lastStepsHtml = '';
2830
+ try { sessionStorage.removeItem(CREATED_KEY); sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
2831
+ state = { step: 1, name: '', mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
2832
+ $('newName').value = ''; $('f-owner').value = ''; $('f-repo').value = ''; $('f-slug').value = ''; $('f-domain').value = '';
2833
+ $('f-noaddr').checked = false;
2834
+ /* Re-seed the owner from the signed-in account for the next project. */
2835
+ if (me && me.github_login) { state.owner = me.github_login; $('f-owner').value = state.owner; }
2836
+ $('h-name').innerHTML = '&nbsp;';
2837
+ setMode('greenfield');
2838
+ show(1, true);
2839
+ });
2840
+ $('panel-done').appendChild(again);
2841
+ }
2842
+ wireAgain();
2843
+
2844
+ /* ── the hub's three doors ── */
2845
+ function createdRecord() {
2846
+ try { return JSON.parse(sessionStorage.getItem(CREATED_KEY) || 'null'); } catch (e) { return null; }
2847
+ }
2848
+ /* enter(focus) — paint the right step for whoever just arrived. Before the
2849
+ identity settles it shows the draft step optimistically (the page stays
2850
+ usable while /me is in flight — the boot's own rule); onAuth() re-enters
2851
+ once the answer lands. Focus only moves on a deliberate nav (the flag). */
2852
+ function enter(focus) {
2853
+ if (authKnown && !me) { show(0, !!focus); return; }
2854
+ var created = authKnown ? createdRecord() : null;
2855
+ if (created && created.id) {
2856
+ /* returning after a create — resume watching that instance instead of a
2857
+ blank wizard */
2858
+ state.name = created.name || created.slug; state.slug = created.slug || '';
2859
+ state.domain = created.domain || ''; state.mode = created.mode || 'greenfield';
2860
+ renderDone();
2861
+ show(5, !!focus);
2862
+ startProgress(created.id);
2863
+ resumePolls();
2864
+ return;
2865
+ }
2866
+ /* before identity settles, never render past the name step: deeper steps
2867
+ carry authenticated side effects (the step-2 repo probe), and onAuth()
2868
+ re-enters to the real draft step the moment /me answers */
2869
+ var step = authKnown && state.step >= 1 && state.step <= 4 ? state.step : 1;
2870
+ if (step === 1) {
2871
+ show(1, false);
2872
+ if (focus) $('newName').focus();
2873
+ } else {
2874
+ show(step, !!focus);
2875
+ }
2876
+ }
2877
+ /* leave() — park the polls (C8). The due tick would park itself anyway;
2878
+ this just stops the timers immediately so nothing fires off-stage. */
2879
+ function leave() {
2880
+ if (pollTimer) { stopPolling(); watchPending = true; }
2881
+ if (manifestPollTimer) { stopManifestPoll(); manifestPending = true; }
2882
+ }
2883
+ /* onAuth() — the boot's /me settled (either way). Seed the owner field for
2884
+ the common "my own account" case, then settle the view if the visitor is
2885
+ already standing on it. */
2886
+ function onAuth() {
2887
+ authKnown = true;
2888
+ if (me && !state.ownerTouched && !state.owner && me.github_login) {
2889
+ state.owner = me.github_login;
2890
+ $('f-owner').value = state.owner;
2891
+ saveDraft();
2892
+ }
2893
+ if (currentView === 'new') enter(false);
2894
+ }
2895
+
2896
+ return { enter: enter, leave: leave, onAuth: onAuth };
2897
+ })();
1082
2898
 
1083
2899
  /* ══ YOUR PROJECTS — search + the five state pills. The filter GROUPS the
1084
2900
  chip outcomes; it never relabels them, so "Live" holding both `live` and
@@ -1370,7 +3186,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
1370
3186
  body += stripFor(inst);
1371
3187
  if (gone) {
1372
3188
  body += '<div class="projMsg">Torn down' + (inst.torn_down_at ? ' ' + esc(shortDate(inst.torn_down_at)) : '') +
1373
- '. The repository is still yours, and its database is kept — bringing it back takes one click. Or <a href="/create">start another</a>.</div>';
3189
+ '. The repository is still yours, and its database is kept — bringing it back takes one click. Or <a href="?view=new">start another</a>.</div>';
1374
3190
  }
1375
3191
  var lm = lastMsg[inst.id];
1376
3192
  body += '<div class="projMsg' + (lm && lm.cls ? ' ' + lm.cls : '') + '" id="msg-' + iid + '">' + (lm ? lm.text : '') + '</div>';
@@ -1729,6 +3545,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
1729
3545
  me = data.builder || data.me || data;
1730
3546
  renderWho();
1731
3547
  paintShellIdentity();
3548
+ wizard.onAuth();
1732
3549
  $('gate').hidden = true;
1733
3550
  $('mine').hidden = false;
1734
3551
  load();
@@ -1740,12 +3557,16 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
1740
3557
  paintShellIdentity();
1741
3558
  $('gate').hidden = false;
1742
3559
  if (String(err && err.message) !== '401') {
1743
- var g = $('gateAlert');
1744
3560
  if (!navigator.onLine || String(err && err.message).indexOf('Failed') === 0) {
1745
- g.textContent = 'Can’t reach the platform right now — sign-in will work once you’re online with it.';
1746
- g.classList.add('on');
3561
+ /* both sign-in gates (mine + wizard) carry the same honest note */
3562
+ ['gateAlert', 'wizGateAlert'].forEach(function (id) {
3563
+ var g = $(id);
3564
+ g.textContent = 'Can’t reach the platform right now — sign-in will work once you’re online with it.';
3565
+ g.classList.add('on');
3566
+ });
1747
3567
  }
1748
3568
  }
3569
+ wizard.onAuth();
1749
3570
  })
1750
3571
  /* the catalog is PUBLIC, so it loads either way — the map is the view a
1751
3572
  signed-out stranger lands on and it must not depend on identity */