synabun 2.0.0-beta → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/THIRD-PARTY-LICENSES.md +5 -0
  3. package/launcher.mjs +40 -0
  4. package/lib/claude-hooks.js +61 -8
  5. package/lib/client-config-repair.js +4 -3
  6. package/lib/data-home-migration.js +4 -0
  7. package/lib/desktop-pwa.js +206 -0
  8. package/lib/external-tools.js +221 -0
  9. package/lib/first-launch-protection.js +5 -0
  10. package/lib/packaged-runtime.js +110 -0
  11. package/lib/start-launcher.js +1309 -0
  12. package/neural-interface/i18n/en.json +41 -2
  13. package/neural-interface/i18n/pt-BR.json +41 -2
  14. package/neural-interface/lib/assistant-envelope.js +9 -6
  15. package/neural-interface/lib/augmented-path.js +23 -3
  16. package/neural-interface/lib/browser-language.js +83 -0
  17. package/neural-interface/lib/claude-agent-bridge.js +54 -147
  18. package/neural-interface/lib/claude-executable.js +241 -0
  19. package/neural-interface/lib/claude-model-catalog.js +52 -91
  20. package/neural-interface/lib/native-binary-runtime.js +69 -337
  21. package/neural-interface/lib/native-loop-providers.js +30 -44
  22. package/neural-interface/lib/offline-page.js +54 -0
  23. package/neural-interface/lib/session-title-generator.js +5 -0
  24. package/neural-interface/lib/synabun-update-plan.js +19 -0
  25. package/neural-interface/package-lock.json +0 -251
  26. package/neural-interface/public/install-app.html +31 -0
  27. package/neural-interface/public/manifest.json +2 -0
  28. package/neural-interface/public/offline.html +401 -66
  29. package/neural-interface/public/onboarding.html +252 -146
  30. package/neural-interface/public/shared/assistant/asst-mascot.js +12 -10
  31. package/neural-interface/public/shared/assistant/asst-rack-styles.js +0 -3
  32. package/neural-interface/public/shared/assistant/asst-render.js +58 -49
  33. package/neural-interface/public/shared/assistant/asst-styles.js +4 -2
  34. package/neural-interface/public/shared/command-runner-model.js +30 -0
  35. package/neural-interface/public/shared/html-shell.js +9 -5
  36. package/neural-interface/public/shared/pwa-install-page.js +34 -0
  37. package/neural-interface/public/shared/pwa-install.css +12 -0
  38. package/neural-interface/public/shared/pwa-install.js +28 -0
  39. package/neural-interface/public/shared/settings/settings-rules.js +160 -0
  40. package/neural-interface/public/shared/start-bridge.js +583 -0
  41. package/neural-interface/public/shared/styleguide/sg-colors.js +5 -2
  42. package/neural-interface/public/shared/styleguide/sg-import-export.js +4 -3
  43. package/neural-interface/public/shared/styleguide/sg-kit.js +4 -1
  44. package/neural-interface/public/shared/styleguide/sg-panel.js +9 -1
  45. package/neural-interface/public/shared/styleguide/sg-typography.js +1 -0
  46. package/neural-interface/public/shared/styles.css +801 -392
  47. package/neural-interface/public/shared/term-ready.js +307 -0
  48. package/neural-interface/public/shared/ui-claude-panel.js +3 -3
  49. package/neural-interface/public/shared/ui-command-runner.js +251 -468
  50. package/neural-interface/public/shared/ui-judgments.js +1 -1
  51. package/neural-interface/public/shared/ui-loading.js +221 -10
  52. package/neural-interface/public/shared/ui-menubar.js +11 -27
  53. package/neural-interface/public/shared/ui-settings.js +36 -59
  54. package/neural-interface/public/shared/ui-terminal.js +130 -144
  55. package/neural-interface/public/sw.js +3 -1
  56. package/neural-interface/public/variant/2d/main.js +2 -1
  57. package/neural-interface/public/variant/3d/main.js +2 -1
  58. package/neural-interface/scripts/rebuild-pty.js +15 -12
  59. package/neural-interface/server.js +220 -185
  60. package/package.json +13 -1
  61. package/postinstall.js +11 -81
  62. package/setup.js +72 -4
  63. package/docs/browser-v2.md +0 -109
  64. package/docs/hook-integrity.md +0 -89
  65. package/docs/judgments.md +0 -182
  66. package/docs/style-guide.md +0 -246
  67. package/scripts/audit-claude-hook-usage.mjs +0 -12
  68. package/scripts/audit-memory-duplicates.mjs +0 -44
  69. package/scripts/measure-claude-hook-overhead.mjs +0 -77
  70. package/scripts/recover-user-config.mjs +0 -254
@@ -28,7 +28,14 @@
28
28
  position: relative;
29
29
  }
30
30
 
31
- /* --- Mascot --- */
31
+ /* --- Mascot ---
32
+ One character, six states (data-state on .mascot-wrap), each tied to what
33
+ the page knows: asleep (nothing asked), waking (asked, nothing confirmed),
34
+ working (the launcher reports a start), ready (the server answers),
35
+ failed (the launcher reports why), stalled (nobody answered). The pill
36
+ eyes and the mouth are the app's mascot (shared/synabun-mascot.js): same
37
+ 280x140 geometry, same poses, drawn here in CSS because this page can
38
+ load nothing. */
32
39
  .mascot-wrap {
33
40
  display: inline-block;
34
41
  position: relative;
@@ -41,7 +48,87 @@
41
48
  50% { transform: translateY(-5px); }
42
49
  }
43
50
 
44
- /* --- Floating Z's --- */
51
+ .mascot-wrap .eye {
52
+ fill: rgba(255,255,255,0.45);
53
+ transform-box: fill-box;
54
+ transform-origin: center;
55
+ transform: translateY(9px) scaleY(0.1);
56
+ transition: transform 0.25s cubic-bezier(0.2, 0, 0, 1), fill 0.25s;
57
+ }
58
+
59
+ .mascot-wrap .mouth {
60
+ fill: none;
61
+ stroke: rgba(255,255,255,0.15);
62
+ stroke-width: 2.5;
63
+ stroke-linecap: round;
64
+ opacity: 0;
65
+ transition: opacity 0.25s;
66
+ }
67
+
68
+ .mascot-wrap .mouth-calm { opacity: 1; }
69
+
70
+ /* waking: half-open eyes that look from side to side, waiting on someone else */
71
+ .mascot-wrap[data-state="waking"] { animation: none; }
72
+ .mascot-wrap[data-state="waking"] .eye {
73
+ fill: rgba(255,255,255,0.7);
74
+ transform: scaleY(0.46);
75
+ animation: glance 2.6s ease-in-out infinite;
76
+ }
77
+
78
+ @keyframes glance {
79
+ 0%, 12% { transform: translateX(0) scaleY(0.46); }
80
+ 28%, 44% { transform: translateX(-9px) scaleY(0.46); }
81
+ 60%, 76% { transform: translateX(9px) scaleY(0.46); }
82
+ 90%, 100% { transform: translateX(0) scaleY(0.46); }
83
+ }
84
+
85
+ /* working: eyes wide open, taking turns: something is really running */
86
+ .mascot-wrap[data-state="working"] { animation: none; }
87
+ .mascot-wrap[data-state="working"] .eye {
88
+ fill: rgba(255,255,255,0.85);
89
+ transform: scaleY(1);
90
+ animation: work 1.1s ease-in-out infinite;
91
+ }
92
+ .mascot-wrap[data-state="working"] .eye-r { animation-delay: 0.18s; }
93
+
94
+ @keyframes work {
95
+ 0%, 100% { transform: translateY(0) scaleY(1); }
96
+ 35% { transform: translateY(-7px) scaleY(1.04); }
97
+ 60% { transform: translateY(2px) scaleY(0.92); }
98
+ }
99
+
100
+ /* ready: happy eyes, a wider smile, one hop */
101
+ .mascot-wrap[data-state="ready"] { animation: hop 0.5s cubic-bezier(0.2, 0, 0, 1) 1; }
102
+ .mascot-wrap[data-state="ready"] .eye { fill: rgba(80,220,120,0.8); transform: translateY(-4px) scaleY(0.3); }
103
+ .mascot-wrap[data-state="ready"] .eye-l { transform: translateY(-4px) rotate(-8deg) scaleY(0.3); }
104
+ .mascot-wrap[data-state="ready"] .eye-r { transform: translateY(-4px) rotate(8deg) scaleY(0.3); }
105
+ .mascot-wrap[data-state="ready"] .mouth-calm { opacity: 0; }
106
+ .mascot-wrap[data-state="ready"] .mouth-happy { opacity: 1; stroke: rgba(80,220,120,0.5); }
107
+
108
+ @keyframes hop {
109
+ 0%, 100% { transform: translateY(0); }
110
+ 45% { transform: translateY(-10px); }
111
+ }
112
+
113
+ /* failed: a wince and a short shake, then still */
114
+ .mascot-wrap[data-state="failed"] { animation: shake 0.4s ease-in-out 1; }
115
+ .mascot-wrap[data-state="failed"] .eye { fill: rgba(255,255,255,0.6); }
116
+ .mascot-wrap[data-state="failed"] .eye-l { transform: translateY(2px) rotate(12deg) scaleY(0.78); }
117
+ .mascot-wrap[data-state="failed"] .eye-r { transform: translateY(2px) rotate(-12deg) scaleY(0.74); }
118
+ .mascot-wrap[data-state="failed"] .mouth-calm { opacity: 0; }
119
+ .mascot-wrap[data-state="failed"] .mouth-sad { opacity: 1; stroke: rgba(255,255,255,0.3); }
120
+
121
+ @keyframes shake {
122
+ 0%, 100% { transform: translateX(0); }
123
+ 25% { transform: translateX(-5px); }
124
+ 75% { transform: translateX(5px); }
125
+ }
126
+
127
+ /* stalled: awake but dimmed, waiting for the person */
128
+ .mascot-wrap[data-state="stalled"] { animation: none; }
129
+ .mascot-wrap[data-state="stalled"] .eye { fill: rgba(255,255,255,0.35); transform: translateY(5px) scaleY(0.44); }
130
+
131
+ /* --- Floating Z's (asleep only) --- */
45
132
  .zzz {
46
133
  position: absolute;
47
134
  top: -4px;
@@ -50,8 +137,11 @@
50
137
  font-weight: 700;
51
138
  color: rgba(110,200,255,0.45);
52
139
  pointer-events: none;
140
+ transition: opacity 0.25s;
53
141
  }
54
142
 
143
+ .mascot-wrap:not([data-state="asleep"]) .zzz { opacity: 0; }
144
+
55
145
  .zzz span {
56
146
  position: absolute;
57
147
  bottom: 0;
@@ -85,6 +175,129 @@
85
175
  margin-bottom: 28px;
86
176
  }
87
177
 
178
+ /* --- Start Server --- */
179
+ .start {
180
+ margin-bottom: 28px;
181
+ }
182
+
183
+ .start[hidden] { display: none; }
184
+
185
+ .start-btn {
186
+ display: inline-flex;
187
+ align-items: center;
188
+ gap: 8px;
189
+ padding: 10px 24px;
190
+ background: rgba(255,255,255,0.08);
191
+ border: 1px solid rgba(255,255,255,0.15);
192
+ border-radius: 8px;
193
+ color: rgba(255,255,255,0.7);
194
+ font-family: inherit;
195
+ font-size: 13px;
196
+ font-weight: 500;
197
+ letter-spacing: 0.05em;
198
+ cursor: pointer;
199
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
200
+ }
201
+
202
+ .start-btn:hover {
203
+ background: rgba(255,255,255,0.12);
204
+ border-color: rgba(255,255,255,0.25);
205
+ color: rgba(255,255,255,0.9);
206
+ }
207
+
208
+ .start-btn:focus-visible {
209
+ outline: 2px solid rgba(110,200,255,0.6);
210
+ outline-offset: 2px;
211
+ }
212
+
213
+ .start-btn:disabled {
214
+ opacity: 0.4;
215
+ cursor: wait;
216
+ }
217
+
218
+ .start-btn svg {
219
+ width: 14px;
220
+ height: 14px;
221
+ fill: none;
222
+ stroke: currentColor;
223
+ stroke-width: 2;
224
+ stroke-linecap: round;
225
+ stroke-linejoin: round;
226
+ }
227
+
228
+ /* What is happening, in order of how sure the page is: the sentence (the
229
+ live region), the page's own clock, a quieter second sentence, then the
230
+ steps the launcher reported with the time each one happened. */
231
+ .start-status {
232
+ min-height: 18px;
233
+ margin-top: 14px;
234
+ font-size: 13px;
235
+ line-height: 1.45;
236
+ color: rgba(255,255,255,0.7);
237
+ }
238
+
239
+ .start-elapsed {
240
+ min-height: 16px;
241
+ margin-top: 4px;
242
+ font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
243
+ font-size: 11.5px;
244
+ font-variant-numeric: tabular-nums;
245
+ color: rgba(110,200,255,0.6);
246
+ }
247
+
248
+ .start-hint {
249
+ margin-top: 4px;
250
+ font-size: 11px;
251
+ line-height: 1.45;
252
+ color: rgba(255,255,255,0.35);
253
+ }
254
+
255
+ .start-hint:empty, .start-elapsed:empty { display: none; }
256
+
257
+ .start-trail {
258
+ list-style: none;
259
+ margin: 12px auto 0;
260
+ max-width: 260px;
261
+ font-size: 11px;
262
+ line-height: 1.7;
263
+ color: rgba(255,255,255,0.35);
264
+ }
265
+
266
+ .start-trail:empty { display: none; }
267
+
268
+ .start-trail li {
269
+ display: flex;
270
+ justify-content: space-between;
271
+ gap: 12px;
272
+ }
273
+
274
+ .start-trail .at {
275
+ font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
276
+ font-variant-numeric: tabular-nums;
277
+ color: rgba(255,255,255,0.55);
278
+ }
279
+
280
+ .start-log {
281
+ margin-top: 10px;
282
+ font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
283
+ font-size: 10.5px;
284
+ line-height: 1.5;
285
+ color: rgba(255,255,255,0.35);
286
+ overflow-wrap: anywhere;
287
+ user-select: all;
288
+ }
289
+
290
+ .start-log[hidden] { display: none; }
291
+
292
+ .manual-note {
293
+ margin: 2px 0 14px;
294
+ font-size: 11px;
295
+ line-height: 1.45;
296
+ color: rgba(255,255,255,0.35);
297
+ }
298
+
299
+ .manual-note[hidden] { display: none; }
300
+
88
301
  /* --- Command labels --- */
89
302
  .cmd-label {
90
303
  font-size: 10px;
@@ -115,7 +328,10 @@
115
328
  font-size: 11.5px;
116
329
  color: rgba(255,255,255,0.55);
117
330
  line-height: 1.5;
118
- word-break: break-all;
331
+ /* Break between words first, inside a long path only when it has to;
332
+ and no ligatures: "--prefix" must read as two hyphens. */
333
+ overflow-wrap: anywhere;
334
+ font-variant-ligatures: none;
119
335
  user-select: all;
120
336
  }
121
337
 
@@ -213,6 +429,19 @@
213
429
  90% { opacity: 1; }
214
430
  100% { transform: translateY(-10vh) translateX(30px); opacity: 0; }
215
431
  }
432
+
433
+ /* Reduced motion: every state keeps its own still face (the eyes and mouth
434
+ above), the words and the clock; nothing moves. */
435
+ @media (prefers-reduced-motion: reduce) {
436
+ .mascot-wrap, .mascot-wrap .eye, .zzz span, .retry-dot, .particles span {
437
+ animation: none !important;
438
+ }
439
+ .mascot-wrap .eye, .mascot-wrap .mouth, .zzz, .start-btn, .copy-btn {
440
+ transition: none !important;
441
+ }
442
+ .zzz span:nth-child(2) { opacity: 0.5; }
443
+ .particles { display: none; }
444
+ }
216
445
  </style>
217
446
  </head>
218
447
  <body>
@@ -221,38 +450,42 @@
221
450
  <div class="particles" id="particles"></div>
222
451
 
223
452
  <div class="card">
224
- <!-- Sleeping mascot -->
225
- <div class="mascot-wrap">
226
- <svg viewBox="0 0 280 140" width="180" height="90" style="overflow:visible">
227
- <!-- Left eye — closed -->
228
- <rect x="80" y="57" width="38" height="6" rx="3" fill="rgba(255,255,255,0.45)"/>
229
- <!-- Right eye — closed -->
230
- <rect x="162" y="57" width="38" height="6" rx="3" fill="rgba(255,255,255,0.45)"/>
231
- <!-- Peaceful smile -->
232
- <path d="M118,90 Q140,100 162,90" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="2.5" stroke-linecap="round"/>
453
+ <!-- The mascot: asleep until a start is asked for (data-state follows the bridge) -->
454
+ <div class="mascot-wrap" id="mascot" data-state="asleep">
455
+ <svg viewBox="0 0 280 140" width="180" height="90" style="overflow:visible" aria-hidden="true" focusable="false">
456
+ <rect class="eye eye-l" x="80" y="24" width="38" height="72" rx="19"/>
457
+ <rect class="eye eye-r" x="162" y="24" width="38" height="72" rx="19"/>
458
+ <path class="mouth mouth-calm" d="M118,116 Q140,128 162,116"/>
459
+ <path class="mouth mouth-happy" d="M111,112 Q140,138 169,112"/>
460
+ <path class="mouth mouth-sad" d="M123,124 Q140,114 157,124"/>
233
461
  </svg>
234
- <div class="zzz">
462
+ <div class="zzz" aria-hidden="true">
235
463
  <span>z</span>
236
464
  <span>z</span>
237
465
  <span>z</span>
238
466
  </div>
239
467
  </div>
240
468
 
241
- <div class="heading">Server is sleeping</div>
242
- <div class="subtext">The Neural Interface server isn't running.</div>
469
+ <div class="heading" id="heading">Server is sleeping</div>
470
+ <div class="subtext" id="subtext">The Neural Interface server isn't running.</div>
471
+
472
+ <div class="start" id="start" hidden>
473
+ <button class="start-btn" id="startBtn" type="button">
474
+ <svg viewBox="0 0 24 24" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>
475
+ <span id="startLabel">Start Server</span>
476
+ </button>
477
+ <!-- Announced when the sentence changes; the ticking clock below is not part of it. -->
478
+ <div class="start-status" id="startStatus" role="status" aria-live="polite" aria-atomic="true"></div>
479
+ <div class="start-elapsed" id="startElapsed" role="timer" aria-live="off"></div>
480
+ <div class="start-hint" id="startHint"></div>
481
+ <ul class="start-trail" id="startTrail" aria-label="What the launcher reported"></ul>
482
+ <div class="start-log" id="startLog" hidden></div>
483
+ </div>
243
484
 
244
- <div>
245
- <div class="cmd-label">npm install</div>
246
- <div class="cmd-block">
247
- <div class="cmd-text" id="cmdText"><span>synabun</span></div>
248
- <button class="copy-btn" id="copyBtn">Copy</button>
249
- </div>
250
- <div class="cmd-or" id="cmdOr" style="display:none">or</div>
251
- <div class="cmd-label" id="cmdFallbackLabel" style="display:none">git / local install</div>
252
- <div class="cmd-block" id="cmdFallback" style="display:none">
253
- <div class="cmd-text" id="cmdFallbackText"></div>
254
- <button class="copy-btn" id="copyFallbackBtn">Copy</button>
255
- </div>
485
+ <div id="manual">
486
+ <div class="cmd-label" id="manualLabel">Start it from a terminal</div>
487
+ <div id="manualList"></div>
488
+ <div class="manual-note" id="manualNote" hidden>Starting it this way once also sets up the Start button.</div>
256
489
  </div>
257
490
 
258
491
  <div class="retry" id="retry">
@@ -261,31 +494,29 @@
261
494
  </div>
262
495
  </div>
263
496
 
497
+ <!-- Filled in by the server when it serves this page (lib/offline-page.js):
498
+ where SynaBun is installed, and the Start Server bridge shared with the app. -->
499
+ <script>/*__SYNABUN_OFFLINE_FACTS__*/</script>
500
+ <script>/*__SYNABUN_START_BRIDGE__*/</script>
264
501
  <script>
265
- // --- Platform detection ---
266
- const ua = navigator.userAgent;
267
- const isWin = /Win/.test(navigator.platform) || /Windows/.test(ua);
268
- const isMac = /Mac/.test(navigator.platform);
269
- // Linux = everything else
270
-
271
- const projectDir = localStorage.getItem('synabun-project-dir');
272
-
273
- // --- Manual command fallback ---
274
- if (projectDir) {
275
- const sep = isWin ? ' & ' : ' ; ';
276
- const fallbackCmd = `cd "${projectDir}"${sep}npm start`;
277
- const cmdOr = document.getElementById('cmdOr');
278
- const cmdFallback = document.getElementById('cmdFallback');
279
- const cmdFallbackText = document.getElementById('cmdFallbackText');
280
- cmdOr.style.display = '';
281
- cmdFallback.style.display = '';
282
- const cmdFallbackLabel = document.getElementById('cmdFallbackLabel');
283
- if (cmdFallbackLabel) cmdFallbackLabel.style.display = '';
284
- cmdFallbackText.innerHTML = `<span>cd</span> "${projectDir}"${sep}<span>npm</span> start`;
285
- document.getElementById('copyFallbackBtn').addEventListener('click', function() { copyToClipboard(fallbackCmd, this); });
286
- }
287
-
288
- // --- Copy helper ---
502
+ const $ = (id) => document.getElementById(id);
503
+ const stored = (key) => { try { return localStorage.getItem(key); } catch { return null; } };
504
+
505
+ // What the server said about this install the last time it answered.
506
+ const facts = window.__SYNABUN_OFFLINE__ || {};
507
+ const projectDir = stored('synabun-project-dir') || facts.projectDir || '';
508
+ const install = stored('synabun-install-kind') || facts.install || '';
509
+ const launcher = stored('synabun-start-launcher') || facts.launcher || '';
510
+ const entry = stored('synabun-start-entry') || facts.entry || '';
511
+ const hasBridge = typeof createStartBridge === 'function';
512
+ const windows = hasBridge ? isWindowsPlatform(navigator) : /Win/.test(navigator.platform);
513
+ const mac = hasBridge ? isMacPlatform(navigator) : /Mac/.test(navigator.platform);
514
+
515
+ // --- Commands to type instead ---
516
+ const commands = hasBridge
517
+ ? manualStartCommands({ projectDir, install, windows, entry })
518
+ : [{ id: 'npm', command: 'synabun' }].concat(projectDir ? [{ id: 'git', command: 'npm --prefix "' + projectDir + '" start' }] : []);
519
+
289
520
  function copyToClipboard(text, btn) {
290
521
  navigator.clipboard.writeText(text).catch(() => {
291
522
  const ta = document.createElement('textarea');
@@ -302,23 +533,127 @@
302
533
  setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
303
534
  }
304
535
 
305
- document.getElementById('copyBtn').addEventListener('click', function() { copyToClipboard('synabun', this); });
306
-
307
- // --- Auto-retry ---
308
- const retry = document.getElementById('retry');
309
- const retryText = document.getElementById('retryText');
310
-
311
- const interval = setInterval(async () => {
536
+ const list = $('manualList');
537
+ commands.forEach((entry, i) => {
538
+ if (i > 0) {
539
+ const or = document.createElement('div');
540
+ or.className = 'cmd-or';
541
+ or.textContent = 'or';
542
+ list.appendChild(or);
543
+ }
544
+ const block = document.createElement('div');
545
+ block.className = 'cmd-block';
546
+ const text = document.createElement('div');
547
+ text.className = 'cmd-text';
548
+ const space = entry.command.indexOf(' ');
549
+ const word = document.createElement('span');
550
+ word.textContent = space < 0 ? entry.command : entry.command.slice(0, space);
551
+ text.appendChild(word);
552
+ if (space >= 0) text.appendChild(document.createTextNode(entry.command.slice(space)));
553
+ const copy = document.createElement('button');
554
+ copy.className = 'copy-btn';
555
+ copy.type = 'button';
556
+ copy.textContent = 'Copy';
557
+ copy.addEventListener('click', () => copyToClipboard(entry.command, copy));
558
+ block.append(text, copy);
559
+ list.appendChild(block);
560
+ });
561
+
562
+ // --- Start Server + reconnect ---
563
+ const retry = $('retry');
564
+ const retryText = $('retryText');
565
+
566
+ async function serverAnswers() {
312
567
  try {
313
568
  const res = await fetch('/', { method: 'HEAD', cache: 'no-store' });
314
- if (res.ok) {
315
- clearInterval(interval);
316
- retry.classList.add('connected');
317
- retryText.textContent = 'Server is back! Reconnecting...';
318
- setTimeout(() => location.reload(), 600);
319
- }
320
- } catch {}
321
- }, 3000);
569
+ return res.ok;
570
+ } catch {
571
+ return false;
572
+ }
573
+ }
574
+
575
+ function reconnect(delay) {
576
+ retry.classList.add('connected');
577
+ retryText.textContent = 'Server is back! Reconnecting...';
578
+ setTimeout(() => location.reload(), delay);
579
+ }
580
+
581
+ if (!hasBridge) {
582
+ // An older server served this page without the bridge: commands and reconnect only.
583
+ const interval = setInterval(async () => {
584
+ if (!(await serverAnswers())) return;
585
+ clearInterval(interval);
586
+ reconnect(150);
587
+ }, 3000);
588
+ } else {
589
+ const start = $('start');
590
+ const startBtn = $('startBtn');
591
+ const startLabel = $('startLabel');
592
+ const startStatus = $('startStatus');
593
+ const usable = startButtonUsable(launcher);
594
+ // Known not to be registered: no dead button, the command and why it matters.
595
+ start.hidden = !usable;
596
+ $('manualNote').hidden = usable;
597
+ if (usable) $('manualLabel').textContent = 'Or start it from a terminal';
598
+
599
+ const platform = windows ? 'windows' : mac ? 'mac' : 'linux';
600
+ const say = (line) => (line ? formatStartText(START_COPY[line.key], line.params) : '');
601
+ const headings = {
602
+ asleep: ['Server is sleeping', "The Neural Interface server isn't running."],
603
+ waking: [START_COPY.headingStarting, "The Neural Interface server isn't running yet."],
604
+ working: [START_COPY.headingStarting, "The Neural Interface server isn't running yet."],
605
+ ready: [START_COPY.headingReady, 'Loading SynaBun.'],
606
+ failed: [START_COPY.headingFailed, "The Neural Interface server isn't running."],
607
+ stalled: ['Server is sleeping', "The Neural Interface server isn't running."],
608
+ };
609
+ const labels = { start: 'Start Server', starting: 'Starting...', retry: 'Try again' };
610
+
611
+ // The launcher's own report, when this page can reach it (see start-bridge.js).
612
+ const beaconAt = startBeaconTarget(window.location);
613
+
614
+ function render(snap) {
615
+ const view = startStatusView(snap, { platform });
616
+ $('mascot').setAttribute('data-state', view.mascot);
617
+ $('heading').textContent = headings[view.mascot][0];
618
+ $('subtext').textContent = headings[view.mascot][1];
619
+ startBtn.disabled = view.busy || view.state === 'online';
620
+ startLabel.textContent = labels[view.button];
621
+ // The live region is written only when its sentence changes.
622
+ const line = say(view.headline);
623
+ if (startStatus.textContent !== line) startStatus.textContent = line;
624
+ $('startElapsed').textContent = view.elapsed;
625
+ $('startElapsed').setAttribute('aria-label', view.elapsed ? START_COPY.elapsedLabel + ': ' + view.elapsed : '');
626
+ $('startHint').textContent = say(view.hint);
627
+ $('startTrail').replaceChildren(...view.trail.map((row) => {
628
+ const item = document.createElement('li');
629
+ const what = document.createElement('span');
630
+ what.textContent = START_COPY[row.key];
631
+ const when = document.createElement('span');
632
+ when.className = 'at';
633
+ when.textContent = row.time;
634
+ item.append(what, when);
635
+ return item;
636
+ }));
637
+ const log = view.error && view.error.log ? formatStartText(START_COPY.failedLog, { path: view.error.log }) : '';
638
+ $('startLog').textContent = log;
639
+ $('startLog').hidden = !log;
640
+ retryText.textContent = view.state === 'online' ? retryText.textContent
641
+ : view.busy ? 'Waiting for the server...' : 'Checking for server...';
642
+ }
643
+
644
+ const bridge = createStartBridge({
645
+ probe: serverAnswers,
646
+ launch: () => openStartLink({ document, location: window.location, userAgent: navigator.userAgent }),
647
+ beacon: beaconAt ? () => fetchStartBeacon(beaconAt) : null,
648
+ port: beaconAt ? beaconAt.port : 0,
649
+ onUpdate: render,
650
+ onState(state) {
651
+ if (state === 'online') reconnect(START_LIMITS.reloadDelayMs);
652
+ },
653
+ });
654
+ startBtn.addEventListener('click', () => bridge.start());
655
+ bridge.watch();
656
+ }
322
657
 
323
658
  // --- Ambient particles ---
324
659
  const container = document.getElementById('particles');