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
@@ -2,9 +2,10 @@
2
2
  // SynaBun Assistant — the panel's mascot director
3
3
  // ═══════════════════════════════════════════
4
4
  // The renderer (asst-render.js) owns the rigs and says which one is on screen:
5
- // the hero (the empty state's character, parked after a send until the first
6
- // stage takes it over) or the live rack's stage. This module dresses that one
7
- // in the panel's own state, above the pose the renderer gives it:
5
+ // the hero (the empty state's character; a send hands it over to the
6
+ // "Thinking" row's at once), the live rack's stage or the "Thinking" row's.
7
+ // This module dresses that one in the panel's own state, above the pose the
8
+ // renderer gives it:
8
9
  //
9
10
  // offline the socket dropped (the Reconnecting banner); one startle on reconnect
10
11
  // blocked a card waits on you: eyes on it, two pulses, then still
@@ -14,12 +15,13 @@
14
15
  // sleep the hero only, after 90 s with no input while nothing runs;
15
16
  // the rig wakes itself on a pointer move or a key
16
17
  //
17
- // Anything else is the renderer's pose: the hero idles (thinks once parked),
18
- // the stage acts out its call. A pose the director sets is held: the renderer
19
- // leaves it alone until the director lets go and asks it to restore its own.
20
- // Only one panel animates its rigs: the visible one used last (typing, focus,
21
- // a new stage). Pure logic: the clock, timers and page input come in through
22
- // `env`, so node:test drives it with fakes (tests/assistant-mascot.test.mjs).
18
+ // Anything else is the renderer's pose: the hero idles, the "Thinking" row's
19
+ // character thinks, the stage acts out its call. A pose the director sets is
20
+ // held: the renderer leaves it alone until the director lets go and asks it
21
+ // to restore its own. Only one panel animates its rigs: the visible one used
22
+ // last (typing, focus, a new stage). Pure logic: the clock, timers and page
23
+ // input come in through `env`, so node:test drives it with fakes
24
+ // (tests/assistant-mascot.test.mjs).
23
25
 
24
26
  export const MASCOT_TIMING = Object.freeze({
25
27
  watchEveryMs: 120,
@@ -254,7 +256,7 @@ export function createMascotDirector({ getTarget = () => null, caret = () => nul
254
256
  type,
255
257
  /** Blur, or the draft went away. */
256
258
  stopTyping,
257
- /** A prompt went out: the watch hands over at once (the renderer parks the hero thinking). */
259
+ /** A prompt went out: the watch hands over at once (the renderer gives the hero to the "Thinking" row). */
258
260
  sent() { touch(); stopTyping(); },
259
261
  /** A card waits on the user (`rectOf()` → its box), or none (null). */
260
262
  setBlocked(rectOf) {
@@ -98,9 +98,6 @@ export const RACK_CSS = `
98
98
  }
99
99
  .asst-rack-face { grid-area: face; align-self: center; color: var(--rk-text); line-height: 0; }
100
100
  .asst-rack-face svg { display: block; width: 72px; height: 36px; }
101
- /* The hero's hand-off (asst-render.js flipIntoStage): the face flies in from outside the stage. */
102
- .asst-rack.is-flip > .asst-rack-stage { overflow: visible; }
103
- .asst-rack.is-flip .asst-rack-face { position: relative; z-index: 3; }
104
101
  .asst-rack-caption {
105
102
  grid-area: caption;
106
103
  align-self: end;
@@ -11,12 +11,12 @@
11
11
  // Replay runs the same handleEvent path with { silent: true }: live minus
12
12
  // motion, durations from the journal's `at`. Every row is inserted before the
13
13
  // "Working…" indicator so it always stays last.
14
- // The empty state's character (the hero) outlives a send: parked where it
15
- // stood, thinking, until the first stage of the turn takes it over (the
16
- // stage's face flies in from the hero's box) or other content arrives (it
17
- // fades out). mascot() names the character on screen for the panel's
18
- // director (asst-mascot.js), which may hold its pose; stills are painted SVGs,
19
- // never rigs.
14
+ // The empty state's character (the hero) outlives a send only for its
15
+ // hand-off: the "Thinking" row's character flies in from the hero's box at
16
+ // once, so nothing sits on the prompt, and with no such row the hero fades
17
+ // out. mascot() names the character on screen for the panel's director
18
+ // (asst-mascot.js), which may hold its pose; stills are painted SVGs, never
19
+ // rigs.
20
20
 
21
21
  import { assistantEventKey, fmtCost, fmtElapsed, isRemovableRun, isRunIdle, isTerminalRunStatus, modelShortName, providerShortLabel, routeTargetLabel, runTone, summarizeMailbox } from './asst-state.js';
22
22
  import { createRouteLine } from './asst-route.js';
@@ -408,8 +408,8 @@ const BURST_MS = 800; // 3+ calls started within this window read "newest ×N"
408
408
  const STATUS_GAP_MS = 1500; // screen-reader announcements at most this often
409
409
  const FOLD_MS = 180; // narration collapsing into the stage caption
410
410
  const COLLAPSE_MS = 260; // the stage folding into the receipt
411
- const FLIP_MS = 320; // the hero flying into the first stage (--asst-dur-slow)
412
- const HERO_LEAVE_MS = 140; // a parked hero nobody took over fades out
411
+ const FLIP_MS = 320; // the hero flying into the "Thinking" row (--asst-dur-slow)
412
+ const HERO_LEAVE_MS = 140; // a parked hero the "Thinking" row did not take over fades out
413
413
  const MAX_TICKS = 60;
414
414
  const NARROW_TICKS = 12;
415
415
  const PREVIEW_LINES = 5;
@@ -535,7 +535,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
535
535
  return scope;
536
536
  }
537
537
 
538
- /** The empty state goes; `park` (the user sent a prompt) keeps its character on screen, thinking. */
538
+ /** The empty state goes; `park` (a turn starts) hands its character over to the "Thinking" row. */
539
539
  function hideEmpty({ park = false } = {}) {
540
540
  if (!emptyEl) return;
541
541
  if (!(park && parkHero())) dropHero();
@@ -561,9 +561,11 @@ export function createRenderer(transcriptEl, hooks = {}) {
561
561
 
562
562
  /**
563
563
  * Park the hero where it stands, in an overlay of the transcript's parent,
564
- * so it outlives the empty state and thinks until the first stage of the
565
- * turn takes it over. Not on replay, under reduced motion or while the UI
566
- * is dragged, and not when it is off screen.
564
+ * so it outlives the empty state for its hand-off. It stays there only
565
+ * until the rows of this task are in (the prompt, "Thinking"): settleHero()
566
+ * then gives it to the "Thinking" row, so it is never drawn over the
567
+ * prompt that took the empty state's place. Not on replay, under reduced
568
+ * motion or while the UI is dragged, and not when it is off screen.
567
569
  */
568
570
  function parkHero() {
569
571
  const h = hero;
@@ -585,10 +587,33 @@ export function createRenderer(transcriptEl, hooks = {}) {
585
587
  h.dock = dock;
586
588
  h.parked = true;
587
589
  if (!h.held) h.rig.setPose('think');
590
+ queueMicrotask(() => settleHero(h));
588
591
  mascotChanged();
589
592
  return true;
590
593
  }
591
594
 
595
+ /**
596
+ * The parked hero's hand-off, once the task that parked it has put its rows
597
+ * in: the "Thinking" row's character flies in from the hero's box and the
598
+ * hero leaves in the same frame. With no such row to take it over (a slash
599
+ * command's echo, a rack already live) it fades out.
600
+ */
601
+ function settleHero(h) {
602
+ if (hero !== h || !h.parked || h.leaving) return;
603
+ if (!workingFree()) { dismissHero(); return; }
604
+ const from = h.el.getBoundingClientRect();
605
+ dropHero(); // the "Thinking" row wears the character now (syncWorkingFace)
606
+ const face = workingFace;
607
+ if (!face?.rig) return;
608
+ const row = working;
609
+ row.classList.add('is-flip');
610
+ const anim = flipFrom(face.el, from);
611
+ const done = () => row.classList.remove('is-flip');
612
+ if (!anim) { done(); return; }
613
+ anim.onfinish = done;
614
+ anim.oncancel = done;
615
+ }
616
+
592
617
  function dropHero() {
593
618
  const h = hero;
594
619
  if (!h) return;
@@ -600,20 +625,15 @@ export function createRenderer(transcriptEl, hooks = {}) {
600
625
  syncWorkingFace(); // the "Thinking" row may wear the character now
601
626
  }
602
627
 
603
- /** A parked hero no stage took over leaves: it fades out (at once when calm), after `delay` ms. */
604
- function dismissHero(delay = 0) {
628
+ /** A parked hero the "Thinking" row did not take over leaves: it fades out (at once when calm). */
629
+ function dismissHero() {
605
630
  const h = hero;
606
631
  if (!h?.parked || h.leaving) return;
607
632
  h.leaving = true;
608
- const go = () => {
609
- if (hero !== h) return;
610
- if (calm() || typeof h.dock.animate !== 'function') { dropHero(); return; }
611
- const anim = h.dock.animate([{ opacity: 1 }, { opacity: 0 }], { duration: HERO_LEAVE_MS, easing: cssEase('--asst-ease-exit', 'cubic-bezier(.2,0,1,.9)'), fill: 'forwards' });
612
- anim.onfinish = () => { if (hero === h) dropHero(); };
613
- h.leaveTimer = setTimeout(() => { if (hero === h) dropHero(); }, HERO_LEAVE_MS + 120);
614
- };
615
- if (delay > 0) h.leaveTimer = setTimeout(go, delay);
616
- else go();
633
+ if (calm() || typeof h.dock.animate !== 'function') { dropHero(); return; }
634
+ const anim = h.dock.animate([{ opacity: 1 }, { opacity: 0 }], { duration: HERO_LEAVE_MS, easing: cssEase('--asst-ease-exit', 'cubic-bezier(.2,0,1,.9)'), fill: 'forwards' });
635
+ anim.onfinish = () => { if (hero === h) dropHero(); };
636
+ h.leaveTimer = setTimeout(() => { if (hero === h) dropHero(); }, HERO_LEAVE_MS + 120);
617
637
  }
618
638
 
619
639
  /**
@@ -635,19 +655,6 @@ export function createRenderer(transcriptEl, hooks = {}) {
635
655
  ], { duration: FLIP_MS, easing: cssEase('--asst-ease-emphasized', 'cubic-bezier(.4,.14,.3,1)') });
636
656
  }
637
657
 
638
- /** The hand-off: the first stage's face flies in from the parked hero, which leaves in the same frame. */
639
- function flipIntoStage(rack) {
640
- const h = hero;
641
- const from = h.el.getBoundingClientRect();
642
- dropHero();
643
- rack.row.classList.add('is-flip');
644
- const anim = flipFrom(rack.stage.face, from);
645
- const done = () => rack.row.classList.remove('is-flip');
646
- if (!anim) { done(); return; }
647
- anim.onfinish = done;
648
- anim.oncancel = done;
649
- }
650
-
651
658
  /** Triple-click the character: its eyes part and re-form, once per mount. Silent. */
652
659
  function wireEgg(h) {
653
660
  const hit = (e) => {
@@ -709,7 +716,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
709
716
  if (scope.container === $msgs && working && working.parentNode === $msgs) $msgs.insertBefore(node, working);
710
717
  else scope.container.appendChild(node);
711
718
  if (scope === rootScope) prune();
712
- // Text, a card, a notice…: the parked hero would sit on it. Only a rack (its stage takes it over) and the prompt keep it.
719
+ // Text, a card, a notice…: the parked hero would sit on it. The prompt and a rack leave it to settleHero().
713
720
  if (hero?.parked && scope === rootScope && !node._rack && !node.classList.contains('msg-user')) dismissHero();
714
721
  if (racks.size) syncRacks();
715
722
  scrollEnd();
@@ -1495,13 +1502,10 @@ export function createRenderer(transcriptEl, hooks = {}) {
1495
1502
  rack.clearTimer = 0;
1496
1503
  rack.row.classList.add('is-staged');
1497
1504
  if (!rack.rig) {
1498
- // The parked hero hands over: the stage's character starts in its pose and flies in from its box.
1499
- const heir = hero?.parked && !hero.leaving && !calm() ? hero : null;
1500
- try { rack.rig = mascotLib.createMascot(rack.stage.face, { width: 72, height: 36, rangeX: 10, rangeY: 7, active: rigsOn, pose: heir ? heir.rig.pose : 'idle' }); } catch { rack.rig = null; }
1505
+ try { rack.rig = mascotLib.createMascot(rack.stage.face, { width: 72, height: 36, rangeX: 10, rangeY: 7, active: rigsOn, pose: 'idle' }); } catch { rack.rig = null; }
1501
1506
  rack.pose = null;
1502
1507
  rack.target = rack.rig ? stageTarget(rack) : null;
1503
- if (heir && rack.rig) flipIntoStage(rack);
1504
- else if (hero?.parked) dropHero();
1508
+ if (hero?.parked) dropHero(); // the stage is the character now: a hero still on its way out goes at once
1505
1509
  }
1506
1510
  mascotChanged();
1507
1511
  return;
@@ -2605,18 +2609,23 @@ export function createRenderer(transcriptEl, hooks = {}) {
2605
2609
 
2606
2610
  // ── The "Thinking" row's character ──
2607
2611
  // The row wears the mascot, thinking, only when no other character is on
2608
- // screen: no parked hero, no stage (a live rack right above it hides the
2609
- // row altogether). A rig, so the director can dress it; a painted still
2610
- // when calm.
2612
+ // screen: no hero, no stage (a live rack right above it hides the row
2613
+ // altogether). A rig, so the director can dress it; a painted still when
2614
+ // calm. A send hands the hero over to it (settleHero).
2611
2615
  function workingCovered() {
2612
2616
  let prev = working?.previousElementSibling || null;
2613
2617
  while (prev && prev.classList.contains('asst-folding')) prev = prev.previousElementSibling;
2614
2618
  return !!prev?._rack?.live;
2615
2619
  }
2616
2620
 
2621
+ /** The row is up and neither a stage nor a live rack stands in for it. */
2622
+ function workingFree() {
2623
+ return !!working?.isConnected && !newestStage() && !workingCovered();
2624
+ }
2625
+
2617
2626
  function syncWorkingFace() {
2618
2627
  const host = working?.querySelector('.asst-working-face');
2619
- const wanted = !!host && working.isConnected && !hero && !newestStage() && !workingCovered();
2628
+ const wanted = !!host && !hero && workingFree();
2620
2629
  if (!wanted) { dropWorkingFace(); return; }
2621
2630
  if (workingFace && workingFace.still === calm()) return;
2622
2631
  dropWorkingFace();
@@ -2638,6 +2647,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
2638
2647
  if (!face) return;
2639
2648
  workingFace = null;
2640
2649
  try { face.rig?.destroy(); } catch { /* gone */ }
2650
+ working?.classList.remove('is-flip');
2641
2651
  working?.querySelector('.asst-working-face')?.replaceChildren();
2642
2652
  if (face.rig) mascotChanged();
2643
2653
  }
@@ -3131,8 +3141,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
3131
3141
 
3132
3142
  /**
3133
3143
  * The turn ended. `holdMs`: the panel plays a one-shot (success, error) on
3134
- * the character on screen, so its stage stays up that long before it folds
3135
- * and a parked hero waits as long before it fades.
3144
+ * the character on screen, so its stage stays up that long before it folds.
3136
3145
  */
3137
3146
  function finishTurn({ holdMs = 0 } = {}) {
3138
3147
  flushStream(rootScope);
@@ -3154,7 +3163,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
3154
3163
  rack.closed = true;
3155
3164
  if (rack.live || tail || rack === stage) renderRack(rack);
3156
3165
  }
3157
- if (hero?.parked) dismissHero(hold ? holdMs : 0);
3166
+ if (hero?.parked) dismissHero();
3158
3167
  }
3159
3168
 
3160
3169
  /**
@@ -446,6 +446,8 @@ export function injectAssistantStyles() {
446
446
  .asst-working-face { display: inline-flex; flex-shrink: 0; color: var(--asst-text); line-height: 0; }
447
447
  .asst-working-face:empty { display: none; }
448
448
  .asst-working-face svg { display: block; width: 56px; height: 28px; }
449
+ /* The hero's hand-off (asst-render.js settleHero): the face flies in from the empty state, over the prompt. */
450
+ .asst-working.is-flip .asst-working-face { position: relative; z-index: 3; }
449
451
 
450
452
  /* ── Tool rows ──────────────────────────────────────────────────── */
451
453
  .asst-tool { min-width: 0; font-size: 12.5px; }
@@ -1575,8 +1577,8 @@ export function injectAssistantStyles() {
1575
1577
 
1576
1578
  /* ── Empty state character (shared/synabun-mascot.js, the onboarding eyes) ──
1577
1579
  The rig moves by itself (idle, blinks, the cursor); nothing floats it.
1578
- After a send it is parked where it stood (.asst-hero-dock, positioned
1579
- by asst-render.js) until the first stage of the turn takes it over. */
1580
+ A send hands it over to the "Thinking" row at once; with no such row it
1581
+ fades out where it stood (.asst-hero-dock, positioned by asst-render.js). */
1580
1582
  .asst-empty-mascot { display: flex; justify-content: center; margin-bottom: 2px; color: var(--asst-text); pointer-events: none; user-select: none; }
1581
1583
  :is(.asst-empty-mascot, .asst-hero-dock) svg { display: block; }
1582
1584
  .asst-hero-dock { position: absolute; z-index: 4; display: flex; align-items: center; justify-content: center; color: var(--asst-text); pointer-events: none; user-select: none; }
@@ -0,0 +1,30 @@
1
+ // Pure library selection; saved command text and user ordering stay untouched.
2
+ const normalize = value => String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLocaleLowerCase();
3
+ const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0);
4
+ const lastRun = command => Number(command.lastRunAt) || 0;
5
+
6
+ export function selectCommandGroups(data, { query = '', categoryId = '', sort = 'saved' } = {}) {
7
+ const terms = normalize(query).trim().split(/\s+/).filter(Boolean);
8
+ const groups = [...data.categories].sort(byOrder)
9
+ .filter(category => !categoryId || category.id === categoryId)
10
+ .map(category => {
11
+ const commands = data.commands.filter(command => {
12
+ if (command.categoryId !== category.id) return false;
13
+ const text = normalize([command.name, command.command, command.cwd, category.name].join(' '));
14
+ return terms.every(term => text.includes(term));
15
+ }).sort(sort === 'recent' ? (a, b) => lastRun(b) - lastRun(a) || byOrder(a, b) : byOrder);
16
+ return { category, commands };
17
+ }).filter(group => !terms.length || group.commands.length);
18
+ if (sort === 'recent') groups.sort((a, b) =>
19
+ Math.max(0, ...b.commands.map(lastRun)) - Math.max(0, ...a.commands.map(lastRun)) || byOrder(a.category, b.category));
20
+ return groups;
21
+ }
22
+
23
+ export function commandLaunchPayload(command) {
24
+ return { command: command.command, cwd: command.cwd || null, label: command.name };
25
+ }
26
+
27
+ // Internet connectivity does not gate commands served by the local workspace.
28
+ export function isCommandLaunchAvailable(online, hostname) {
29
+ return online !== false || ['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname);
30
+ }
@@ -18,8 +18,8 @@ export function getSharedHTML() {
18
18
  return `
19
19
  <!-- Loading Overlay -->
20
20
  <div id="loading-overlay">
21
- <div id="loading-mascot">
22
- <svg viewBox="0 0 280 140" width="160" height="80" style="overflow:visible">
21
+ <div id="loading-mascot" data-state="asleep">
22
+ <svg viewBox="0 0 280 140" width="160" height="80" style="overflow:visible" aria-hidden="true">
23
23
  <g class="syna-eye">
24
24
  <g class="syna-lid" transform="translate(0,60) scale(1,0.08) translate(0,-60)">
25
25
  <rect x="80" y="24" width="38" height="72" rx="19" fill="#ffffff"/>
@@ -42,7 +42,11 @@ export function getSharedHTML() {
42
42
  <svg viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
43
43
  <span id="loading-action-label">${t('loading.startServer')}</span>
44
44
  </button>
45
- <div id="loading-action-status"></div>
45
+ <div id="loading-action-status" role="status" aria-live="polite" aria-atomic="true"></div>
46
+ <div id="loading-action-elapsed" role="timer" aria-live="off"></div>
47
+ <div id="loading-action-hint"></div>
48
+ <ul id="loading-action-trail"></ul>
49
+ <div id="loading-action-log"></div>
46
50
  </div>
47
51
  <div id="loading-server-cmd">
48
52
  <div id="loading-server-cmd-hint">${t('loading.runCommand')}</div>
@@ -146,7 +150,7 @@ export function getSharedHTML() {
146
150
  <span class="menu-check"></span>
147
151
  <span class="menu-text"><span class="menu-icon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg></span> WhatsApp</span>
148
152
  </div>
149
- <div class="menu-item" id="menu-app-leonardo">
153
+ <div class="menu-item" id="menu-app-leonardo" hidden>
150
154
  <span class="menu-check"></span>
151
155
  <span class="menu-text"><span class="menu-icon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.5 2l1.5 3.5L14.5 7l-3.5 1.5L9.5 12l-1.5-3.5L4.5 7l3.5-1.5L9.5 2zM19 10l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2zM5 17l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2z"/></svg></span> Leonardo.AI</span>
152
156
  </div>
@@ -155,7 +159,7 @@ export function getSharedHTML() {
155
159
  <span class="menu-check"></span>
156
160
  <span class="menu-text"><span class="menu-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"/></svg></span> Command Runner</span>
157
161
  </div>
158
- <div class="menu-item" id="menu-terminal-link">
162
+ <div class="menu-item" id="menu-terminal-link" hidden>
159
163
  <span class="menu-check"></span>
160
164
  <span class="menu-text"><span class="menu-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span> Link Terminals</span>
161
165
  </div>
@@ -0,0 +1,34 @@
1
+ import { initPwaInstall, isStandalone, requestPwaInstall } from './pwa-install.js';
2
+
3
+ if (isStandalone()) location.replace('/');
4
+ else {
5
+ const pt = navigator.language.toLowerCase().startsWith('pt');
6
+ const copy = pt ? {
7
+ 'install-title': 'Instale o SynaBun como app',
8
+ 'install-intro': 'O servidor está rodando em segundo plano. Confirme a instalação uma vez no navegador para abrir o SynaBun em sua própria janela.',
9
+ 'install-chromium': 'Chrome ou Edge: escolha Instalar como app abaixo ou a opção Instalar app no menu do navegador.',
10
+ 'install-safari': 'Safari no macOS: escolha Arquivo → Adicionar ao Dock. Mantenha o nome SynaBun.',
11
+ 'install-later': 'Após instalar, abra novamente o iniciador nativo do SynaBun. Ele inicia ou reutiliza o servidor e abre o PWA instalado. Fechar a janela mantém o servidor ativo. Use Apps → Stop Server para pará-lo.',
12
+ 'install-button': 'Instalar como app', 'install-continue': 'Continuar para o SynaBun',
13
+ } : {};
14
+ if (pt) document.documentElement.lang = 'pt-BR';
15
+ for (const [id, text] of Object.entries(copy)) document.getElementById(id).textContent = text;
16
+ const status = document.getElementById('install-status');
17
+ const instructions = pt
18
+ ? 'Use a opção Instalar app do Chrome/Edge ou Arquivo → Adicionar ao Dock no Safari. A instalação exige sua confirmação no navegador.'
19
+ : 'Use Chrome/Edge’s Install app menu or Safari’s File → Add to Dock. Installation requires your browser confirmation.';
20
+ initPwaInstall({ changed: value => {
21
+ status.textContent = value === 'installed'
22
+ ? (pt ? 'Instalado. Abra novamente o iniciador do SynaBun para usar o PWA.' : 'Installed. Reopen the SynaBun launcher to use the PWA.')
23
+ : (pt ? 'Pronto para instalar. Escolha Instalar como app.' : 'Ready to install. Choose Install as App.');
24
+ } });
25
+ document.getElementById('install-button').addEventListener('click', async () => {
26
+ try {
27
+ const result = await requestPwaInstall();
28
+ status.textContent = result === 'accepted'
29
+ ? (pt ? 'Confirmação aceita. O navegador está concluindo a instalação.' : 'Confirmation accepted. The browser is completing installation.')
30
+ : instructions;
31
+ } catch { status.textContent = instructions; }
32
+ });
33
+ if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
34
+ }
@@ -0,0 +1,12 @@
1
+ /* Scoped first-run route; the application owns all colors, fonts and radii. */
2
+ #pwa-install { display: grid; place-items: center; overflow: auto; padding: 24px; color: var(--t-bright); }
3
+ #pwa-install main { width: min(560px, 100%); padding: 24px; background: var(--s-panel); border: 1px solid var(--b-light); border-radius: var(--r-card); }
4
+ #pwa-install h1 { font-family: var(--ff-heading); font-size: calc(28px * var(--font-scale)); font-weight: 600; line-height: 1.2; margin: 16px 0; }
5
+ #pwa-install p, #pwa-install li { font-size: var(--fs-sm); line-height: 1.5; }
6
+ #pwa-install ol { padding-left: 24px; margin: 16px 0; }
7
+ #pwa-install li + li { margin-top: 8px; }
8
+ #pwa-install .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; margin-top: 24px; }
9
+ #pwa-install button { background: var(--s-active); border: 1px solid var(--b-visible); border-radius: var(--r-small); color: var(--t-bright); font: inherit; padding: 8px 12px; cursor: pointer; }
10
+ #pwa-install button:hover { background: var(--s-hover); }
11
+ #pwa-install a { color: var(--t-bright); font-size: var(--fs-sm); }
12
+ #pwa-install #install-status { color: var(--t-primary); margin-top: 16px; min-height: 1.5em; }
@@ -0,0 +1,28 @@
1
+ /** Shared by the existing Install as App menu and the native launcher's first-run page. */
2
+ export function isStandalone(win = window, nav = navigator) {
3
+ return win.matchMedia('(display-mode: standalone)').matches
4
+ || win.matchMedia('(display-mode: window-controls-overlay)').matches || !!nav.standalone;
5
+ }
6
+
7
+ export function initPwaInstall({ win = window, nav = navigator, changed = () => {} } = {}) {
8
+ win.addEventListener('beforeinstallprompt', event => {
9
+ event.preventDefault();
10
+ win._pwaInstallPrompt = event;
11
+ changed('available');
12
+ });
13
+ win.addEventListener('appinstalled', () => {
14
+ win._pwaInstallPrompt = null;
15
+ changed('installed');
16
+ });
17
+ if (isStandalone(win, nav)) changed('installed');
18
+ }
19
+
20
+ export async function requestPwaInstall(win = window) {
21
+ const prompt = win._pwaInstallPrompt;
22
+ if (!prompt) return 'instructions';
23
+ // Called only by a click: genuine browser confirmation, never silent installation.
24
+ await prompt.prompt();
25
+ const { outcome } = await prompt.userChoice;
26
+ win._pwaInstallPrompt = null; // Browser install events are single-use, including dismissal.
27
+ return outcome;
28
+ }
@@ -0,0 +1,160 @@
1
+ // ═══════════════════════════════════════════
2
+ // SETTINGS — SynaBun rules, in words
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // What the rules status (GET /api/setup/rules, lib/rulesets/installer.js) says about a tool: the name of its
6
+ // state, the action that state calls for, the sentence next to it, and what an install answered. Pure: no DOM
7
+ // and no import, so node runs it (tests/rulesets-rules-view.test.mjs).
8
+ //
9
+ // Two surfaces render the status, Settings → AI connections (ui-settings.js) and the onboarding wizard
10
+ // (onboarding.html). Both ask this module, so a state reads the same and offers the same action in both.
11
+ //
12
+ // describeRulesHost(info, { label, version, tx }) one tool: badge, path, action, note
13
+ // describeRulesInstall(result, state, { label, tx }) what Install / Update / Replace answered
14
+ // describeRulesInstallAll(result, { tx, labels }) what "install for all" answered, and where the rules live
15
+ // rulesInstallPlan(status) what "install for all" would find to do
16
+ // describeRulesInPlace(status, { tx, labels }) the same report for rules that are already there
17
+ // catalogueTranslator(messages) `tx` for a page that does not run the app's i18n
18
+ //
19
+ // `tx(key, params)` is the caller's translate function over the settings.redesign.setup.* strings (the kit's
20
+ // `tx` in Settings; the wizard reads the same catalogue, i18n/en.json).
21
+
22
+ /** The tools SynaBun installs rules for, in the order both surfaces list them. Cursor is copy-only. */
23
+ export const RULES_INSTALL_HOSTS = Object.freeze(['claude', 'gemini', 'codex', 'opencode']);
24
+
25
+ export const RULES_HOST_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode', gemini: 'Gemini', cursor: 'Cursor', coexistence: 'coexistence' });
26
+
27
+ /** The states that count as "the rules are in place". `shadowed` is installed too, only not loaded (Codex's override file). */
28
+ export const RULES_IN_PLACE = Object.freeze(['installed', 'newer', 'shadowed']);
29
+
30
+ /**
31
+ * One tool's rules as both surfaces show them.
32
+ *
33
+ * `info` is `status.hosts[host]`, or nothing when the status could not be read (a server that was not restarted
34
+ * after the update answers 404): then the badge says "Unavailable", the path line says what to do and no action
35
+ * is offered, because none could work.
36
+ *
37
+ * @returns {{ known: boolean, state: string, badge: string, good: boolean, path: string, pathText: string,
38
+ * action: ''|'install'|'update'|'replace', actionLabel: string, force: boolean, removable: boolean, note: string }}
39
+ */
40
+ export function describeRulesHost(info, { label, version = null, tx }) {
41
+ if (!info) {
42
+ return {
43
+ known: false, state: '', badge: tx('settings.redesign.setup.unavailable'), good: false, path: '',
44
+ pathText: tx('settings.redesign.setup.restartSynabunToFinishThisUpdate'), action: '', actionLabel: '', force: false, removable: false, note: '',
45
+ };
46
+ }
47
+ const badges = {
48
+ installed: [tx('settings.redesign.setup.installed'), true], newer: [tx('settings.redesign.setup.newer'), true], outdated: [tx('settings.redesign.setup.updateAvailable'), false], modified: [tx('settings.redesign.setup.edited2'), false],
49
+ conflict: [tx('settings.redesign.setup.conflict'), false], error: [tx('settings.redesign.setup.error'), false], shadowed: [tx('settings.redesign.setup.shadowed'), false],
50
+ 'not-installed': [tx('settings.redesign.setup.notInstalled'), false], manual: [tx('settings.redesign.setup.copyOnly'), false],
51
+ };
52
+ const [badge, good] = badges[info.state] || [info.state || tx('settings.redesign.setup.unknown'), false];
53
+ // The action the state calls for; conflict, error and shadowed only explain themselves.
54
+ const actions = { 'not-installed': ['install', tx('settings.redesign.setup.install')], outdated: ['update', tx('settings.redesign.setup.update')], modified: ['replace', tx('settings.redesign.setup.replace')] };
55
+ const [action, actionLabel] = info.detected === false ? ['', ''] : (actions[info.state] || ['', '']);
56
+ // A file without SynaBun's markers is the user's own: the server refuses to replace it, so no button.
57
+ const canAct = !!action && info.replaceable !== false;
58
+ // An edited copy is the user's: nothing replaces or removes it without the confirm behind each button.
59
+ const editedNote = tx('settings.redesign.setup.thisCopyWasEditedSoSynabun');
60
+ const stateNote = info.detected === false ? tx('settings.redesign.setup.wasNotFoundOnThisMachine', { label })
61
+ : info.state === 'modified' ? (info.replaceable === false ? (info.detail || '') : editedNote)
62
+ : info.state === 'outdated' ? (version ? tx('settings.redesign.setup.versionOfTheRulesIsAvailable', { version }) : tx('settings.redesign.setup.aNewerVersionOfTheRules'))
63
+ : ['conflict', 'error', 'shadowed', 'newer'].includes(info.state) ? (info.error || info.detail || '')
64
+ : '';
65
+ // A removal that could not finish left rules behind: say which, until a remove or an install completes.
66
+ const partial = info.partialRemoval?.error ? tx('settings.redesign.setup.theLastRemovalDidNotFinish', { error: info.partialRemoval.error }) : '';
67
+ const note = partial && stateNote && !partial.includes(stateNote) ? `${partial} ${stateNote}` : (partial || stateNote);
68
+ return {
69
+ known: true,
70
+ state: info.state || '',
71
+ badge,
72
+ good,
73
+ path: info.path || '',
74
+ pathText: info.installedVersion ? `${info.path} (v${info.installedVersion})` : (info.path || ''),
75
+ action: canAct ? action : '',
76
+ actionLabel: canAct ? actionLabel : '',
77
+ // Replace sends force, and only after the confirm that says the edited copy is backed up first.
78
+ force: canAct && action === 'replace',
79
+ removable: ['installed', 'newer', 'outdated', 'modified', 'shadowed'].includes(info.state),
80
+ note,
81
+ };
82
+ }
83
+
84
+ /** The sentence for what one tool's Install, Update or Replace answered. `state` is what the tool was in before. */
85
+ export function describeRulesInstall(result, state, { label, tx }) {
86
+ if (!result?.changed) return tx('settings.redesign.setup.rulesAreAlreadyCurrent', { label });
87
+ return tx(state === 'not-installed' ? 'settings.redesign.setup.rulesInstalled2' : 'settings.redesign.setup.rulesUpdated2', { label });
88
+ }
89
+
90
+ /**
91
+ * What "install for all" answered: `message` is the result in one sentence, `note` says which file each tool
92
+ * reads ('' when nothing was written or found), `failed` lists the tools that were refused.
93
+ * The rules are global, so a project's own CLAUDE.md and AGENTS.md stay as they are; the note says so.
94
+ */
95
+ export function describeRulesInstallAll(result, { tx, labels = RULES_HOST_LABELS }) {
96
+ const name = (host) => labels[host] || RULES_HOST_LABELS[host] || host;
97
+ const rows = Object.entries(result?.results || {});
98
+ if (!rows.length) return { message: tx('settings.redesign.setup.noConnectedToolNeedsRules'), note: '', failed: [] };
99
+ const where = rows.filter(([, row]) => row.ok !== false && row.path).map(([host, row]) => `${name(host)}: ${row.path}`).join(' · ');
100
+ const note = where ? tx('settings.redesign.setup.rulesInstalledAt', { where }) : '';
101
+ const failed = rows.filter(([, row]) => row.ok === false);
102
+ if (failed.length) {
103
+ return {
104
+ message: tx('settings.redesign.setup.installedWithFailure', { count: rows.length - failed.length, host: name(failed[0][0]), error: failed[0][1].error || failed[0][1].state }),
105
+ note,
106
+ failed: failed.map(([host]) => host),
107
+ };
108
+ }
109
+ const map = rows.map(([host]) => name(host)).join(', ');
110
+ return { message: tx(rows.some(([, row]) => row.changed) ? 'settings.redesign.setup.rulesInstalledFor' : 'settings.redesign.setup.rulesAlreadyCurrentFor', { map }), note, failed: [] };
111
+ }
112
+
113
+ /**
114
+ * What "install for all" would find to do, by the installer's own rule (installAll in lib/rulesets/installer.js):
115
+ * it takes a tool that is on this machine, connected to SynaBun (or already managed), not opted out and not in
116
+ * the middle of a removal.
117
+ *
118
+ * pending tools it would install or update
119
+ * inPlace tools whose rules are there already
120
+ * attention tools it would be refused for (an edited copy, damaged markers, an error): each needs its own answer
121
+ * skipped connected tools it leaves alone because the user took their rules out
122
+ * connected every tool above
123
+ */
124
+ export function rulesInstallPlan(status) {
125
+ const plan = { pending: [], inPlace: [], attention: [], skipped: [], connected: [] };
126
+ for (const host of RULES_INSTALL_HOSTS) {
127
+ const info = status?.hosts?.[host];
128
+ if (!info || info.detected === false) continue;
129
+ if (info.managed !== true && info.mcp !== true) continue;
130
+ plan.connected.push(host);
131
+ if (RULES_IN_PLACE.includes(info.state)) plan.inPlace.push(host);
132
+ else if (info.managed === false || info.partialRemoval) plan.skipped.push(host);
133
+ else if (info.state === 'not-installed' || info.state === 'outdated') plan.pending.push(host);
134
+ else plan.attention.push(host);
135
+ }
136
+ return plan;
137
+ }
138
+
139
+ /**
140
+ * The same report for rules that are in place already: the wizard reaches its rules step after its own toggles
141
+ * installed them, and says what Settings says after "install for all". Null when no connected tool has rules.
142
+ */
143
+ export function describeRulesInPlace(status, { tx, labels = RULES_HOST_LABELS }) {
144
+ const results = {};
145
+ for (const host of rulesInstallPlan(status).inPlace) results[host] = { ok: true, changed: true, path: status.hosts[host].path };
146
+ return Object.keys(results).length ? describeRulesInstallAll({ results }, { tx, labels }) : null;
147
+ }
148
+
149
+ /**
150
+ * `tx` over a loaded catalogue (the parsed i18n/en.json), for a page that does not run the app's i18n: the
151
+ * onboarding wizard is English only and must not wait for the app's storage. Same rules as t() in i18n.js:
152
+ * a missing key comes back as the key, `{name}` is filled from `params`.
153
+ */
154
+ export function catalogueTranslator(messages) {
155
+ return (key, params) => {
156
+ const value = String(key).split('.').reduce((node, part) => node?.[part], messages);
157
+ if (typeof value !== 'string') return key;
158
+ return params ? value.replace(/\{(\w+)\}/g, (_, name) => (params[name] != null ? params[name] : `{${name}}`)) : value;
159
+ };
160
+ }