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.
- package/CHANGELOG.md +8 -0
- package/THIRD-PARTY-LICENSES.md +5 -0
- package/launcher.mjs +40 -0
- package/lib/claude-hooks.js +61 -8
- package/lib/client-config-repair.js +4 -3
- package/lib/data-home-migration.js +4 -0
- package/lib/desktop-pwa.js +206 -0
- package/lib/external-tools.js +221 -0
- package/lib/first-launch-protection.js +5 -0
- package/lib/packaged-runtime.js +110 -0
- package/lib/start-launcher.js +1309 -0
- package/neural-interface/i18n/en.json +41 -2
- package/neural-interface/i18n/pt-BR.json +41 -2
- package/neural-interface/lib/assistant-envelope.js +9 -6
- package/neural-interface/lib/augmented-path.js +23 -3
- package/neural-interface/lib/browser-language.js +83 -0
- package/neural-interface/lib/claude-agent-bridge.js +54 -147
- package/neural-interface/lib/claude-executable.js +241 -0
- package/neural-interface/lib/claude-model-catalog.js +52 -91
- package/neural-interface/lib/native-binary-runtime.js +69 -337
- package/neural-interface/lib/native-loop-providers.js +30 -44
- package/neural-interface/lib/offline-page.js +54 -0
- package/neural-interface/lib/session-title-generator.js +5 -0
- package/neural-interface/lib/synabun-update-plan.js +19 -0
- package/neural-interface/package-lock.json +0 -251
- package/neural-interface/public/install-app.html +31 -0
- package/neural-interface/public/manifest.json +2 -0
- package/neural-interface/public/offline.html +401 -66
- package/neural-interface/public/onboarding.html +252 -146
- package/neural-interface/public/shared/assistant/asst-mascot.js +12 -10
- package/neural-interface/public/shared/assistant/asst-rack-styles.js +0 -3
- package/neural-interface/public/shared/assistant/asst-render.js +58 -49
- package/neural-interface/public/shared/assistant/asst-styles.js +4 -2
- package/neural-interface/public/shared/command-runner-model.js +30 -0
- package/neural-interface/public/shared/html-shell.js +9 -5
- package/neural-interface/public/shared/pwa-install-page.js +34 -0
- package/neural-interface/public/shared/pwa-install.css +12 -0
- package/neural-interface/public/shared/pwa-install.js +28 -0
- package/neural-interface/public/shared/settings/settings-rules.js +160 -0
- package/neural-interface/public/shared/start-bridge.js +583 -0
- package/neural-interface/public/shared/styleguide/sg-colors.js +5 -2
- package/neural-interface/public/shared/styleguide/sg-import-export.js +4 -3
- package/neural-interface/public/shared/styleguide/sg-kit.js +4 -1
- package/neural-interface/public/shared/styleguide/sg-panel.js +9 -1
- package/neural-interface/public/shared/styleguide/sg-typography.js +1 -0
- package/neural-interface/public/shared/styles.css +801 -392
- package/neural-interface/public/shared/term-ready.js +307 -0
- package/neural-interface/public/shared/ui-claude-panel.js +3 -3
- package/neural-interface/public/shared/ui-command-runner.js +251 -468
- package/neural-interface/public/shared/ui-judgments.js +1 -1
- package/neural-interface/public/shared/ui-loading.js +221 -10
- package/neural-interface/public/shared/ui-menubar.js +11 -27
- package/neural-interface/public/shared/ui-settings.js +36 -59
- package/neural-interface/public/shared/ui-terminal.js +130 -144
- package/neural-interface/public/sw.js +3 -1
- package/neural-interface/public/variant/2d/main.js +2 -1
- package/neural-interface/public/variant/3d/main.js +2 -1
- package/neural-interface/scripts/rebuild-pty.js +15 -12
- package/neural-interface/server.js +220 -185
- package/package.json +13 -1
- package/postinstall.js +11 -81
- package/setup.js +72 -4
- package/docs/browser-v2.md +0 -109
- package/docs/hook-integrity.md +0 -89
- package/docs/judgments.md +0 -182
- package/docs/style-guide.md +0 -246
- package/scripts/audit-claude-hook-usage.mjs +0 -12
- package/scripts/audit-memory-duplicates.mjs +0 -44
- package/scripts/measure-claude-hook-overhead.mjs +0 -77
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')));
|