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
@@ -0,0 +1,583 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — Start Server bridge (page side)
3
+ // A page cannot ask a server that is down to start itself. It hands the
4
+ // operating system a synabun://start link instead; the launcher registered
5
+ // for that link (lib/start-launcher.js) starts the server, and the page
6
+ // watches for it. DOM-free and dependency-free.
7
+ //
8
+ // The page also cannot ask a stopped server how its start is going. The
9
+ // launcher says so itself, on a loopback beacon (port + 10000) that lives only
10
+ // while a launch does. Everything the page shows about a start comes from one
11
+ // of three things: its own clock, what that beacon reported (each step with
12
+ // the time it happened), and its own probe of the server. Nothing is guessed:
13
+ // no percentages, and no step is named before the launch reported it.
14
+ //
15
+ // offline.html is shown when nothing can be fetched, so the server pastes this
16
+ // file into it (lib/offline-page.js drops the `export` keywords). Keep every
17
+ // export a plain `export function` / `export const` at the start of a line,
18
+ // and never import anything here.
19
+ // ═══════════════════════════════════════════
20
+
21
+ export const START_LINK = 'synabun://start';
22
+
23
+ // What the page remembers from the last time the server answered.
24
+ export const START_STORAGE = {
25
+ projectDir: 'synabun-project-dir',
26
+ install: 'synabun-install-kind', // 'npm' | 'git' | 'app' (a packaged application)
27
+ launcher: 'synabun-start-launcher', // 'registered' | 'stale' | 'missing' | 'skipped'
28
+ entry: 'synabun-start-entry', // a packaged application's entry executable
29
+ };
30
+
31
+ export const START_LIMITS = {
32
+ pollMs: 300, // while a start is under way: is the server up, what does the launch say
33
+ idlePollMs: 3000, // while nothing was asked for
34
+ hintAfterMs: 4000, // nothing from the launcher yet: say what to look for
35
+ stallAfterMs: 60000, // still nothing from anyone: offer the button and the command again
36
+ quietAfterMs: 15000, // the launch has reported nothing new for this long: say so
37
+ lostAfterMs: 3000, // a launch that was reporting went silent: stop repeating what it said
38
+ idleBeaconChecks: 2, // on load, look for a start already under way (a reload in the middle of one)
39
+ reloadDelayMs: 150, // the "it is up" frame before the page reloads
40
+ beaconTimeoutMs: 1200,
41
+ };
42
+
43
+ export const START_BEACON_PATH = '/synabun-start-status';
44
+ export const START_BEACON_VERSION = 1;
45
+
46
+ export function isWindowsPlatform(nav) {
47
+ const platform = String(nav?.userAgentData?.platform || nav?.platform || '');
48
+ return /win/i.test(platform) || /Windows/i.test(String(nav?.userAgent || ''));
49
+ }
50
+
51
+ export function isMacPlatform(nav) {
52
+ const platform = String(nav?.userAgentData?.platform || nav?.platform || '');
53
+ return /mac/i.test(platform);
54
+ }
55
+
56
+ /**
57
+ * How to hand the link to the OS. Firefox replaces the page with an error when
58
+ * a top-level navigation has no handler, so it gets a hidden frame; every
59
+ * other engine asks the user from the top frame and ignores an unknown link.
60
+ */
61
+ export function startLinkMode(userAgent) {
62
+ return /\bFirefox\//.test(String(userAgent || '')) ? 'frame' : 'top';
63
+ }
64
+
65
+ /**
66
+ * Hand START_LINK to the OS. Call it from the click itself: browsers only ask
67
+ * "open SynaBun?" for a navigation the user started.
68
+ */
69
+ export function openStartLink({ document: doc, location: loc, userAgent } = {}) {
70
+ if (startLinkMode(userAgent) === 'frame') {
71
+ const frame = doc.createElement('iframe');
72
+ frame.style.display = 'none';
73
+ frame.src = START_LINK;
74
+ doc.body.appendChild(frame);
75
+ const tidy = setTimeout(() => { try { frame.remove(); } catch {} }, 5000);
76
+ if (typeof tidy === 'object') tidy?.unref?.(); // only outside a browser
77
+ return 'frame';
78
+ }
79
+ loc.href = START_LINK;
80
+ return 'top';
81
+ }
82
+
83
+ /** A path as one argument, in whatever shell the platform's terminal runs. */
84
+ export function quoteShellPath(path, windows) {
85
+ const value = String(path || '');
86
+ // cmd.exe and PowerShell both take a double-quoted path; a Windows path cannot contain a quote.
87
+ if (windows) return `"${value}"`;
88
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
89
+ return `'${value.replace(/'/g, `'\\''`)}'`;
90
+ }
91
+
92
+ /**
93
+ * How a packaged application is started by hand: its entry executable with
94
+ * `start`. A path that needs no quotes reads the same in every shell. One that
95
+ * does goes through `cmd /c` on Windows, because PowerShell does not run a
96
+ * quoted path by itself and cmd.exe does not know PowerShell's `&`.
97
+ */
98
+ export function packagedStartCommand(entry) {
99
+ const value = String(entry || '');
100
+ if (/^[A-Za-z]:[\\/]|^\\\\/.test(value)) {
101
+ const path = value.replace(/\//g, '\\');
102
+ return /^[A-Za-z0-9_.:\\-]+$/.test(path) ? `${path} start` : `cmd /c "${path}" start`;
103
+ }
104
+ return `${quoteShellPath(value, false)} start`;
105
+ }
106
+
107
+ /**
108
+ * The commands a person can type instead. One line each, no `cd`, no `;` or
109
+ * `&` between commands: `npm --prefix <dir> start` reads the same in cmd.exe,
110
+ * PowerShell, bash, zsh and fish.
111
+ * Returns [{ id: 'npm' | 'git' | 'app', command }] — the one that matches the
112
+ * install when it is known, both npm and git when it is not. A packaged
113
+ * application (`install: 'app'`) is started through its `entry` executable.
114
+ */
115
+ export function manualStartCommands({ projectDir, install, windows, entry } = {}) {
116
+ if (install === 'app' && entry) return [{ id: 'app', command: packagedStartCommand(entry) }];
117
+ const npm = { id: 'npm', command: 'synabun' };
118
+ const git = projectDir ? { id: 'git', command: `npm --prefix ${quoteShellPath(projectDir, windows)} start` } : null;
119
+ if (install === 'npm') return [npm];
120
+ if (install === 'git' && git) return [git];
121
+ return git ? [npm, git] : [npm];
122
+ }
123
+
124
+ /** Whether the button can work: only a registration known to be absent rules it out. */
125
+ export function startButtonUsable(launcherState) {
126
+ return launcherState !== 'missing';
127
+ }
128
+
129
+ // ═══════════════════════════════════════════
130
+ // The launch's own report (the beacon)
131
+ // ═══════════════════════════════════════════
132
+
133
+ /** The beacon's port for a server port: + 10000, − 10000 past the last port. As lib/start-launcher.js `startBeaconPort`. */
134
+ export function startBeaconPort(port) {
135
+ const n = Number(port);
136
+ const p = Number.isInteger(n) && n >= 1 && n <= 65535 ? n : 3344;
137
+ return p + 10000 <= 65535 ? p + 10000 : p - 10000;
138
+ }
139
+
140
+ /**
141
+ * Where this page reads the beacon, or null when it cannot: the launcher runs
142
+ * on the machine the server runs on, so only a page opened from that machine's
143
+ * own loopback address (and over plain http, like the beacon) has one to read.
144
+ * Returns { url, port } with `port` the server's.
145
+ */
146
+ export function startBeaconTarget(loc) {
147
+ const host = String(loc?.hostname || '').toLowerCase();
148
+ if (String(loc?.protocol || '') !== 'http:') return null;
149
+ if (host !== 'localhost' && host !== '127.0.0.1' && host !== '[::1]' && host !== '::1') return null;
150
+ const port = Number(loc?.port) || 80;
151
+ return { port, url: `http://127.0.0.1:${startBeaconPort(port)}${START_BEACON_PATH}` };
152
+ }
153
+
154
+ const START_LAUNCH_STATES = ['starting', 'started', 'failed', 'gave-up'];
155
+ const START_LAUNCH_STEPS = ['received', 'locked', 'terminal', 'spawned'];
156
+ const START_SUPERVISOR_PHASES = ['checking', 'dependencies', 'browser', 'build', 'snapshot', 'server', 'listening', 'failed'];
157
+
158
+ function startCleanError(error) {
159
+ if (!error || typeof error !== 'object') return null;
160
+ const exitCode = Number(error.exitCode);
161
+ return {
162
+ code: String(error.code || '').slice(0, 40),
163
+ message: String(error.message || '').slice(0, 300),
164
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Read one beacon answer. Returns the launch as this page may show it, or
170
+ * null: not a beacon, another server's, or the end of a launch this page has
171
+ * nothing to do with (a failure still being reported from before the click).
172
+ * A launch that is still under way is this server's start whoever asked.
173
+ * askedAt — when this page handed the link over (null: it did not)
174
+ * following — the launch id this page is already showing
175
+ * past — launch ids this page has moved on from (a retry was clicked)
176
+ */
177
+ export function readStartBeacon(data, { port, askedAt = null, following = null, past = [] } = {}) {
178
+ if (!data || typeof data !== 'object' || data.synabun !== 'start-beacon' || data.v !== START_BEACON_VERSION) return null;
179
+ if (Number(data.port) !== Number(port)) return null;
180
+ if (!START_LAUNCH_STATES.includes(data.state)) return null;
181
+ const id = String(data.launchId || '');
182
+ const startedAt = Number(data.startedAt) || 0;
183
+ if (!id || !startedAt) return null;
184
+ if (data.state !== 'starting') {
185
+ if (past.includes(id)) return null;
186
+ const mine = id === following || (askedAt !== null && startedAt >= askedAt - 2000);
187
+ if (!mine) return null;
188
+ }
189
+
190
+ const steps = [];
191
+ for (const step of Array.isArray(data.steps) ? data.steps : []) {
192
+ const at = Number(step?.at);
193
+ if (START_LAUNCH_STEPS.includes(step?.id) && Number.isFinite(at)) steps.push({ id: step.id, at });
194
+ }
195
+ let supervisor = null;
196
+ const sup = data.supervisor;
197
+ if (sup && typeof sup === 'object' && START_SUPERVISOR_PHASES.includes(sup.phase)) {
198
+ const detail = sup.detail && typeof sup.detail === 'object' ? sup.detail : {};
199
+ const number = (value) => (Number.isFinite(Number(value)) && value !== null ? Number(value) : null);
200
+ supervisor = {
201
+ phase: sup.phase,
202
+ at: Number(sup.at) || 0,
203
+ startedAt: Number(sup.startedAt) || 0,
204
+ phases: (Array.isArray(sup.phases) ? sup.phases : [])
205
+ .filter(p => START_SUPERVISOR_PHASES.includes(p?.phase) && Number.isFinite(Number(p?.at)))
206
+ .map(p => ({ phase: p.phase, at: Number(p.at) })),
207
+ detail: {
208
+ name: typeof detail.name === 'string' ? detail.name.slice(0, 80) : '',
209
+ step: typeof detail.step === 'string' ? detail.step.slice(0, 40) : '',
210
+ files: number(detail.files),
211
+ bytes: number(detail.bytes),
212
+ },
213
+ error: startCleanError(sup.error),
214
+ };
215
+ }
216
+ const last = steps[steps.length - 1] || { id: 'received', at: startedAt };
217
+ const newest = Math.max(last.at, supervisor ? supervisor.at : 0, startedAt);
218
+ return {
219
+ id,
220
+ state: data.state,
221
+ startedAt,
222
+ step: last.id,
223
+ steps,
224
+ supervisor,
225
+ error: startCleanError(data.error) || (supervisor && supervisor.error) || null,
226
+ log: typeof data.log === 'string' ? data.log.slice(0, 400) : '',
227
+ // On the launcher's own clock: how long since it last saw something happen.
228
+ quietMs: Math.max(0, (Number(data.now) || newest) - newest),
229
+ };
230
+ }
231
+
232
+ /** Fetch the beacon once: its JSON, or null when nothing is there to answer. */
233
+ export function fetchStartBeacon(target, { fetchImpl, timeoutMs = START_LIMITS.beaconTimeoutMs } = {}) {
234
+ const doFetch = fetchImpl || (typeof fetch === 'function' ? fetch : null);
235
+ if (!target || !doFetch) return Promise.resolve(null);
236
+ const options = { cache: 'no-store', credentials: 'omit', mode: 'cors' };
237
+ try {
238
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') options.signal = AbortSignal.timeout(timeoutMs);
239
+ } catch {}
240
+ return Promise.resolve()
241
+ .then(() => doFetch(target.url, options))
242
+ .then(res => (res && res.ok ? res.json() : null))
243
+ .catch(() => null);
244
+ }
245
+
246
+ // ═══════════════════════════════════════════
247
+ // The button's life
248
+ // ═══════════════════════════════════════════
249
+
250
+ /**
251
+ * idle → launching → starting | waiting → stalled | failed, or online at any
252
+ * point. The page keeps probing in every state, so a server started by hand is
253
+ * noticed just the same.
254
+ * probe() → Promise<boolean> is the server answering?
255
+ * launch() → hand START_LINK to the OS
256
+ * beacon() → Promise<object|null> the launch's raw report (optional)
257
+ * port → the server's port (what a beacon must be about)
258
+ * onState(state, detail) on every change of state
259
+ * onUpdate(snapshot) whenever what there is to show changed: the
260
+ * state, what the launch reported, or the second
261
+ *
262
+ * States:
263
+ * 'idle' nothing asked
264
+ * 'launching' the link was handed to the OS; nothing has confirmed it
265
+ * 'starting' the launcher itself reports a start under way (evidence)
266
+ * 'waiting' hintAfterMs with no word from the launcher
267
+ * 'stalled' stallAfterMs with no word, or the launcher gave up waiting
268
+ * 'failed' the launcher reports that the start failed, and why
269
+ * 'online' the server answers this page
270
+ */
271
+ export function createStartBridge({
272
+ probe,
273
+ launch,
274
+ beacon = null,
275
+ port = 0,
276
+ onState = () => {},
277
+ onUpdate = () => {},
278
+ limits = START_LIMITS,
279
+ now = () => Date.now(),
280
+ setTimer = (fn, ms) => setTimeout(fn, ms),
281
+ clearTimer = (id) => clearTimeout(id),
282
+ } = {}) {
283
+ const lim = { ...START_LIMITS, ...limits };
284
+ let state = 'idle';
285
+ let askedAt = null; // when this page handed the link over (null: it has not)
286
+ let since = null; // what the elapsed time counts from
287
+ let clicks = 0;
288
+ let timer = null;
289
+ let stopped = false;
290
+ let probing = false;
291
+ let launchInfo = null; // the launch as last reported
292
+ let launchSeenAt = 0;
293
+ let idleChecks = 0;
294
+ let shown = '';
295
+ const past = [];
296
+
297
+ const busy = () => state === 'launching' || state === 'starting' || state === 'waiting';
298
+
299
+ function snapshot() {
300
+ return {
301
+ state,
302
+ clicks,
303
+ askedAt,
304
+ since,
305
+ elapsedMs: since === null ? 0 : Math.max(0, now() - since),
306
+ launch: launchInfo,
307
+ };
308
+ }
309
+
310
+ function update() {
311
+ const snap = snapshot();
312
+ const l = snap.launch;
313
+ const key = [
314
+ snap.state, snap.clicks, busy() ? Math.floor(snap.elapsedMs / 1000) : '',
315
+ l ? [l.id, l.state, l.step, l.supervisor?.phase, l.supervisor?.detail?.step, l.supervisor?.detail?.files, l.quietMs >= lim.quietAfterMs, l.error?.message].join('|') : '',
316
+ ].join('/');
317
+ if (key === shown) return;
318
+ shown = key;
319
+ try { onUpdate(snap); } catch {}
320
+ }
321
+
322
+ function set(next, detail = {}) {
323
+ if (state === next) return;
324
+ state = next;
325
+ try { onState(state, { clicks, ...detail }); } catch {}
326
+ }
327
+
328
+ function schedule(delay) {
329
+ if (stopped || state === 'online') return;
330
+ if (timer) clearTimer(timer);
331
+ timer = setTimer(tick, delay ?? (busy() ? lim.pollMs : lim.idlePollMs));
332
+ }
333
+
334
+ /** What the launch says, when there is one to listen to right now. */
335
+ async function listen() {
336
+ if (!beacon) return;
337
+ // Idle: only just after load, for a start that was already under way.
338
+ if (state === 'idle' && idleChecks >= lim.idleBeaconChecks) return;
339
+ if (state === 'idle') idleChecks++;
340
+ // Failed: the launcher said how it ended, there is no more to hear. Stalled
341
+ // keeps listening: a prompt answered a minute late still starts a launch.
342
+ if (state === 'failed') return;
343
+ let raw = null;
344
+ try { raw = await beacon(); } catch {}
345
+ if (stopped || state === 'online') return;
346
+ const read = readStartBeacon(raw, { port, askedAt, following: launchInfo?.id || null, past });
347
+ if (read) {
348
+ launchInfo = read;
349
+ launchSeenAt = now();
350
+ if (since === null) since = read.startedAt; // a start this page did not ask for: count from its own beginning
351
+ if (read.state === 'failed') set('failed');
352
+ else if (read.state === 'gave-up') set('stalled');
353
+ else set('starting');
354
+ return;
355
+ }
356
+ // It was reporting and stopped: a launcher that ended without this page
357
+ // reading how. What it said last is no longer known to be true.
358
+ if (state === 'starting' && now() - launchSeenAt >= lim.lostAfterMs) {
359
+ launchInfo = null;
360
+ if (askedAt === null) { since = null; set('idle'); } // a start this page never asked for: back to watching
361
+ else set(now() - askedAt >= lim.hintAfterMs ? 'waiting' : 'launching');
362
+ }
363
+ }
364
+
365
+ async function tick() {
366
+ timer = null;
367
+ if (stopped || probing) return;
368
+ probing = true;
369
+ let up = false;
370
+ try { up = (await probe()) === true; } catch {}
371
+ if (!up && !stopped) await listen();
372
+ probing = false;
373
+ if (stopped) return;
374
+ if (up) { set('online'); update(); return; }
375
+ if (state === 'launching' || state === 'waiting') {
376
+ const waited = now() - askedAt;
377
+ if (waited >= lim.stallAfterMs) set('stalled');
378
+ else if (waited >= lim.hintAfterMs) set('waiting');
379
+ }
380
+ update();
381
+ schedule();
382
+ }
383
+
384
+ return {
385
+ get state() { return state; },
386
+ snapshot,
387
+ /** Begin watching for the server (call once the page is up). */
388
+ watch() { schedule(0); },
389
+ /** The click. Ignored while a start is already under way. */
390
+ start() {
391
+ if (stopped || state === 'online' || busy()) return false;
392
+ clicks++;
393
+ askedAt = now();
394
+ since = askedAt;
395
+ // What the last try reported is over: a launcher still saying so is not this one.
396
+ if (launchInfo && !past.includes(launchInfo.id)) past.push(launchInfo.id);
397
+ launchInfo = null;
398
+ state = 'idle'; // a second try after 'stalled' announces 'launching' again
399
+ set('launching');
400
+ update();
401
+ try { launch(); } catch {}
402
+ schedule();
403
+ return true;
404
+ },
405
+ stop() {
406
+ stopped = true;
407
+ if (timer) clearTimer(timer);
408
+ timer = null;
409
+ },
410
+ };
411
+ }
412
+
413
+ // ═══════════════════════════════════════════
414
+ // What to show
415
+ // ═══════════════════════════════════════════
416
+
417
+ // The sentences, in English. The offline page uses them as they are; the app
418
+ // reads the same keys from its dictionary (`loading.start.<key>` in
419
+ // i18n/*.json, kept equal to these by tests/start-bridge.test.mjs).
420
+ export const START_COPY = {
421
+ asked: 'Asked your system to start SynaBun. If your browser shows a prompt, choose Open.',
422
+ unconfirmedMac: 'No word from the launcher yet. If your browser is asking for permission, choose Open: a Terminal window then opens with the server.',
423
+ unconfirmedWindows: 'No word from the launcher yet. If your browser is asking for permission, choose Open: a console window then opens with the server.',
424
+ unconfirmedLinux: 'No word from the launcher yet. If your browser is asking for permission, choose Open: the server then starts in the background.',
425
+ stalled: 'Nothing has answered. If no prompt or window appeared, run the command below once: it also repairs this button.',
426
+ launcherRunning: 'The launcher is running.',
427
+ terminalOpened: 'A Terminal window was opened for the server.',
428
+ terminalQuiet: 'A Terminal window was opened {seconds} s ago, but the server has not started in it. Look at that window.',
429
+ processStarted: 'The server\'s supervisor was started.',
430
+ checking: 'Checking the install.',
431
+ dependencies: 'Installing dependencies with npm. A first start, or the first after an update, takes a few minutes.',
432
+ browser: 'Downloading the automation browser. This happens once.',
433
+ build: 'Building the MCP server.',
434
+ snapshot: 'Backing up your data before the first start of this version.',
435
+ snapshotSized: 'Backing up your data before the first start of this version: {files} files, {megabytes} MB.',
436
+ server: 'The server process is running. Waiting for it to answer.',
437
+ listening: 'The server is listening. Connecting.',
438
+ answered: 'The server answered the launcher. Connecting.',
439
+ quiet: 'Nothing new from the start for {seconds} s.',
440
+ failed: 'The start failed: {reason}',
441
+ failedHint: 'Run the command below to see the full output, or try again.',
442
+ failedLog: 'Launcher log: {path}',
443
+ gaveUp: 'The server was started but has not answered. Check its window, or run the command below.',
444
+ online: 'The server is up. Loading SynaBun.',
445
+ trailLauncher: 'Launcher started',
446
+ trailTerminal: 'Terminal opened',
447
+ trailProcess: 'Supervisor started',
448
+ trailSupervisor: 'Supervisor running',
449
+ trailServer: 'Server process started',
450
+ trailListening: 'Server listening',
451
+ elapsedLabel: 'Time since Start was pressed',
452
+ headingStarting: 'Starting the server',
453
+ headingReady: 'Server is up',
454
+ headingFailed: 'The server did not start',
455
+ };
456
+
457
+ /** `{name}` placeholders, like the app's t(). */
458
+ export function formatStartText(template, params) {
459
+ return String(template ?? '').replace(/\{(\w+)\}/g, (all, key) => (params && params[key] != null ? String(params[key]) : all));
460
+ }
461
+
462
+ /** 7 s · 1 min 05 s. Whole seconds: the page's own clock, nothing finer is claimed. */
463
+ export function formatStartElapsed(ms) {
464
+ const total = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
465
+ if (total < 60) return `${total} s`;
466
+ return `${Math.floor(total / 60)} min ${String(total % 60).padStart(2, '0')} s`;
467
+ }
468
+
469
+ /** 0.4 s for a step's own time: these come with milliseconds. */
470
+ function startStepSeconds(ms) {
471
+ const value = Math.max(0, Number(ms) || 0) / 1000;
472
+ return value < 10 ? `${value.toFixed(1)} s` : `${Math.round(value)} s`;
473
+ }
474
+
475
+ /**
476
+ * Everything a surface renders for one snapshot, as data:
477
+ * mascot 'asleep' | 'waking' | 'working' | 'ready' | 'failed' | 'stalled'
478
+ * busy the button is disabled (a start is under way)
479
+ * button 'start' | 'starting' | 'retry'
480
+ * headline { key, params } | null the status sentence (the live region)
481
+ * hint { key, params } | null a second, quieter sentence
482
+ * elapsed '12 s' | '' the page's clock since the click
483
+ * trail [{ key, time }] what was reported, with when
484
+ * error { message, log } | null
485
+ * A headline names a step only when the snapshot holds the report of it.
486
+ */
487
+ export function startStatusView(snap, { platform = 'linux', limits = START_LIMITS } = {}) {
488
+ const state = snap?.state || 'idle';
489
+ const launchOf = snap?.launch || null;
490
+ const sup = launchOf?.supervisor || null;
491
+ const view = {
492
+ state,
493
+ mascot: 'asleep',
494
+ busy: state === 'launching' || state === 'starting' || state === 'waiting',
495
+ button: 'start',
496
+ headline: null,
497
+ hint: null,
498
+ elapsed: '',
499
+ trail: [],
500
+ error: null,
501
+ };
502
+ if (view.busy) {
503
+ view.button = 'starting';
504
+ view.elapsed = formatStartElapsed(snap.elapsedMs);
505
+ }
506
+
507
+ // The reported steps, each at the time it was reported for, counted from the
508
+ // click (or from the launch's own start when another page asked for it).
509
+ if (launchOf) {
510
+ const asked = snap.askedAt !== null && snap.askedAt !== undefined && launchOf.startedAt >= snap.askedAt - 2000;
511
+ const zero = asked ? snap.askedAt : launchOf.startedAt;
512
+ const add = (key, at) => { if (at) view.trail.push({ key, time: startStepSeconds(at - zero) }); };
513
+ add('trailLauncher', launchOf.startedAt);
514
+ for (const step of launchOf.steps) {
515
+ if (step.id === 'terminal') add('trailTerminal', step.at);
516
+ if (step.id === 'spawned') add('trailProcess', step.at);
517
+ }
518
+ if (sup) {
519
+ add('trailSupervisor', sup.startedAt);
520
+ const first = (phase) => sup.phases.find(p => p.phase === phase)?.at || (sup.phase === phase ? sup.at : 0);
521
+ add('trailServer', first('server'));
522
+ add('trailListening', first('listening'));
523
+ }
524
+ }
525
+
526
+ if (state === 'online') {
527
+ view.mascot = 'ready';
528
+ view.headline = { key: 'online', params: {} };
529
+ return view;
530
+ }
531
+ if (state === 'launching') {
532
+ view.mascot = 'waking';
533
+ view.headline = { key: 'asked', params: {} };
534
+ return view;
535
+ }
536
+ if (state === 'waiting') {
537
+ view.mascot = 'waking';
538
+ view.headline = { key: platform === 'mac' ? 'unconfirmedMac' : platform === 'windows' ? 'unconfirmedWindows' : 'unconfirmedLinux', params: {} };
539
+ return view;
540
+ }
541
+ if (state === 'stalled') {
542
+ view.mascot = 'stalled';
543
+ view.headline = launchOf?.state === 'gave-up' ? { key: 'gaveUp', params: {} } : { key: 'stalled', params: {} };
544
+ return view;
545
+ }
546
+ if (state === 'failed') {
547
+ view.mascot = 'failed';
548
+ view.button = 'retry';
549
+ const reason = launchOf?.error?.message || '';
550
+ view.headline = { key: 'failed', params: { reason } };
551
+ view.hint = { key: 'failedHint', params: {} };
552
+ view.error = { message: reason, log: launchOf?.log || '' };
553
+ return view;
554
+ }
555
+ if (state === 'starting' && launchOf) {
556
+ view.mascot = 'working';
557
+ const quiet = Math.floor(launchOf.quietMs / 1000);
558
+ const isQuiet = launchOf.quietMs >= limits.quietAfterMs;
559
+ if (launchOf.state === 'started') {
560
+ view.headline = { key: 'answered', params: {} };
561
+ } else if (sup && sup.phase !== 'failed') {
562
+ const megabytes = sup.detail.bytes !== null ? Math.round(sup.detail.bytes / 1048576) : null;
563
+ if (sup.phase === 'snapshot' && sup.detail.files !== null && megabytes !== null) {
564
+ view.headline = { key: 'snapshotSized', params: { files: sup.detail.files, megabytes } };
565
+ } else {
566
+ view.headline = { key: sup.phase, params: {} };
567
+ }
568
+ // Slow on purpose (an install, a download, a backup): silence there is expected.
569
+ const slow = ['dependencies', 'browser', 'build', 'snapshot'].includes(sup.phase);
570
+ if (isQuiet && !slow) view.hint = { key: 'quiet', params: { seconds: quiet } };
571
+ } else if (launchOf.step === 'terminal') {
572
+ view.headline = isQuiet ? { key: 'terminalQuiet', params: { seconds: quiet } } : { key: 'terminalOpened', params: {} };
573
+ } else if (launchOf.step === 'spawned') {
574
+ view.headline = { key: 'processStarted', params: {} };
575
+ if (isQuiet) view.hint = { key: 'quiet', params: { seconds: quiet } };
576
+ } else {
577
+ view.headline = { key: 'launcherRunning', params: {} };
578
+ if (isQuiet) view.hint = { key: 'quiet', params: { seconds: quiet } };
579
+ }
580
+ return view;
581
+ }
582
+ return view;
583
+ }
@@ -38,6 +38,7 @@ export function render(host, s) {
38
38
  host.append(card('palettes', palettes, row(name, add, fromLogo)));
39
39
  const semantic = el('div', 'sg-table-wrap');
40
40
  semantic.tabIndex = 0;
41
+ semantic.setAttribute('role', 'region');
41
42
  semantic.setAttribute('aria-label', label('semantic'));
42
43
  const table = el('table', 'sg-table');
43
44
  const head = el('tr');
@@ -86,10 +87,12 @@ export function render(host, s) {
86
87
  return box;
87
88
  }
88
89
  const matrix = el('section', 'sg-card');
89
- matrix.append(el('h3', '', t('styleguide.contrast')), el('p', 'sg-purpose', t('styleguide.contrastHelp')));
90
+ const matrixBody = el('div', 'sg-card-body');
91
+ matrix.append(el('h3', '', t('styleguide.contrast')), el('p', 'sg-purpose', t('styleguide.contrastHelp')), matrixBody);
90
92
  for (const theme of ['light', 'dark']) {
91
93
  const wrap = el('div', 'sg-table-wrap');
92
94
  wrap.tabIndex = 0;
95
+ wrap.setAttribute('role', 'region');
93
96
  wrap.setAttribute('aria-label', `${t('styleguide.contrast')} · ${t(`styleguide.${theme}`)}`);
94
97
  const tab = el('table', 'sg-table sg-contrast-matrix');
95
98
  tab.dataset.theme = theme;
@@ -110,7 +113,7 @@ export function render(host, s) {
110
113
  tab.append(tr);
111
114
  }
112
115
  wrap.append(tab);
113
- matrix.append(wrap);
116
+ matrixBody.append(wrap);
114
117
  }
115
118
  host.append(matrix);
116
119
  const update = () => {
@@ -20,15 +20,16 @@ export async function render(host, s) {
20
20
  return; preview = { ...data, kind }; diff.replaceChildren(diffView(data.diff)); warnings.replaceChildren(...(data.warnings || []).map(w => el('p', '', w))); apply.disabled = false; };
21
21
  const gallery = el('div', 'sg-grid');
22
22
  host.append(card('import', row(format, strategy), upload, paste, community, el('p', 'sg-hint', t('styleguide.attribution')), row(button(t('styleguide.scan'), () => { kind = 'codebase'; format.querySelector('select').value = kind; invalidate(); return makePreview(); }), button(t('styleguide.previewDiff'), makePreview), apply), warnings, diff));
23
- const presetCard = el('section', 'sg-card');
24
- presetCard.append(el('h3', '', t('styleguide.presets')), el('p', 'sg-purpose', t('styleguide.presetsHelp')), gallery);
23
+ const presetCard = el('section', 'sg-card'), presetBody = el('div', 'sg-card-body');
24
+ presetBody.append(gallery);
25
+ presetCard.append(el('h3', '', t('styleguide.presets')), el('p', 'sg-purpose', t('styleguide.presetsHelp')), presetBody);
25
26
  host.append(presetCard);
26
27
  const exports = card('exports', grid(toggle(s, 'exports.designMd'), toggle(s, 'exports.tokensJson'), toggle(s, 'exports.cssVars'), select(s, 'exports.tailwind', ['v4', 'v3', 'none']), select(s, 'exports.darkMode', ['attribute', 'media', 'class']), text(s, 'exports.cssSelector'), text(s, 'exports.outDir')), button(t('styleguide.writeNow'), async () => { await s.flush({ force: !s.meta.saved }); const data = await s.action(path => writeStyleGuideExports(path)); drawWritten(data.written); }), written);
27
28
  host.append(exports);
28
29
  const copies = row();
29
30
  for (const [format, name] of [['design-md', 'DESIGN.md'], ['dtcg', 'tokens.json'], ['css', 'tokens.css'], ['tailwind-v4', 'Tailwind v4'], ['tailwind-v3', 'Tailwind v3'], ['summary', t('styleguide.summary')], ['json', 'JSON']])
30
31
  copies.append(button(`${t('styleguide.copy')} · ${name}`, async () => { await s.flush(); await navigator.clipboard.writeText(await fetchStyleGuideExport(s.projectPath, format)); }));
31
- exports.append(copies);
32
+ exports.querySelector('.sg-card-body').append(copies);
32
33
  function drawWritten(files) { written.replaceChildren(el('h4', '', t('styleguide.written'))); if (!files?.length)
33
34
  written.append(el('p', 'sg-hint', t('styleguide.writeEmpty'))); for (const f of files || [])
34
35
  written.append(row(el('code', '', f.path), button(t('styleguide.copy'), () => navigator.clipboard.writeText(f.path)))); }
@@ -11,7 +11,9 @@ export const grid = (...children) => { const n = el('div', 'sg-grid'); n.append(
11
11
  export function card(key, ...children) {
12
12
  const n = el('section', 'sg-card');
13
13
  n.dataset.card = key;
14
- n.append(el('h3', '', t(`styleguide.cards.${key}.title`)), el('p', 'sg-purpose', t(`styleguide.cards.${key}.purpose`)), ...children);
14
+ const body = el('div', 'sg-card-body');
15
+ body.append(...children);
16
+ n.append(el('h3', '', t(`styleguide.cards.${key}.title`)), el('p', 'sg-purpose', t(`styleguide.cards.${key}.purpose`)), body);
15
17
  return n;
16
18
  }
17
19
  export function button(text, handler, cls = '') {
@@ -223,6 +225,7 @@ export function colorField(s, path, name, onValue, { eyedropper = true } = {}) {
223
225
  export function diffView(diff = []) {
224
226
  const host = el('div', 'sg-table-wrap');
225
227
  host.tabIndex = 0;
228
+ host.setAttribute('role', 'region');
226
229
  host.setAttribute('aria-label', t('styleguide.previewDiff'));
227
230
  if (!diff.length) {
228
231
  host.append(el('p', 'sg-hint', t('styleguide.noDiff')));