babavoss 0.12.6 → 0.13.1

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 (62) hide show
  1. package/gui/babavoss-web.js +4 -1
  2. package/gui/{chunk-9gmmm54v.js → chunk-qgzmajan.js} +619 -574
  3. package/gui/gui.js +1538 -277
  4. package/gui/theme.css +2007 -1128
  5. package/index.ts +1 -1
  6. package/package.json +5 -5
  7. package/src/{promptware → agents}/compile.ts +23 -31
  8. package/src/{promptware → agents}/define.ts +2 -2
  9. package/src/{promptware → agents}/disk.ts +7 -4
  10. package/src/{promptware → agents}/sync.ts +2 -2
  11. package/src/{promptware → agents}/system.ts +17 -17
  12. package/src/baba/check.ts +3 -3
  13. package/src/baba/config.ts +5 -2
  14. package/src/baba/init.ts +13 -18
  15. package/src/baba/node.ts +2 -2
  16. package/src/baba/project.ts +2 -2
  17. package/src/baba/requirements.ts +39 -0
  18. package/src/baba/setup.ts +30 -0
  19. package/src/baba/worker.ts +13 -5
  20. package/src/build/mdx.ts +4 -4
  21. package/src/build/project.ts +6 -1
  22. package/src/build/views.ts +3 -3
  23. package/src/desktop/desktop.css +35 -39
  24. package/src/desktop/index.ts +9 -7
  25. package/src/desktop/keys.ts +153 -0
  26. package/src/desktop/view.tsx +174 -130
  27. package/src/door/core.ts +2 -2
  28. package/src/ecs/baba.ts +27 -17
  29. package/src/generated/build.ts +1 -1
  30. package/src/gui/gui.tsx +21 -31
  31. package/src/gui/index.ts +6 -2
  32. package/src/gui/{promptware.tsx → instructions.tsx} +12 -12
  33. package/src/gui/levels.tsx +241 -59
  34. package/src/gui/lockup.ts +11 -0
  35. package/src/gui/settings.tsx +241 -0
  36. package/src/gui/setup.tsx +77 -0
  37. package/src/gui/theme.css +205 -58
  38. package/src/gui/theme.ts +9 -5
  39. package/src/gui/voss-settings.ts +48 -0
  40. package/src/gui/wizard.tsx +17 -40
  41. package/src/guide/add-a-desktop.mdx +7 -7
  42. package/src/guide/index.ts +4 -4
  43. package/src/guide/write-a-system.mdx +3 -3
  44. package/src/guide/{write-promptware.mdx → write-agentic-instructions.mdx} +17 -24
  45. package/src/http/server.ts +140 -17
  46. package/src/{prompt → instructions}/evals.ts +1 -1
  47. package/src/{prompt → instructions}/index.ts +9 -8
  48. package/src/{prompt → instructions}/jsx-runtime.ts +3 -3
  49. package/src/mcp/main.ts +3 -3
  50. package/src/mcp/tools.ts +4 -4
  51. package/src/runtime/harness.ts +2 -2
  52. package/src/server/edge.ts +31 -3
  53. package/src/server/main.ts +2 -1
  54. package/src/server/messages.ts +47 -7
  55. package/src/shell/run.ts +18 -18
  56. package/src/spec/index.ts +1 -1
  57. package/src/web/core.tsx +2 -2
  58. package/src/web/index.tsx +1 -1
  59. package/src/desktop/bob.ts +0 -76
  60. /package/src/{promptware → agents}/markdown.d.ts +0 -0
  61. /package/src/{prompt → instructions}/jsx-dev-runtime.ts +0 -0
  62. /package/src/{prompt → instructions}/mdx.d.ts +0 -0
@@ -0,0 +1,77 @@
1
+ // The setup wizard: what a folder's baba lacks of what voss requires, asked a
2
+ // step at a time in one card of a fixed size, then made so in its last step:
3
+ // its .baba written when there is none, or the values written into the one
4
+ // there. Until it is done the baba does not run, and opening it lands here.
5
+ import { useEffect, useRef, useState } from "react";
6
+ import { Eyes } from "../desktop/view.tsx";
7
+ import { nameFrom, requirementError, requirements, type Requirement, type RequirementValues } from "../baba/requirements.ts";
8
+ import type { PendingEntry } from "../server/messages.ts";
9
+ import { postJson, tilde } from "./wizard.tsx";
10
+
11
+ export function SetupWizard({ entry, home, taken, onCancel, onDone }: { entry: PendingEntry; home: string; taken: readonly string[]; onCancel(): void; onDone(name: string): void }) {
12
+ const asks = requirements.filter((r) => entry.missing.includes(r.key));
13
+ // A step per requirement missing, then the one that makes it so.
14
+ const steps = [...asks.map((r) => ({ kind: "ask" as const, r })), { kind: "done" as const }];
15
+ const [at, setAt] = useState(0);
16
+ const [values, setValues] = useState<RequirementValues>(() => ({ name: nameFrom(entry.dir) }));
17
+ const [busy, setBusy] = useState(false);
18
+ const [error, setError] = useState<string | null>(null);
19
+ const field = useRef<HTMLInputElement>(null), primary = useRef<HTMLButtonElement>(null);
20
+ const step = steps[at]!;
21
+ const why = (k: Requirement) => requirementError(k, values[k] ?? "", taken);
22
+ const blocked = step.kind === "ask" && why(step.r.key) !== null;
23
+ // Each step takes the keys: its field, or on the last its button, so Enter goes on.
24
+ useEffect(() => { if (field.current) { field.current.focus(); field.current.select(); } else primary.current?.focus(); }, [at]);
25
+
26
+ const next = async () => {
27
+ if (busy || blocked) return;
28
+ if (step.kind === "ask") { setAt(at + 1); return; }
29
+ setBusy(true); setError(null);
30
+ try {
31
+ const j = await postJson<{ name: string | null }>("/api/projects/setup", { dir: entry.dir, values });
32
+ if (j.name) onDone(j.name); else throw new Error("voss kept it to set up: it still lacks something");
33
+ } catch (e) { setError(String(e instanceof Error ? e.message : e)); setBusy(false); }
34
+ };
35
+ const back = () => { if (busy) return; if (at > 0) setAt(at - 1); else onCancel(); };
36
+ useEffect(() => {
37
+ const key = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); back(); } };
38
+ addEventListener("keydown", key); return () => removeEventListener("keydown", key);
39
+ });
40
+
41
+ const where = tilde(entry.dir, home);
42
+ return <form className="sw-card" aria-label="Set up a baba" onSubmit={(e) => { e.preventDefault(); void next(); }}>
43
+ <header className="sw-head">
44
+ <Eyes size={28} />
45
+ <div className="sw-head-text"><h1>Set up a baba</h1><p title={entry.dir}>{where}</p></div>
46
+ </header>
47
+ <ol className="sw-steps" aria-label="Steps">
48
+ {steps.map((s, i) => <li key={i} className={i === at ? "is-here" : i < at ? "is-done" : undefined} aria-current={i === at ? "step" : undefined}>
49
+ <span className="sw-step-dot">{i + 1}</span>{s.kind === "ask" ? s.r.title : entry.baba ? "Save" : "Create"}
50
+ </li>)}
51
+ </ol>
52
+ <div className="sw-body">
53
+ {step.kind === "ask" ? <>
54
+ <label className="sw-label" htmlFor="sw-field">{step.r.title}</label>
55
+ <p className="sw-summary">{step.r.summary}</p>
56
+ <input id="sw-field" ref={field} className={`sw-input${why(step.r.key) ? " is-wrong" : ""}`} value={values[step.r.key] ?? ""} spellCheck={false} autoComplete="off" disabled={busy}
57
+ onChange={(e) => setValues({ ...values, [step.r.key]: e.target.value.toLowerCase() })} />
58
+ <p className={`sw-hint${why(step.r.key) ? " is-wrong" : ""}`}>{why(step.r.key) ?? <>It opens at <code>/{values[step.r.key]}/</code>.</>}</p>
59
+ </> : <>
60
+ <p className="sw-summary">{entry.baba
61
+ ? <>voss writes what it needs into <code>.baba/package.json</code> and leaves the rest of it as it is.</>
62
+ : <>voss makes a baba here: <code>.baba/</code> beside what the folder holds, with a first system and its spec, then installs its packages. It takes a moment.</>}</p>
63
+ <dl className="sw-review">
64
+ {asks.map((r) => <div key={r.key}><dt>{r.title}</dt><dd><code>{values[r.key]}</code></dd></div>)}
65
+ <div><dt>Folder</dt><dd><code title={entry.dir}>{where}</code></dd></div>
66
+ </dl>
67
+ </>}
68
+ {error && <p className="sw-error" role="alert">{error}</p>}
69
+ </div>
70
+ <footer className="sw-foot">
71
+ <button type="button" className="sw-button is-quiet" disabled={busy} onClick={back}>{at > 0 ? "Back" : "Cancel"}</button>
72
+ <span className="sw-gap" />
73
+ {busy && <span className="sw-busy"><span className="pk-spin" aria-hidden="true" />{entry.baba ? "Saving…" : "Installing packages…"}</span>}
74
+ <button ref={primary} type="submit" className="sw-button is-primary" disabled={busy || blocked}>{step.kind === "ask" ? "Continue" : entry.baba ? "Save and open" : "Create and open"}</button>
75
+ </footer>
76
+ </form>;
77
+ }
package/src/gui/theme.css CHANGED
@@ -6,29 +6,32 @@
6
6
  @layer voss {
7
7
 
8
8
  /* The one theme: voss's pages, the kit (bv-*) and plain elements inside a view.
9
- Every neutral carries the same faint warmth; depth is a step of lightness,
9
+ The colours are Baba Voss's, which are Alan Remarc's (marc/logo/brands.ts, site/brand.html):
10
+ walnut and linen surfaces, tan and brown ink. The desk is the brand's surface, the primary
11
+ its ink, the text and muted text its own; the steps between surface and soft are ours.
12
+ Every neutral carries the same warmth; depth is a step of lightness,
10
13
  layers part by a 1px line, never by a shadow. A row is 28px, a control 24,
11
14
  the gutter 12, one radius of 4. Two sizes of type: 13/20 and 11/16.
12
15
  Light by default; dark from the system unless a choice sets data-theme. */
13
16
 
14
17
  :root, .desktop {
15
- --background: oklch(0.968 0.004 75);
16
- --surface: oklch(0.992 0.003 75);
18
+ --background: #F3EDE4;
19
+ --surface: #F8F4EE;
17
20
  --card: var(--surface);
18
- --foreground: oklch(0.29 0.008 70);
19
- --popover: oklch(0.995 0.003 75);
20
- --muted: oklch(0.945 0.005 75);
21
- --muted-foreground: oklch(0.53 0.01 70);
22
- --accent: oklch(0.935 0.006 75);
23
- --primary: oklch(0.52 0.11 250);
24
- --primary-foreground: oklch(0.985 0.004 250);
25
- --destructive: oklch(0.56 0.14 28);
26
- --warning: oklch(0.68 0.11 75);
27
- --success: oklch(0.58 0.1 155);
28
- --border: oklch(0.29 0.008 70 / 11%);
29
- --input: oklch(0.29 0.008 70 / 16%);
30
- --desk: oklch(0.925 0.006 75);
31
- --chrome: oklch(0.952 0.005 75);
21
+ --foreground: #332922;
22
+ --popover: #FAF7F2;
23
+ --muted: #E6DDD0;
24
+ --muted-foreground: #6F6356;
25
+ --accent: #E0D5C5;
26
+ --primary: #634A38;
27
+ --primary-foreground: #ECE4D8;
28
+ --destructive: #A4472A;
29
+ --warning: #A4472A;
30
+ --success: #4F6B34;
31
+ --border: rgb(51 41 34 / 12%);
32
+ --input: rgb(51 41 34 / 18%);
33
+ --desk: #ECE4D8;
34
+ --chrome: #E6DDD0;
32
35
  --radius: 4px;
33
36
  --row: 28px;
34
37
  --control: 24px;
@@ -40,43 +43,43 @@
40
43
 
41
44
  @media (prefers-color-scheme: dark) {
42
45
  :root:not([data-theme="light"]), .desktop[data-theme="system"] {
43
- --background: oklch(0.2 0.005 75);
44
- --surface: oklch(0.228 0.006 75);
45
- --foreground: oklch(0.87 0.012 80);
46
- --popover: oklch(0.25 0.006 75);
47
- --muted: oklch(0.245 0.006 75);
48
- --muted-foreground: oklch(0.66 0.012 78);
49
- --accent: oklch(0.275 0.007 75);
50
- --primary: oklch(0.74 0.075 245);
51
- --primary-foreground: oklch(0.2 0.01 245);
52
- --destructive: oklch(0.71 0.1 30);
53
- --warning: oklch(0.79 0.09 80);
54
- --success: oklch(0.75 0.085 155);
55
- --border: oklch(0.87 0.012 80 / 10%);
56
- --input: oklch(0.87 0.012 80 / 15%);
57
- --desk: oklch(0.165 0.005 75);
58
- --chrome: oklch(0.185 0.005 75);
46
+ --background: #1B1917;
47
+ --surface: #1F1C1A;
48
+ --foreground: #E3DDD6;
49
+ --popover: #262220;
50
+ --muted: #23201E;
51
+ --muted-foreground: #9C948C;
52
+ --accent: #2C2826;
53
+ --primary: #AE8D74;
54
+ --primary-foreground: #151312;
55
+ --destructive: #D9824A;
56
+ --warning: #D9824A;
57
+ --success: #A3B67E;
58
+ --border: rgb(227 221 214 / 10%);
59
+ --input: rgb(227 221 214 / 15%);
60
+ --desk: #151312;
61
+ --chrome: #181614;
59
62
  color-scheme: dark;
60
63
  }
61
64
  }
62
65
 
63
66
  :root[data-theme="dark"], .desktop[data-theme="dark"] {
64
- --background: oklch(0.2 0.005 75);
65
- --surface: oklch(0.228 0.006 75);
66
- --foreground: oklch(0.87 0.012 80);
67
- --popover: oklch(0.25 0.006 75);
68
- --muted: oklch(0.245 0.006 75);
69
- --muted-foreground: oklch(0.66 0.012 78);
70
- --accent: oklch(0.275 0.007 75);
71
- --primary: oklch(0.74 0.075 245);
72
- --primary-foreground: oklch(0.2 0.01 245);
73
- --destructive: oklch(0.71 0.1 30);
74
- --warning: oklch(0.79 0.09 80);
75
- --success: oklch(0.75 0.085 155);
76
- --border: oklch(0.87 0.012 80 / 10%);
77
- --input: oklch(0.87 0.012 80 / 15%);
78
- --desk: oklch(0.165 0.005 75);
79
- --chrome: oklch(0.185 0.005 75);
67
+ --background: #1B1917;
68
+ --surface: #1F1C1A;
69
+ --foreground: #E3DDD6;
70
+ --popover: #262220;
71
+ --muted: #23201E;
72
+ --muted-foreground: #9C948C;
73
+ --accent: #2C2826;
74
+ --primary: #AE8D74;
75
+ --primary-foreground: #151312;
76
+ --destructive: #D9824A;
77
+ --warning: #D9824A;
78
+ --success: #A3B67E;
79
+ --border: rgb(227 221 214 / 10%);
80
+ --input: rgb(227 221 214 / 15%);
81
+ --desk: #151312;
82
+ --chrome: #181614;
80
83
  color-scheme: dark;
81
84
  }
82
85
 
@@ -84,6 +87,10 @@
84
87
  *, *::before, *::after { box-sizing: border-box; }
85
88
  html { font: 13px/20px var(--font-sans); -webkit-font-smoothing: antialiased; height: 100%; }
86
89
  body { margin: 0; height: 100%; background: var(--desk); color: var(--foreground); }
90
+ /* Going from one of voss's pages to another, the picker to a baba and back, crossfades rather than cuts, where the browser can. */
91
+ @view-transition { navigation: auto; }
92
+ ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 160ms; }
93
+ @media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
87
94
  #root { height: 100%; display: flex; overflow: hidden; }
88
95
  * { scrollbar-width: thin; scrollbar-color: color-mix(in oklch, var(--foreground) 25%, transparent) transparent; }
89
96
  code, kbd, pre, samp { font-family: var(--font-mono); font-size: 12px; line-height: 20px; }
@@ -253,12 +260,6 @@ button.pk-crumb:hover { background: var(--accent); color: var(--foreground); }
253
260
  .pk-dock-text { min-width: 0; color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
254
261
  .pk-here { color: var(--foreground); font-family: var(--font-mono); font-size: 12px; }
255
262
  .pk-dock-text code { font-size: 12px; color: var(--foreground); }
256
- .pk-field { display: flex; align-items: center; height: var(--control); min-width: 0; width: 150px; border: 1px solid var(--input); border-radius: var(--radius); background: var(--surface); overflow: hidden; }
257
- .pk-field.is-wide { width: 190px; }
258
- .pk-field:focus-within { border-color: color-mix(in oklch, var(--primary) 55%, transparent); }
259
- .pk-field span { flex: none; align-self: stretch; display: grid; place-items: center; padding: 0 7px; border-right: 1px solid var(--border); background: var(--muted); color: var(--muted-foreground); font-size: 11px; }
260
- .pk-field input { flex: 1; min-width: 0; height: 100%; padding: 0 7px; border: 0; outline: 0; background: transparent; }
261
- .pk-busy { display: inline-flex; align-items: center; gap: 8px; color: var(--muted-foreground); }
262
263
 
263
264
  .pk-primary { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 6px 0 10px; border: 0; border-radius: var(--radius); background: var(--primary); color: var(--primary-foreground); font-weight: 500; cursor: pointer; white-space: nowrap; transition: background 100ms; }
264
265
  .pk-empty .pk-primary { padding-right: 10px; }
@@ -274,7 +275,6 @@ button.pk-crumb:hover { background: var(--accent); color: var(--foreground); }
274
275
 
275
276
  @media (max-width: 560px) {
276
277
  .pk-dock-line:not(.is-say) { flex-wrap: wrap; }
277
- .pk-field, .pk-field.is-wide { width: 100%; }
278
278
  .pk-filter, .pk-filter:focus-within { width: 120px; }
279
279
  }
280
280
  @media (prefers-reduced-motion: reduce) { .pk-fade, .pk-ghost-line, .pk-spin { animation: none; } .pk-go, .pk-filter, .pk-folders { transition: none; } }
@@ -295,10 +295,64 @@ button.pk-crumb:hover { background: var(--accent); color: var(--foreground); }
295
295
  @keyframes gui-appear { from { opacity: 0 } to { opacity: 1 } }
296
296
  @media (prefers-reduced-motion: reduce) { .gui-fade { animation: none; } }
297
297
 
298
- /* ---- voss's apps, in the shell's windows: the state, the contract, the promptware, the settings, with a page's gutters; the Maker edge to edge ---- */
298
+ /* ---- voss's apps, in the shell's windows: the state, the contract, the agentic instructions, the settings, with a page's gutters; the Maker edge to edge ---- */
299
299
  .gui-app { max-width: 1100px; padding: 16px 20px 32px; }
300
300
  .gui-app > h2 { margin: 16px 0 4px; font-size: 13px; }
301
- .gui-choices { display: flex; gap: 6px; margin-top: 8px; }
301
+ .gui-choices { display: flex; gap: 6px; }
302
+ /* The settings: a sidebar of pages, the page beside it; a setting is its name and what it does on the left, its control on the right. */
303
+ .gui-settings { display: flex; height: 100%; }
304
+ .gui-settings-nav { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; width: 200px; padding: 16px 8px; border-right: 1px solid var(--border); }
305
+ .gui-settings-link { display: flex; align-items: center; height: 28px; padding: 0 12px; border: 0; border-radius: 999px; background: transparent; color: var(--muted-foreground); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
306
+ .gui-settings-link:hover { color: var(--foreground); background: color-mix(in oklch, var(--foreground) 6%, transparent); }
307
+ .gui-settings-link.is-active { color: var(--foreground); background: color-mix(in oklch, var(--foreground) 11%, transparent); }
308
+ .gui-settings-link:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
309
+ .gui-settings-level { margin: 12px 12px 4px; font-size: 10px; line-height: 16px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--muted-foreground); }
310
+ .gui-settings-level:first-child { margin-top: 0; }
311
+ .gui-name { width: 200px; height: 28px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; background: color-mix(in oklch, var(--foreground) 4%, transparent); color: var(--foreground); font: inherit; font-family: var(--font-mono); outline: none; }
312
+ .gui-name:focus { border-color: var(--primary); }
313
+ .gui-settings-page { flex: 1; min-width: 0; overflow: auto; padding: 24px 32px 48px; }
314
+ .gui-settings-page > * { max-width: 720px; }
315
+ .gui-settings-head h1 { margin: 0 0 16px; font-size: 18px; line-height: 28px; font-weight: 600; }
316
+ .gui-settings-list { display: flex; flex-direction: column; }
317
+ .gui-setting { display: flex; align-items: center; gap: 24px; padding: 14px 0; border-top: 1px solid var(--border); }
318
+ .gui-setting-text { flex: 1; min-width: 0; }
319
+ .gui-setting-title { font-weight: 500; }
320
+ .gui-setting-summary { color: var(--muted-foreground); font-size: 12px; }
321
+ .gui-setting-note { display: block; margin-top: 2px; color: var(--muted-foreground); font-style: italic; }
322
+ .gui-setting-note.is-error { color: var(--destructive); font-style: normal; }
323
+ .gui-setting-control { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
324
+ .gui-setting-control .is-hidden { visibility: hidden; }
325
+ .gui-settings-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; }
326
+ /* About: facts in sections, a name and its value, the value's copy at the row's end when pointed at. */
327
+ .gui-facts { margin-bottom: 20px; }
328
+ .gui-facts h2 { margin: 0 0 4px; font-size: 10px; line-height: 16px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--muted-foreground); }
329
+ .gui-facts dl { margin: 0; border-top: 1px solid var(--border); }
330
+ .gui-fact { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 16px; align-items: center; min-height: 32px; border-bottom: 1px solid var(--border); }
331
+ .gui-fact dt { color: var(--muted-foreground); }
332
+ .gui-fact dd { display: flex; align-items: center; gap: 8px; min-width: 0; margin: 0; }
333
+ .gui-fact dd.is-mono .gui-fact-value { font-family: var(--font-mono); font-size: 12px; white-space: normal; overflow-wrap: anywhere; padding: 6px 0; }
334
+ .gui-fact-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
335
+ .gui-fact-tag { margin-left: 8px; padding: 0 6px; border-radius: 999px; background: color-mix(in oklch, var(--foreground) 6%, transparent); color: var(--muted-foreground); font-size: 11px; }
336
+ .gui-fact-tag.is-warn { color: var(--destructive); }
337
+ .gui-fact-error { color: var(--destructive); white-space: normal; }
338
+ .gui-fact-copy { flex-shrink: 0; height: 22px; padding: 0 8px; border: 0; border-radius: 999px; background: transparent; color: var(--muted-foreground); font: inherit; font-size: 11px; cursor: pointer; opacity: 0; }
339
+ .gui-fact:hover .gui-fact-copy, .gui-fact-copy:focus-visible { opacity: 1; }
340
+ .gui-fact-copy:hover { color: var(--foreground); background: color-mix(in oklch, var(--foreground) 6%, transparent); }
341
+ @media (max-width: 640px) { .gui-fact { grid-template-columns: 1fr; gap: 0; padding: 6px 0; } }
342
+ /* A binding as a key: it records the next press when clicked. */
343
+ .gui-key { min-width: 88px; height: 28px; padding: 0 12px; border: 1px solid transparent; border-radius: 999px; background: color-mix(in oklch, var(--foreground) 6%, transparent); color: var(--foreground); font: inherit; font-variant-numeric: tabular-nums; cursor: pointer; }
344
+ .gui-key:hover { background: color-mix(in oklch, var(--foreground) 11%, transparent); }
345
+ .gui-key-none { color: var(--muted-foreground); }
346
+ .gui-key.is-recording { border-color: var(--primary); color: var(--primary); }
347
+ .gui-key:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
348
+ @media (max-width: 640px) {
349
+ .gui-settings { flex-direction: column; height: auto; }
350
+ .gui-settings-page { overflow: visible; }
351
+ .gui-settings-nav { flex-direction: row; width: auto; padding: 8px; border-right: 0; border-bottom: 1px solid var(--border); overflow-x: auto; }
352
+ .gui-settings-level { display: none; }
353
+ .gui-settings-page { padding: 16px; }
354
+ .gui-setting { flex-wrap: wrap; gap: 8px; }
355
+ }
302
356
  .gui-stale { position: fixed; left: 10px; bottom: 10px; z-index: 900; display: flex; gap: 12px; align-items: center; max-width: min(640px, calc(100vw - 60px)); padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--card); color: var(--foreground); font-size: 13px; box-shadow: 0 8px 24px color-mix(in oklch, black 25%, transparent); }
303
357
 
304
358
  /* The proofs page and a view's own use of the player: voss's classes. */
@@ -496,3 +550,96 @@ button.pk-crumb:hover { background: var(--accent); color: var(--foreground); }
496
550
  .bv-lab-tree { list-style: none; margin: 2px 0 0 10px; padding: 0; font-size: 11px; }
497
551
  .bv-lab-expect { margin: 4px 0; }
498
552
  .bv-lab-model .bv-object-map { margin-bottom: 6px; }
553
+
554
+ /* The root and its setup wizard sit in the theme's layer, so the kit's classes and the browser's buttons keep their order over them. */
555
+ @layer voss {
556
+ /* ---- voss's root: the mark large in the middle, the babas under it as rows, a folder added by its path ---- */
557
+ .rt { display: flex; justify-content: center; background: var(--desk); color: var(--foreground); font-size: 13px; line-height: 20px;
558
+ --rt-ink: color-mix(in oklch, var(--foreground) 6%, transparent); --rt-ink-strong: color-mix(in oklch, var(--foreground) 11%, transparent); }
559
+ .rt.rt-center { align-items: center; padding: 16px; }
560
+ :where(.rt) :where(button, input) { font: inherit; color: inherit; }
561
+ .rt-column { display: flex; flex-direction: column; gap: 8px; width: min(560px, 100%); padding: max(32px, 12vh) 16px 48px; }
562
+ .rt-mark { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 24px; padding: 0 5px; color: var(--primary); }
563
+ .rt-mark h1 { margin: 0; line-height: 0; }
564
+ .rt-lockup { display: block; height: 22px; width: auto; }
565
+ .rt-version { display: flex; align-items: center; color: var(--muted-foreground); font: 12px/20px var(--font-mono); font-variant-numeric: tabular-nums; }
566
+ /* The wordmark is centred on its capitals; the version's text is trimmed to its own, so the two share one middle, not the line box's. */
567
+ .rt-version-text { display: block; text-box: trim-both cap alphabetic; }
568
+ .rt-version .rt-tag { display: flex; align-items: center; height: 18px; }
569
+ .rt-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
570
+ .rt-list { display: flex; flex-direction: column; gap: 2px; margin: 0; padding: 0; list-style: none; }
571
+ .rt-row { position: relative; display: flex; flex-wrap: wrap; align-items: center; padding-left: calc(var(--depth, 0) * 20px); }
572
+ .rt-row-main { display: flex; flex: 1; align-items: center; gap: 3px; min-width: 0; height: 32px; padding: 0 12px 0 5px; border: 0; border-radius: 999px; background: transparent; color: var(--muted-foreground); text-align: left; cursor: pointer; }
573
+ .rt-row-main:hover, .rt-row-main:focus-visible { background: var(--rt-ink-strong); color: var(--foreground); outline: none; }
574
+ .rt-row-icon { display: grid; place-items: center; flex-shrink: 0; width: 22px; height: 22px; }
575
+ .rt-row-label { flex-shrink: 0; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--foreground); }
576
+ .rt-row-note { flex: 1; min-width: 0; padding-left: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; font-size: 11px; color: var(--muted-foreground); }
577
+ .rt-tag { flex-shrink: 0; margin-left: 8px; padding: 0 7px; border-radius: 999px; background: var(--rt-ink-strong); color: var(--muted-foreground); font-size: 11px; line-height: 18px; }
578
+ .rt-tag.is-failed { color: var(--destructive); }
579
+ /* As on the bar's tabs: pointing at a row turns its icon into its ×, in the very same box, with the same small bounce. */
580
+ .rt-row-forget { position: absolute; left: calc(var(--depth, 0) * 20px + 5px); top: 5px; display: grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px; background: transparent; color: var(--foreground); cursor: pointer; opacity: 0; transform: scale(.5); pointer-events: none; }
581
+ .rt-row-icon, .rt-row-forget { transition: transform .26s cubic-bezier(.3, 1.6, .4, 1), opacity .08s; }
582
+ .rt-row:hover .rt-row-main .rt-row-icon, .rt-row:has(:focus-visible) .rt-row-main .rt-row-icon { opacity: 0; transform: scale(.5); }
583
+ .rt-row:hover .rt-row-forget, .rt-row:has(:focus-visible) .rt-row-forget { opacity: 1; transform: none; pointer-events: auto; }
584
+ .rt-row-forget:hover { background: var(--rt-ink-strong); }
585
+ @media (prefers-reduced-motion: reduce) { .rt-row-icon, .rt-row-forget { transition: none; } }
586
+ .rt-row-error { flex-basis: 100%; margin: 2px 0 6px 30px; color: var(--destructive); font-size: 12px; white-space: pre-wrap; }
587
+ .rt-empty { padding: 6px 30px; color: var(--muted-foreground); }
588
+ .rt-add { position: relative; margin-top: 12px; }
589
+ .rt-add-field { display: flex; align-items: center; gap: 3px; height: 36px; padding: 0 4px 0 6px; border: 1px solid transparent; border-radius: 999px; background: var(--rt-ink); }
590
+ .rt-add-field:focus-within { border-color: var(--border); }
591
+ .rt-add-field input { flex: 1; min-width: 0; height: 100%; padding: 0 6px 0 0; border: 0; background: transparent; outline: none; }
592
+ .rt-add-field input::placeholder { color: var(--muted-foreground); }
593
+ .rt-add-go, .rt-add-browse { flex-shrink: 0; height: 26px; padding: 0 12px; border: 0; border-radius: 999px; background: transparent; color: var(--muted-foreground); cursor: pointer; }
594
+ /* Add is always there, a round icon button at the field's end: the ink's when a path is typed, quiet until then. */
595
+ .rt-add-go { display: grid; place-items: center; width: 26px; padding: 0; background: var(--primary); color: var(--primary-foreground); }
596
+ .rt-add-go:disabled { background: var(--rt-ink-strong); color: var(--muted-foreground); cursor: default; }
597
+ .rt-add-browse:hover { background: var(--rt-ink-strong); color: var(--foreground); }
598
+ .rt-suggest { position: absolute; z-index: 2; left: 0; right: 0; top: 40px; display: flex; flex-direction: column; gap: 2px; margin: 0; padding: 4px; list-style: none; border-radius: 18px; background: var(--desk); box-shadow: 0 12px 32px -12px color-mix(in oklch, black 45%, transparent); }
599
+ .rt-suggest button { display: flex; align-items: center; gap: 3px; width: 100%; height: 28px; padding: 0 10px 0 3px; border: 0; border-radius: 999px; background: transparent; color: var(--muted-foreground); text-align: left; cursor: pointer; }
600
+ .rt-suggest button:hover, .rt-suggest button.is-active { background: var(--rt-ink-strong); color: var(--foreground); }
601
+ .rt-suggest .rt-tag { margin-left: auto; }
602
+ .rt-browse { display: flex; flex-direction: column; height: min(480px, 60vh); overflow: hidden; border: 1px solid var(--border); border-radius: 14px; background: var(--background); }
603
+ .rt-error { margin: 8px 0 0; padding: 0 12px; color: var(--destructive); font-size: 12px; }
604
+ .rt-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); display: flex; align-items: center; gap: 12px; padding: 6px 6px 6px 16px; border-radius: 999px; background: var(--foreground); color: var(--background); font-size: 12px; }
605
+ .rt-toast button { height: 24px; padding: 0 12px; border: 0; border-radius: 999px; background: color-mix(in oklch, var(--background) 20%, transparent); color: inherit; font-weight: 500; cursor: pointer; }
606
+
607
+ /* ---- the setup wizard: one card of a fixed size, a step at a time ---- */
608
+ .sw-card { display: flex; flex-direction: column; width: min(520px, 100%); height: min(440px, calc(100dvh - 32px)); border-radius: 22px; background: var(--background); border: 1px solid var(--border); overflow: hidden; }
609
+ .sw-gone { height: auto; padding: 24px; gap: 16px; align-items: flex-start; }
610
+ .sw-gone p { margin: 0; }
611
+ .sw-head { display: flex; align-items: center; gap: 12px; padding: 20px 24px 12px; }
612
+ .sw-head > svg, .rt-mark > svg { flex-shrink: 0; }
613
+ .sw-head-text { min-width: 0; }
614
+ .sw-head h1 { margin: 0; font-size: 15px; line-height: 22px; font-weight: 600; }
615
+ .sw-head p { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted-foreground); font-size: 12px; font-family: var(--font-mono); }
616
+ .sw-steps { display: flex; gap: 4px; margin: 0; padding: 0 24px 12px; list-style: none; border-bottom: 1px solid var(--border); }
617
+ .sw-steps li { display: flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px 0 3px; border-radius: 999px; color: var(--muted-foreground); font-size: 12px; }
618
+ .sw-steps li.is-here { background: color-mix(in oklch, var(--foreground) 8%, transparent); color: var(--foreground); }
619
+ .sw-step-dot { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 999px; background: color-mix(in oklch, var(--foreground) 8%, transparent); font-size: 11px; font-variant-numeric: tabular-nums; }
620
+ .sw-steps li.is-here .sw-step-dot { background: var(--primary); color: var(--primary-foreground); }
621
+ .sw-steps li.is-done .sw-step-dot { background: color-mix(in oklch, var(--primary) 25%, transparent); color: var(--foreground); }
622
+ .sw-body { flex: 1; min-height: 0; overflow: auto; padding: 20px 24px; }
623
+ .sw-label { display: block; font-weight: 600; }
624
+ .sw-summary { margin: 2px 0 14px; color: var(--muted-foreground); }
625
+ .sw-input { width: 100%; height: 36px; padding: 0 14px; border: 1px solid var(--border); border-radius: 999px; background: color-mix(in oklch, var(--foreground) 4%, transparent); color: var(--foreground); font: inherit; font-family: var(--font-mono); outline: none; }
626
+ .sw-input:focus { border-color: var(--primary); }
627
+ .sw-input.is-wrong { border-color: var(--destructive); }
628
+ .sw-hint { margin: 6px 14px 0; font-size: 12px; color: var(--muted-foreground); }
629
+ .sw-hint.is-wrong { color: var(--destructive); }
630
+ .sw-review { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 0; }
631
+ .sw-review > div { display: contents; }
632
+ .sw-review dt { color: var(--muted-foreground); }
633
+ .sw-review dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
634
+ .sw-error { margin: 12px 0 0; color: var(--destructive); font-size: 12px; white-space: pre-wrap; }
635
+ .sw-foot { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
636
+ .sw-gap { flex: 1; }
637
+ .sw-busy { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--muted-foreground); font-size: 12px; white-space: nowrap; }
638
+ .sw-button { flex-shrink: 0; white-space: nowrap; height: 30px; padding: 0 14px; border: 0; border-radius: 999px; background: color-mix(in oklch, var(--foreground) 8%, transparent); color: var(--foreground); font: inherit; cursor: pointer; }
639
+ .sw-button.is-quiet { background: transparent; color: var(--muted-foreground); }
640
+ .sw-button.is-quiet:hover { color: var(--foreground); background: color-mix(in oklch, var(--foreground) 6%, transparent); }
641
+ .sw-button.is-primary { background: var(--primary); color: var(--primary-foreground); font-weight: 500; }
642
+ .sw-button:disabled { opacity: .5; cursor: default; }
643
+ @media (max-width: 560px) { .rt-row-note { display: none; } }
644
+ .sw-button:focus-visible, .rt-add-go:focus-visible, .rt-add-browse:focus-visible, .rt-row-forget:focus-visible, .rt-toast button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
645
+ }
package/src/gui/theme.ts CHANGED
@@ -1,12 +1,16 @@
1
- // Light or dark: the system decides unless a choice is remembered in this
2
- // browser. The stylesheet follows prefers-color-scheme by itself; a choice
3
- // is a data-theme attribute on the root element, set before the first paint.
1
+ // Light or dark: the system decides unless voss's settings choose one, which
2
+ // this browser remembers. The stylesheet follows prefers-color-scheme by
3
+ // itself; a choice is a data-theme attribute on the root element, set before
4
+ // the first paint from what was remembered.
4
5
  export type Theme = "light" | "dark" | "system";
5
6
  export const themeKey = "babavoss.theme";
6
-
7
- /** Runs in the head of every page, before anything paints: a remembered choice never flashes the other theme. */
7
+ /** Runs in the head of every page, before anything paints: voss's theme as this browser last saw it, so a page never flashes the other theme. */
8
8
  export const early = `try{var t=localStorage.getItem(${JSON.stringify(themeKey)});if(t==="light"||t==="dark")document.documentElement.dataset.theme=t}catch(e){}`;
9
9
 
10
+ /** The desk's colour, light and dark, as theme.css has it: inline in every page's head, so the first paint is the desk's colour before the stylesheet arrives, never the browser's white. */
11
+ export const deskColors = { light: "#ECE4D8", dark: "#151312" } as const;
12
+ export const firstPaint = `html{background:${deskColors.light};color-scheme:light dark}@media (prefers-color-scheme:dark){html:not([data-theme="light"]){background:${deskColors.dark}}}html[data-theme="dark"]{background:${deskColors.dark}}`;
13
+
10
14
  export function theme(): Theme {
11
15
  try {
12
16
  const v = localStorage.getItem(themeKey);
@@ -0,0 +1,48 @@
1
+ // voss's own settings, the person's, as every page holds them: read from
2
+ // /api/settings when the page opens and again when its window comes back to
3
+ // the front, so a change made in another tab arrives; one copy per page,
4
+ // shared by whatever reads it. The theme is applied to the whole page, and
5
+ // remembered in this browser so the next page paints in it at once.
6
+ import { useEffect, useSyncExternalStore } from "react";
7
+ import { apiHeaders } from "babavoss/web";
8
+ import type { VossSettings } from "../server/messages.ts";
9
+ import type { KeyChanges } from "../desktop/keys.ts";
10
+ import { setTheme } from "./theme.ts";
11
+
12
+ let current: VossSettings | null = null;
13
+ /** Saves so far: a read begun before the last of them answers with what it was, and is dropped, so a save is never undone by an older read. */
14
+ let saves = 0;
15
+ const listeners = new Set<() => void>();
16
+ const publish = (s: VossSettings) => { current = s; setTheme(s.theme); for (const l of listeners) l(); };
17
+
18
+ async function fetchSettings(): Promise<void> {
19
+ const at = saves;
20
+ try {
21
+ const r = await fetch("/api/settings", { headers: apiHeaders() });
22
+ if (r.ok) { const s = await r.json() as VossSettings; if (at === saves) publish(s); }
23
+ } catch { /* asked again when the window comes back */ }
24
+ }
25
+
26
+ /** Changes voss's settings; refused with the daemon's reason, a key that is not one or that two commands would share. */
27
+ export async function saveSettings(change: { theme?: VossSettings["theme"]; keys?: KeyChanges }): Promise<VossSettings> {
28
+ saves++;
29
+ const r = await fetch("/api/settings", { method: "POST", headers: { "content-type": "application/json", ...apiHeaders() }, body: JSON.stringify(change) });
30
+ const j = await r.json() as VossSettings & { error?: { message: string } };
31
+ if (!r.ok || j.error) throw new Error(j.error?.message ?? r.statusText);
32
+ publish(j);
33
+ return j;
34
+ }
35
+
36
+ let watching = 0;
37
+ /** voss's settings, null until they arrive; the page keeps them current while anything reads them. */
38
+ export function useVossSettings(): VossSettings | null {
39
+ useEffect(() => {
40
+ if (watching++ === 0) {
41
+ void fetchSettings();
42
+ addEventListener("focus", onFront); document.addEventListener("visibilitychange", onFront);
43
+ }
44
+ return () => { if (--watching === 0) { removeEventListener("focus", onFront); document.removeEventListener("visibilitychange", onFront); } };
45
+ }, []);
46
+ return useSyncExternalStore((l) => { listeners.add(l); return () => listeners.delete(l); }, () => current);
47
+ }
48
+ const onFront = () => { if (document.visibilityState === "visible") void fetchSettings(); };
@@ -1,9 +1,8 @@
1
- // The New baba app, at voss's root: a folder browser and, in the folder
2
- // chosen, a baba made, or one already there remembered or opened. A list
3
- // moved by the keyboard as much as by the pointer: arrows move, Enter goes
4
- // into a folder, Backspace up, Cmd/Ctrl Enter makes. The daemon answers:
5
- // /api/dirs for a directory's folders, /api/projects/add to remember a baba
6
- // or make one.
1
+ // The folder browser of voss's root, behind its Browse: it points at a folder
2
+ // to add, and the picker adds it, a baba there or one to set up. A list moved
3
+ // by the keyboard as much as by the pointer: arrows move, Enter goes into a
4
+ // folder, Backspace up, Cmd/Ctrl Enter picks. The daemon answers /api/dirs
5
+ // with a directory's folders. Also the helpers the root's pages share.
7
6
  import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
8
7
  import { apiHeaders } from "babavoss/web";
9
8
 
@@ -17,7 +16,7 @@ export const getJson = async <T,>(path: string): Promise<T> => {
17
16
  if (!r.ok || j.error) throw new Error(j.error?.message ?? r.statusText);
18
17
  return j;
19
18
  };
20
- const postJson = async <T,>(path: string, body: unknown): Promise<T> => {
19
+ export const postJson = async <T,>(path: string, body: unknown): Promise<T> => {
21
20
  const r = await fetch(path, { method: "POST", headers: { "content-type": "application/json", ...apiHeaders() }, body: JSON.stringify(body) });
22
21
  const j = await r.json() as T & { error?: { message: string } };
23
22
  if (!r.ok || j.error) throw new Error(j.error?.message ?? r.statusText);
@@ -35,8 +34,6 @@ export const short = (path: string, home: string, max = 52) => {
35
34
  for (let keep = 3; keep >= 1; keep--) { const s = `${head}/…/${parts.slice(-keep).join("/")}`; if (s.length <= max || keep === 1) return s; }
36
35
  return t;
37
36
  };
38
- /** A directory's name as a baba's name: what `voss init` would choose. */
39
- const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
40
37
  const isTyping = (e: KeyboardEvent) => e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement;
41
38
 
42
39
  // ---- glyphs: drawn here, 16 on a 16 grid, in the current colour ------------
@@ -60,16 +57,13 @@ function useInView(list: React.RefObject<HTMLElement | null>, active: number) {
60
57
 
61
58
  // ---- the folder picker ---------------------------------------------------
62
59
 
63
- /** The wizard: from `start`, or the daemon's own choice of folder; `onOpened` with the baba's name once it is made, remembered or found; `onBack` on Escape. */
64
- export function Folders({ home, start, onBack, onOpened, onVerb }: { home: string; start?: string; onBack: () => void; onOpened: (name: string) => void; onVerb?: (verb: string) => void }) {
60
+ /** The browser: from `start`, or the user's home; `pick` with the folder chosen; `onBack` on Escape. */
61
+ export function Folders({ home, start, onBack, pick }: { home: string; start?: string; onBack: () => void; pick: (dir: string) => void }) {
65
62
  const [at, setAt] = useState<Browsed | null>(null);
66
63
  const [loading, setLoading] = useState(true);
67
64
  const [error, setError] = useState<string | null>(null);
68
- const [busy, setBusy] = useState<string | null>(null);
69
65
  const [filter, setFilter] = useState("");
70
66
  const [active, setActive] = useState(0);
71
- const [name, setName] = useState("");
72
- const [title, setTitle] = useState("");
73
67
  const list = useRef<HTMLOListElement>(null);
74
68
  const filterRef = useRef<HTMLInputElement>(null);
75
69
  const homeDir = at?.home || home;
@@ -80,8 +74,7 @@ export function Folders({ home, start, onBack, onOpened, onVerb }: { home: strin
80
74
  // Coming up out of a folder, the folder left is the one the keys are on.
81
75
  const from = at?.path;
82
76
  const back = from ? b.dirs.findIndex((d) => d.path === from) : -1;
83
- setAt(b); setError(null); setFilter(""); setName(""); setTitle("");
84
- onVerb?.(b.known ? "open" : b.baba ? "remember" : "make");
77
+ setAt(b); setError(null); setFilter("");
85
78
  // The filter keeps the keys: whatever is typed next narrows the folders here.
86
79
  if (!(document.activeElement instanceof HTMLInputElement) || document.activeElement === filterRef.current) filterRef.current?.focus();
87
80
  setActive(back >= 0 ? back + (b.parent ? 1 : 0) : 0);
@@ -99,25 +92,15 @@ export function Folders({ home, start, onBack, onOpened, onVerb }: { home: strin
99
92
  const cur = Math.min(active, Math.max(0, rows.length - 1));
100
93
  useInView(list, cur);
101
94
 
102
- const act = async () => {
103
- if (!at || busy) return;
104
- if (at.known) { const p = await getJson<{ projects: Project[] }>("/api/projects"); const own = p.projects.find((x) => x.dir === at.path); if (own) onOpened(own.name); return; }
105
- const init = !at.baba;
106
- setBusy(init ? "Making the baba and installing its packages…" : "Opening the baba…");
107
- setError(null);
108
- try {
109
- const made = await postJson<{ name: string }>("/api/projects/add", { dir: at.path, init, ...(name.trim() ? { name: name.trim() } : {}), ...(title.trim() ? { title: title.trim() } : {}) });
110
- onOpened(made.name);
111
- } catch (e) { setError(String(e instanceof Error ? e.message : e)); setBusy(null); }
112
- };
95
+ const act = () => { if (at && !at.known) pick(at.path); };
113
96
  const enter = (i: number) => { const r = rows[i]; if (r) walk(r.kind === "up" ? r.path : r.d.path); };
114
97
 
115
98
  useEffect(() => {
116
99
  const key = (e: KeyboardEvent) => {
117
100
  // The shell's launcher has the keys while it is open over the wizard.
118
- if (busy || document.querySelector(".desktop-launcher")) return;
101
+ if (document.querySelector(".desktop-launcher")) return;
119
102
  const inField = isTyping(e) && e.target !== filterRef.current;
120
- if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); void act(); return; }
103
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); act(); return; }
121
104
  if (e.key === "Escape") { e.preventDefault(); if (inField) (e.target as HTMLElement).blur(); else if (filter) setFilter(""); else onBack(); return; }
122
105
  if (inField) return;
123
106
  if (e.key === "ArrowDown") { e.preventDefault(); setActive(Math.min(rows.length - 1, cur + 1)); }
@@ -141,12 +124,11 @@ export function Folders({ home, start, onBack, onOpened, onVerb }: { home: strin
141
124
  // Deep paths keep their first crumb and the last three; the ones between fold into one that goes to the nearest of them.
142
125
  const shown = crumbs.length > 5 ? [crumbs[0]!, { label: "…", path: crumbs[crumbs.length - 4]!.path }, ...crumbs.slice(-3)] : crumbs;
143
126
  const here = at ? short(at.path, homeDir, 44) : "";
144
- const leaf = at ? at.path.split("/").filter(Boolean).pop() ?? "" : "";
145
127
 
146
128
  return (
147
129
  <div className="pk-pane pk-fade">
148
130
  <header className="pk-strip">
149
- <span className="pk-strip-title">New baba</span>
131
+ <span className="pk-strip-title">Choose a folder</span>
150
132
  <span className="pk-gap" />
151
133
  {loading && <Spinner />}
152
134
  </header>
@@ -193,19 +175,14 @@ export function Folders({ home, start, onBack, onOpened, onVerb }: { home: strin
193
175
  <span className="pk-dock-icon">{at?.baba ? <BabaGlyph /> : <FolderGlyph />}</span>
194
176
  <span className="pk-dock-text">
195
177
  {!at ? "…" : at.known ? <>Voss already knows the baba in <span className="pk-here" title={at?.path}>{here}</span>.</>
196
- : at.baba ? <>There is a baba in <span className="pk-here" title={at?.path}>{here}</span>. Remember it to open it here.</>
197
- : <>A new baba goes in <span className="pk-here" title={at?.path}>{here}</span>, as <code>.baba/</code> beside what is there.</>}
178
+ : at.baba ? <>There is a baba in <span className="pk-here" title={at?.path}>{here}</span>. Add it to the list.</>
179
+ : <>There is no baba in <span className="pk-here" title={at?.path}>{here}</span> yet: added, it is set up first.</>}
198
180
  </span>
199
181
  </div>
200
182
  <div className="pk-dock-line">
201
- {at && !at.baba && <>
202
- <label className="pk-field"><span>Name</span><input value={name} placeholder={slug(leaf) || "baba"} spellCheck={false} disabled={!!busy} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void act(); } }} /></label>
203
- <label className="pk-field is-wide"><span>Title</span><input value={title} placeholder={name.trim() || slug(leaf) || "baba"} disabled={!!busy} onChange={(e) => setTitle(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void act(); } }} /></label>
204
- </>}
205
- {busy && <span className="pk-busy"><Spinner />{busy}</span>}
206
183
  <span className="pk-gap" />
207
- <button type="button" className="pk-primary" disabled={!at || !!busy || loading} onClick={() => void act()}>
208
- {!at ? "Make a baba here" : at.known ? "Open it" : at.baba ? "Remember this baba" : "Make a baba here"}
184
+ <button type="button" className="pk-primary" disabled={!at || loading || at.known} onClick={act}>
185
+ {at?.known ? "Already in the list" : "Add this folder"}
209
186
  <kbd className="pk-kbd is-on-primary">⌘↵</kbd>
210
187
  </button>
211
188
  </div>