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
@@ -1,73 +1,255 @@
1
1
  // voss's root: the picker. One voss runs on this machine, and its root page
2
- // lists the babas it knows, to open one, and makes a new one. A page inside
3
- // a baba is that baba only; Leave brings a tab back here. The root has no
4
- // state: its shell is the same frame over page state, the babas as its
5
- // apps, and its one window is the New baba wizard.
6
- import { useEffect, useMemo, useState } from "react";
7
- import { DesktopFrame, type Picture, type AppRow } from "../desktop/view.tsx";
8
- import type { IconShape } from "../desktop/icons.ts";
9
- import type { Hand } from "../web/app.tsx";
10
- import { theme } from "./theme.ts";
11
- import { Folders, getJson, type Project } from "./wizard.tsx";
2
+ // lists the babas it knows under the mark, to open one; a folder is added by
3
+ // pointing at it, and forgotten with its ×, its files kept. A folder whose
4
+ // baba lacks what voss requires, or has no baba yet, is listed to be set up:
5
+ // opening it opens the setup wizard, at /?setup=FOLDER, until it has
6
+ // everything, and then it opens as any other. A page inside a baba is that
7
+ // baba only; Leave brings a tab back here. The root has no state of its own.
8
+ import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react";
9
+ import { Eyes } from "../desktop/view.tsx";
10
+ import { Folders, getJson, postJson, short, tilde, type Project } from "./wizard.tsx";
11
+ import { SetupWizard } from "./setup.tsx";
12
+ import type { PendingEntry } from "../server/messages.ts";
13
+ import { useVossSettings } from "./voss-settings.ts";
14
+ import { lockup } from "./lockup.ts";
12
15
 
13
- /** The babas this machine knows, and its home, from the daemon; null until they arrive. */
14
- export function useProjects(): { projects: Project[] | null; home: string } {
15
- const [state, setState] = useState<{ projects: Project[] | null; home: string }>({ projects: null, home: "" });
16
- useEffect(() => {
17
- let live = true;
18
- getJson<{ projects: Project[]; home?: string }>("/api/projects").then((j) => { if (live) setState({ projects: j.projects, home: j.home ?? "" }); }, () => { if (live) setState({ projects: [], home: "" }); });
19
- return () => { live = false; };
20
- }, []);
21
- return state;
16
+ /** What the daemon lists: the babas it runs, the folders it keeps to be set up or that failed, and the user's home. */
17
+ export interface Listed { projects: Project[]; pending: PendingEntry[]; home: string }
18
+
19
+ /** The babas this machine knows, and its home, from the daemon; null until they arrive. `reload` asks again. */
20
+ export function useProjects(): { listed: Listed | null; reload: () => Promise<Listed>; set: (l: Listed) => void } {
21
+ const [listed, setListed] = useState<Listed | null>(null);
22
+ const reload = async () => {
23
+ const j = await getJson<{ projects: Project[]; pending?: PendingEntry[]; home?: string }>("/api/projects");
24
+ const l = { projects: j.projects, pending: j.pending ?? [], home: j.home ?? "" };
25
+ setListed(l);
26
+ return l;
27
+ };
28
+ useEffect(() => { reload().catch(() => setListed({ projects: [], pending: [], home: "" })); }, []);
29
+ return { listed, reload, set: setListed };
22
30
  }
23
31
 
24
- const href = (name: string) => `/${encodeURIComponent(name)}/`;
25
- const plus: readonly IconShape[] = [{ tag: "path", attrs: { d: "M5 12h14" } }, { tag: "path", attrs: { d: "M12 5v14" } }];
26
- const eyes: readonly IconShape[] = [{ tag: "circle", attrs: { cx: "8.1", cy: "12", r: "4.3" } }, { tag: "circle", attrs: { cx: "17.2", cy: "12", r: "3" } }];
27
- /** The wizard's app, the picker's one window. */
28
- export const NEW = "new";
29
- /** A baba's app key in the picker; a baba may be called `new`, so the key says which it is. */
30
- export const babaKey = (name: string) => `baba:${name}`;
32
+ /** One row of the picker: a baba voss runs, or a folder it keeps to set up or that failed; `depth` is how deep inside other babas it is. */
33
+ export type PickerRow = ({ kind: "baba"; p: Project } | { kind: "pending"; e: PendingEntry }) & { depth: number; dir: string };
31
34
 
32
35
  /**
33
- * The picker's rows as the frame's apps: every baba under Babas, a baba inside another under its parent's
34
- * title, in the order voss lists them; then New baba under Make. Pure: the same from the same list.
36
+ * The picker's rows: every baba in the order voss lists them, the folders to set up after, each one inside
37
+ * another right under its parent, a level deeper. Pure: the same from the same lists.
35
38
  */
36
- export function pickerApps(projects: Project[]): AppRow[] {
37
- const byName = new Map(projects.map((p) => [p.name, p]));
38
- const rows: AppRow[] = projects.map((p, i) => {
39
- const parent = p.parent ? byName.get(p.parent) : undefined;
40
- return { entity: 10 + i, key: babaKey(p.name), title: p.title, group: parent ? `In ${parent.title}` : "Babas", icon: "", many: false, order: i, drawing: eyes };
41
- });
42
- rows.push({ entity: 2, key: NEW, title: "New baba", group: "Make", icon: "plus", many: false, order: projects.length, drawing: plus });
43
- return rows;
39
+ export function pickerRows(projects: readonly Project[], pending: readonly PendingEntry[]): PickerRow[] {
40
+ const all: PickerRow[] = [
41
+ ...projects.map((p) => ({ kind: "baba" as const, p, dir: p.dir ?? "", depth: 0 })),
42
+ ...pending.map((e) => ({ kind: "pending" as const, e, dir: e.dir, depth: 0 })),
43
+ ];
44
+ const parentOf = (r: PickerRow) => r.kind === "baba" ? r.p.parent : r.e.parent;
45
+ const names = new Set(projects.map((p) => p.name));
46
+ const out: PickerRow[] = [];
47
+ const place = (r: PickerRow, depth: number) => {
48
+ out.push({ ...r, depth });
49
+ if (r.kind === "baba") for (const c of all.filter((x) => parentOf(x) === r.p.name)) place(c, depth + 1);
50
+ };
51
+ for (const r of all) { const up = parentOf(r); if (!up || !names.has(up)) place(r, 0); }
52
+ return out;
44
53
  }
45
54
 
46
- type Win = Picture["windows"][number];
47
- const wizard: Win = { entity: 3, session: 1, app: 2, path: "/", title: "", memory: {} };
55
+ const href = (name: string) => `/${encodeURIComponent(name)}/`;
56
+ /** The setup wizard's address for a folder. */
57
+ export const setupHref = (dir: string) => `/?setup=${encodeURIComponent(dir)}`;
58
+ const setupAsked = () => new URLSearchParams(location.search).get("setup");
48
59
 
49
- /** The picker: the frame over page state, the babas as its apps; choosing one goes to its page, New baba opens the wizard in front. */
60
+ /** The root page: the picker, or the setup wizard when the address asks for one. */
50
61
  export function Root() {
51
- const { projects, home } = useProjects();
52
- const [open, setOpen] = useState(false);
53
- const apps = useMemo(() => pickerApps(projects ?? []), [projects]);
54
- const picture: Picture = {
55
- session: { entity: 1, key: "main", selected: open ? wizard.entity : null, theme: "system" }, theme: theme(),
56
- apps,
57
- windows: open ? [wizard] : [],
62
+ const { listed, reload, set } = useProjects();
63
+ // The picker wears voss's theme too.
64
+ useVossSettings();
65
+ const [asked, setAsked] = useState(setupAsked);
66
+ useEffect(() => { const back = () => setAsked(setupAsked()); addEventListener("popstate", back); return () => removeEventListener("popstate", back); }, []);
67
+ const go = (dir: string | null) => { history.pushState(null, "", dir ? setupHref(dir) : "/"); setAsked(dir); };
68
+ useEffect(() => { document.title = asked ? "Set up · voss" : "voss"; }, [asked]);
69
+ const ready = asked ? listed?.projects.find((p) => p.dir === asked) : undefined;
70
+ // Set up already: it opens as any other baba.
71
+ useEffect(() => { if (ready) location.replace(href(ready.name)); }, [ready]);
72
+ if (!listed || ready) return <div className="gui-view rt" />;
73
+ if (asked) {
74
+ const entry = listed.pending.find((e) => e.dir === asked);
75
+ return <div className="gui-view rt rt-center">
76
+ {entry && !entry.error
77
+ ? <SetupWizard entry={entry} home={listed.home} taken={listed.projects.map((p) => p.name)} onCancel={() => go(null)} onDone={(name) => location.assign(href(name))} />
78
+ : <div className="sw-card sw-gone">
79
+ <p>{entry?.error ? <>The baba in <code>{tilde(asked, listed.home)}</code> did not open: {entry.error}</> : <>voss does not remember <code>{tilde(asked, listed.home)}</code>.</>}</p>
80
+ <button type="button" className="sw-button" onClick={() => go(null)}>Back to the babas</button>
81
+ </div>}
82
+ </div>;
83
+ }
84
+ return <Picker listed={listed} reload={reload} set={set} setup={go} />;
85
+ }
86
+
87
+ /** Which voss this is, at the header's end: its version, and whether it runs from a checkout. */
88
+ function VossVersion() {
89
+ const [v, setV] = useState<{ version: string; dev: boolean; sha: string } | null>(null);
90
+ useEffect(() => { getJson<{ version: string; dev: boolean; sha: string }>("/api/voss").then(setV, () => {}); }, []);
91
+ if (!v) return null;
92
+ return <span className="rt-version" title={v.sha ? `voss ${v.version}, ${v.sha.slice(0, 12)}` : `voss ${v.version}`}>
93
+ <span className="rt-version-text">v{v.version}</span>{v.dev && <span className="rt-tag"><span className="rt-version-text">checkout</span></span>}
94
+ </span>;
95
+ }
96
+
97
+ /** The brand's lockup, the symbol knocked out of its circle beside the wordmark, in the theme's ink; drawn without its clear space, so it sits flush with the column. */
98
+ function Lockup() {
99
+ const t = lockup.tile, w = Number(lockup.viewBox.split(" ")[2]);
100
+ return <svg className="rt-lockup" viewBox={`${t.x} ${t.y} ${w - 2 * t.x} ${t.height}`} role="img" aria-label="Baba Voss">
101
+ <mask id="rt-lockup-eyes" maskUnits="userSpaceOnUse" x={t.x} y={t.y} width={t.width} height={t.height}><rect x={t.x} y={t.y} width={t.width} height={t.height} fill="#fff" /><g transform={lockup.symbol.transform} fill="#000">{lockup.symbol.eyes.map((e, i) => <circle key={i} {...e} />)}</g></mask>
102
+ <rect {...t} fill="currentColor" mask="url(#rt-lockup-eyes)" />
103
+ <path fill="currentColor" transform={lockup.word.transform} d={lockup.word.d} />
104
+ </svg>;
105
+ }
106
+
107
+ const Cross = () => <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>;
108
+ const Glyph = ({ children }: { children: ReactNode }) => <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{children}</svg>;
109
+ const FolderGlyph = () => <Glyph><path d="M2 4.5c0-.6.4-1 1-1h3l1.5 1.5H13c.6 0 1 .4 1 1V12c0 .6-.4 1-1 1H3c-.6 0-1-.4-1-1z" /></Glyph>;
110
+ const Plus = () => <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 3.5v9M3.5 8h9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>;
111
+ const SetupGlyph = () => <Glyph><circle cx="8" cy="8" r="5.5" strokeDasharray="2.2 2" /></Glyph>;
112
+ const WarnGlyph = () => <Glyph><path d="M8 2.5l6 10.5H2z" /><path d="M8 6.5v3M8 11.2v.1" /></Glyph>;
113
+ const message = (e: unknown) => String(e instanceof Error ? e.message : e);
114
+
115
+ function Picker({ listed, reload, set, setup }: { listed: Listed; reload: () => Promise<Listed>; set: (l: Listed) => void; setup: (dir: string) => void }) {
116
+ const { home } = listed;
117
+ const rows = useMemo(() => pickerRows(listed.projects, listed.pending), [listed]);
118
+ const [browsing, setBrowsing] = useState(false);
119
+ const [error, setError] = useState<string | null>(null);
120
+ const [opened, setOpened] = useState<string | null>(null);
121
+ // The last folder forgotten, for a moment, to undo.
122
+ const [forgot, setForgot] = useState<{ dir: string; label: string } | null>(null);
123
+ useEffect(() => { if (!forgot) return; const t = setTimeout(() => setForgot(null), 6000); return () => clearTimeout(t); }, [forgot]);
124
+ const list = useRef<HTMLUListElement>(null);
125
+ const answer = (j: Listed) => set({ projects: j.projects, pending: j.pending, home });
126
+
127
+ /** Adds a folder: a baba there joins the list; a folder that needs setting up opens its wizard. */
128
+ const add = async (dir: string) => {
129
+ setError(null);
130
+ try {
131
+ const j = await postJson<Listed & { name: string | null; dir: string }>("/api/projects/add", { dir });
132
+ answer(j);
133
+ setBrowsing(false);
134
+ const waiting = j.pending.find((e) => e.dir === j.dir);
135
+ if (waiting && !waiting.error) setup(j.dir);
136
+ return true;
137
+ } catch (e) { setError(message(e)); return false; }
138
+ };
139
+ const forget = async (r: PickerRow) => {
140
+ setError(null);
141
+ try {
142
+ answer(await postJson<Listed>("/api/projects/remove", { dir: r.dir }));
143
+ setForgot({ dir: r.dir, label: r.kind === "baba" ? r.p.title : tilde(r.dir, home) });
144
+ } catch (e) { setError(message(e)); void reload(); }
145
+ };
146
+ const undo = async () => {
147
+ const f = forgot;
148
+ setForgot(null);
149
+ if (f) try { answer(await postJson<Listed>("/api/projects/add", { dir: f.dir })); } catch (e) { setError(message(e)); }
150
+ };
151
+ const open = (r: PickerRow) => {
152
+ if (r.kind === "baba") location.assign(href(r.p.name));
153
+ else if (!r.e.error) setup(r.dir);
154
+ else setOpened(opened === r.dir ? null : r.dir);
155
+ };
156
+ // Up and down move between the rows and the field, as in the launcher.
157
+ const move = (e: KeyboardEvent) => {
158
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
159
+ const all = [...(list.current?.querySelectorAll<HTMLElement>("[data-row]") ?? [])];
160
+ const field = document.querySelector<HTMLInputElement>(".rt-add input");
161
+ if (field) all.push(field);
162
+ const i = all.indexOf(document.activeElement as HTMLElement);
163
+ if (i < 0) return;
164
+ e.preventDefault();
165
+ all[(i + (e.key === "ArrowDown" ? 1 : -1) + all.length) % all.length]?.focus();
166
+ };
167
+
168
+ return <div className="gui-view rt" onKeyDown={move}>
169
+ <div className="rt-column">
170
+ <header className="rt-mark"><h1><Lockup /><span className="rt-hidden">voss</span></h1><VossVersion /></header>
171
+ {browsing
172
+ ? <div className="rt-browse"><Folders home={home} pick={(dir) => void add(dir)} onBack={() => setBrowsing(false)} /></div>
173
+ : <>
174
+ <ul ref={list} className="rt-list" aria-label="Babas">
175
+ {rows.map((r) => {
176
+ const label = r.kind === "baba" ? r.p.title : r.dir.split("/").filter(Boolean).pop() || r.dir;
177
+ const failed = r.kind === "pending" && !!r.e.error;
178
+ return <li key={r.dir} className={`rt-row${failed ? " is-failed" : ""}`} style={{ "--depth": r.depth } as CSSProperties}>
179
+ <button type="button" data-row className="rt-row-main" title={r.kind === "baba" ? `Open ${r.p.title}` : failed ? "Show why it did not open" : `Set up the baba in ${tilde(r.dir, home)}`} onClick={() => open(r)}>
180
+ <span className="rt-row-icon">{r.kind === "baba" ? <Eyes size={16} /> : failed ? <WarnGlyph /> : <SetupGlyph />}</span>
181
+ <span className="rt-row-label">{label}</span>
182
+ {r.kind === "pending" && <span className={`rt-tag${failed ? " is-failed" : ""}`}>{failed ? "Did not open" : "Set up"}</span>}
183
+ <span className="rt-row-note">{short(r.dir, home, 44)}</span>
184
+ </button>
185
+ <button type="button" className="rt-row-forget" aria-label={`Forget ${label}`} title={`Forget ${label}: voss stops running it; its files stay`} onClick={() => void forget(r)}><Cross /></button>
186
+ {failed && opened === r.dir && r.kind === "pending" && <p className="rt-row-error">{r.e.error}</p>}
187
+ </li>;
188
+ })}
189
+ {!rows.length && <li className="rt-empty">No babas yet. Add a folder to start one.</li>}
190
+ </ul>
191
+ <AddField home={home} onAdd={add} onBrowse={() => { setError(null); setBrowsing(true); }} />
192
+ </>}
193
+ {error && <p className="rt-error" role="alert">{error}</p>}
194
+ </div>
195
+ {forgot && <div className="rt-toast" role="status">Forgot {forgot.label}. Its files stay.<button type="button" onClick={() => void undo()}>Undo</button></div>}
196
+ </div>;
197
+ }
198
+
199
+ interface Dir { name: string; path: string; baba: boolean; known: boolean }
200
+
201
+ /** A folder by its path: typed or pasted, completed from the folders there as it is typed; Tab takes the suggestion, Enter adds. */
202
+ function AddField({ home, onAdd, onBrowse }: { home: string; onAdd: (dir: string) => Promise<boolean>; onBrowse: () => void }) {
203
+ const [text, setText] = useState("");
204
+ const [dirs, setDirs] = useState<Dir[]>([]);
205
+ const [at, setAt] = useState(-1);
206
+ const [busy, setBusy] = useState(false);
207
+ const expand = (t: string) => t === "~" ? home : t.startsWith("~/") ? home + t.slice(1) : t;
208
+ const full = expand(text.trim());
209
+ const cut = full.lastIndexOf("/");
210
+ const parent = cut < 0 ? "" : full.slice(0, cut) || "/", leaf = cut < 0 ? full : full.slice(cut + 1);
211
+ useEffect(() => {
212
+ if (!parent.startsWith("/")) { setDirs([]); return; }
213
+ let live = true;
214
+ const t = setTimeout(() => {
215
+ getJson<{ dirs: Dir[] }>(`/api/dirs?path=${encodeURIComponent(parent)}`).then((b) => { if (live) setDirs(b.dirs); }, () => { if (live) setDirs([]); });
216
+ }, 100);
217
+ return () => { live = false; clearTimeout(t); };
218
+ }, [parent]);
219
+ const shown = dirs.filter((d) => d.name.toLowerCase().startsWith(leaf.toLowerCase()) && d.path !== full).slice(0, 6);
220
+ const take = (d: Dir) => { setText(`${tilde(d.path, home)}/`); setAt(-1); };
221
+ const submit = async () => {
222
+ const dir = full.replace(/\/+$/, "") || "/";
223
+ if (!dir.startsWith("/") || busy) return;
224
+ setBusy(true);
225
+ if (await onAdd(dir)) setText("");
226
+ setBusy(false);
58
227
  };
59
- // The shell's verbs, kept in the page: a baba opens as another page; the wizard is the one window here.
60
- const hand: Hand = { fire: (name, args) => {
61
- const a = (args ?? {}) as { app?: string; window?: number };
62
- if (name === "desktop-open") {
63
- if (a.app === NEW) setOpen(true);
64
- else if (a.app?.startsWith("baba:")) location.assign(href(a.app.slice(5)));
65
- } else if (name === "desktop-select") setOpen(a.window === wizard.entity);
66
- else if (name === "desktop-close") setOpen(false);
67
- } };
68
- return <div className="gui-view">
69
- <DesktopFrame picture={picture} title="voss" hand={hand} render={() => (
70
- <Folders home={home} onBack={() => setOpen(false)} onOpened={(name) => location.assign(href(name))} />
71
- )} />
228
+ return <div className="rt-add">
229
+ <label className="rt-add-field">
230
+ <span className="rt-row-icon"><FolderGlyph /></span>
231
+ <input value={text} placeholder="Add a folder: type its path, ~/…" spellCheck={false} autoComplete="off" aria-label="Add a folder by its path" disabled={busy}
232
+ onChange={(e) => { setText(e.target.value); setAt(-1); }}
233
+ onKeyDown={(e) => {
234
+ if (e.key === "Tab" && !e.shiftKey && shown.length && text.trim()) { e.preventDefault(); take(shown[Math.max(0, at)]!); }
235
+ else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && shown.length && text.trim()) {
236
+ e.preventDefault(); e.stopPropagation();
237
+ const next = at + (e.key === "ArrowDown" ? 1 : -1);
238
+ setAt(next < -1 ? shown.length - 1 : next >= shown.length ? -1 : next);
239
+ }
240
+ else if (e.key === "Enter") { e.preventDefault(); if (at >= 0 && shown[at]) take(shown[at]!); else void submit(); }
241
+ else if (e.key === "Escape" && text) { e.preventDefault(); setText(""); }
242
+ }} />
243
+ <button type="button" className="rt-add-browse" onClick={onBrowse}>Browse</button>
244
+ <button type="button" className="rt-add-go" aria-label={busy ? "Adding the folder" : "Add the folder"} title="Add the folder (↵)" disabled={busy || !full.startsWith("/")} onClick={() => void submit()}>{busy ? <span className="pk-spin" aria-hidden="true" /> : <Plus />}</button>
245
+ </label>
246
+ {shown.length > 0 && text.trim() && <ul className="rt-suggest" role="listbox" aria-label="Folders">
247
+ {shown.map((d, i) => <li key={d.path} role="option" aria-selected={i === at}>
248
+ <button type="button" className={i === at ? "is-active" : undefined} onMouseDown={(e) => e.preventDefault()} onClick={() => take(d)}>
249
+ <span className="rt-row-icon">{d.baba ? <Eyes size={14} /> : <FolderGlyph />}</span>{d.name}
250
+ {d.known ? <span className="rt-tag">in the list</span> : d.baba ? <span className="rt-tag">baba</span> : null}
251
+ </button>
252
+ </li>)}
253
+ </ul>}
72
254
  </div>;
73
255
  }
@@ -0,0 +1,11 @@
1
+ // The Baba Voss lockup, the symbol knocked out of an ink circle beside the
2
+ // wordmark, as brand/lockup-*-transparent.svg has it from the brand workshop
3
+ // (marc/logo/lockup.ts): its geometry, drawn in the theme's ink so it is tan on
4
+ // walnut and brown on linen. Inlined, since the package carries no brand
5
+ // files; test/brand.test.ts holds it to brand/.
6
+ export const lockup = {
7
+ viewBox: "0 0 745 160",
8
+ tile: { x: 50.00, y: 30.00, width: 100.00, height: 100.00, rx: 50.00 },
9
+ symbol: { transform: "translate(50.00 30.00) scale(1.0000)", eyes: [{ cx: 39.772, cy: 50, r: 18 }, { cx: 77.500, cy: 50, r: 12.728 }] },
10
+ word: { transform: "translate(195.00 114.90)", d: "M8 0L8-69.80L32.30-69.80Q42.20-69.80 47.55-64.85Q52.90-59.90 52.90-51.40Q52.90-45.40 49.95-41.80Q47-38.20 41.60-36.60L41.60-36.10Q47.90-34.60 51.55-30.65Q55.20-26.70 55.20-19.50Q55.20-15.10 53.75-11.55Q52.30-8 49.65-5.40Q47-2.80 43.25-1.40Q39.50 0 34.80 0L8 0M20.80-9.50L30.80-9.50Q36.30-9.50 39.05-11.55Q41.80-13.60 41.80-18.60L41.80-21.90Q41.80-26.80 39.05-28.85Q36.30-30.90 30.80-30.90L20.80-30.90L20.80-9.50M20.80-40L29.10-40Q34.40-40 36.95-41.90Q39.50-43.80 39.50-48.50L39.50-51.80Q39.50-56.50 36.95-58.40Q34.40-60.30 29.10-60.30L20.80-60.30 M116.50 0L109 0Q104.30 0 101.75-2.35Q99.20-4.70 98.70-8.90L98.20-8.90Q96.80-4.10 92.70-1.45Q88.60 1.20 82.60 1.20Q74.80 1.20 70.20-2.90Q65.60-7 65.60-14.30Q65.60-29.90 88.50-29.90L97.60-29.90L97.60-33.30Q97.60-38.20 95.20-40.70Q92.80-43.20 87.40-43.20Q82.50-43.20 79.50-41.30Q76.50-39.40 74.40-36.40L67.10-42.60Q69.50-46.90 74.85-49.85Q80.20-52.80 88.70-52.80Q98.90-52.80 104.65-48.05Q110.40-43.30 110.40-33.90L110.40-9.60L116.50-9.60L116.50 0M86.90-7.60Q91.50-7.60 94.55-9.75Q97.60-11.90 97.60-15.60L97.60-22.50L88.80-22.50Q78.30-22.50 78.30-15.90L78.30-13.90Q78.30-10.80 80.60-9.20Q82.90-7.60 86.90-7.60 M126.90 0L126.90-74L139.70-74L139.70-42.50L140.40-42.50Q142.50-47.20 146.15-50Q149.80-52.80 155.80-52.80Q160.20-52.80 163.85-51.15Q167.50-49.50 170.10-46.15Q172.70-42.80 174.10-37.70Q175.50-32.60 175.50-25.80Q175.50-19 174.10-13.90Q172.70-8.80 170.10-5.45Q167.50-2.10 163.85-0.45Q160.20 1.20 155.80 1.20Q149.80 1.20 146.15-1.60Q142.50-4.40 140.40-9.10L139.70-9.10L139.70 0L126.90 0M150.40-9Q156-9 159.05-12.40Q162.10-15.80 162.10-21.70L162.10-29.90Q162.10-35.80 159.05-39.20Q156-42.60 150.40-42.60Q146-42.60 142.85-40.45Q139.70-38.30 139.70-33.90L139.70-17.70Q139.70-13.30 142.85-11.15Q146-9 150.40-9 M236.50 0L229 0Q224.30 0 221.75-2.35Q219.20-4.70 218.70-8.90L218.20-8.90Q216.80-4.10 212.70-1.45Q208.60 1.20 202.60 1.20Q194.80 1.20 190.20-2.90Q185.60-7 185.60-14.30Q185.60-29.90 208.50-29.90L217.60-29.90L217.60-33.30Q217.60-38.20 215.20-40.70Q212.80-43.20 207.40-43.20Q202.50-43.20 199.50-41.30Q196.50-39.40 194.40-36.40L187.10-42.60Q189.50-46.90 194.85-49.85Q200.20-52.80 208.70-52.80Q218.90-52.80 224.65-48.05Q230.40-43.30 230.40-33.90L230.40-9.60L236.50-9.60L236.50 0M206.90-7.60Q211.50-7.60 214.55-9.75Q217.60-11.90 217.60-15.60L217.60-22.50L208.80-22.50Q198.30-22.50 198.30-15.90L198.30-13.90Q198.30-10.80 200.60-9.20Q202.90-7.60 206.90-7.60 M299.20 0L280.80 0L261.70-69.80L276.10-69.80L284.80-34.50L289.90-12.50L290.60-12.50L295.80-34.50L304.60-69.80L318.30-69.80 M350 1.20Q344.10 1.20 339.45-0.70Q334.80-2.60 331.55-6.10Q328.30-9.60 326.55-14.60Q324.80-19.60 324.80-25.80Q324.80-32 326.55-37Q328.30-42 331.55-45.50Q334.80-49 339.45-50.90Q344.10-52.80 350-52.80Q355.80-52.80 360.50-50.90Q365.20-49 368.45-45.50Q371.70-42 373.45-37Q375.20-32 375.20-25.80Q375.20-19.60 373.45-14.60Q371.70-9.60 368.45-6.10Q365.20-2.60 360.50-0.70Q355.80 1.20 350 1.20M350-8.60Q355.50-8.60 358.70-11.95Q361.90-15.30 361.90-21.80L361.90-29.80Q361.90-36.30 358.70-39.65Q355.50-43 350-43Q344.50-43 341.30-39.65Q338.10-36.30 338.10-29.80L338.10-21.80Q338.10-15.30 341.30-11.95Q344.50-8.60 350-8.60 M409.80 1.20Q401.30 1.20 395.10-1.45Q388.90-4.10 385.40-8.60L392.90-15.40Q396.10-11.90 400.25-10Q404.40-8.10 409.90-8.10Q414.70-8.10 417.60-9.70Q420.50-11.30 420.50-14.60Q420.50-17.40 418.55-18.40Q416.60-19.40 413.40-19.90L405.10-21.20Q401.90-21.70 398.85-22.65Q395.80-23.60 393.45-25.30Q391.10-27 389.70-29.65Q388.30-32.30 388.30-36.10Q388.30-44.20 394.35-48.50Q400.40-52.80 411-52.80Q418.50-52.80 423.75-50.70Q429-48.60 432.40-44.80L425.70-37.40Q423.50-39.80 419.85-41.65Q416.20-43.50 410.60-43.50Q400.80-43.50 400.80-37.40Q400.80-34.60 402.80-33.55Q404.80-32.50 408-32L416.20-30.70Q419.50-30.20 422.55-29.25Q425.60-28.30 427.95-26.60Q430.30-24.90 431.70-22.30Q433.10-19.70 433.10-15.90Q433.10-7.90 426.95-3.35Q420.80 1.20 409.80 1.20 M469.80 1.20Q461.30 1.20 455.10-1.45Q448.90-4.10 445.40-8.60L452.90-15.40Q456.10-11.90 460.25-10Q464.40-8.10 469.90-8.10Q474.70-8.10 477.60-9.70Q480.50-11.30 480.50-14.60Q480.50-17.40 478.55-18.40Q476.60-19.40 473.40-19.90L465.10-21.20Q461.90-21.70 458.85-22.65Q455.80-23.60 453.45-25.30Q451.10-27 449.70-29.65Q448.30-32.30 448.30-36.10Q448.30-44.20 454.35-48.50Q460.40-52.80 471-52.80Q478.50-52.80 483.75-50.70Q489-48.60 492.40-44.80L485.70-37.40Q483.50-39.80 479.85-41.65Q476.20-43.50 470.60-43.50Q460.80-43.50 460.80-37.40Q460.80-34.60 462.80-33.55Q464.80-32.50 468-32L476.20-30.70Q479.50-30.20 482.55-29.25Q485.60-28.30 487.95-26.60Q490.30-24.90 491.70-22.30Q493.10-19.70 493.10-15.90Q493.10-7.90 486.95-3.35Q480.80 1.20 469.80 1.20" },
11
+ } as const;
@@ -0,0 +1,241 @@
1
+ // The settings, a page per subject under a sidebar of two levels. The baba's
2
+ // first: its Profile, its name for now, written into its package.json. Then
3
+ // voss's own, the person's, the same on every baba: the theme, the keys the
4
+ // desktop runs, and About, which voss this is and where it keeps everything;
5
+ // they are kept in voss's settings.json, through /api/settings.
6
+ import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
7
+ import { apiHeaders, type AppProps } from "babavoss/web";
8
+ import type { Described } from "../baba/node.ts";
9
+ import type { ProjectEntry, Voss } from "../server/messages.ts";
10
+ import { requirementError } from "../baba/requirements.ts";
11
+ import { isMac } from "../desktop/keys.ts";
12
+ import { bindingNote, bindingOf, formatBinding, keyCommands, keymapOf, normalBinding, themes, type KeyCommand } from "../desktop/index.ts";
13
+ import { Button } from "../web/ui.tsx";
14
+ import { saveSettings, useVossSettings } from "./voss-settings.ts";
15
+
16
+ /** The settings' pages, by path, under their level in the sidebar's order. */
17
+ const pages = [
18
+ { level: "baba", path: "/", title: "Profile" },
19
+ { level: "voss", path: "/appearance", title: "Appearance" },
20
+ { level: "voss", path: "/keyboard", title: "Keyboard" },
21
+ { level: "voss", path: "/about", title: "About" },
22
+ ] as const;
23
+
24
+ /** The baba the settings are open in, as the page describes it: given by context, so the app's component stays the same as it is described again, and a half-typed name or a key being recorded stays. */
25
+ export const SettingsBaba = createContext<Described | undefined>(undefined);
26
+
27
+ export function Settings({ path, navigate, setTitle }: AppProps) {
28
+ const baba = useContext(SettingsBaba);
29
+ const page = pages.find((p) => p.path === path) ?? pages[0];
30
+ useEffect(() => { setTitle("Settings"); }, []);
31
+ const link = (p: typeof pages[number]) => <button key={p.path} type="button" className={`gui-settings-link${p === page ? " is-active" : ""}`} aria-current={p === page ? "page" : undefined} onClick={() => navigate(p.path)}>{p.title}</button>;
32
+ return <div className="gui-settings">
33
+ <nav className="gui-settings-nav" aria-label="Settings">
34
+ <h2 className="gui-settings-level">Baba</h2>
35
+ {pages.filter((p) => p.level === "baba").map(link)}
36
+ <h2 className="gui-settings-level">voss</h2>
37
+ {pages.filter((p) => p.level === "voss").map(link)}
38
+ </nav>
39
+ <section className="gui-settings-page" aria-labelledby="gui-settings-title">
40
+ <header className="gui-settings-head">
41
+ <h1 id="gui-settings-title">{page.title}</h1>
42
+ </header>
43
+ {page.path === "/" ? <Profile baba={baba} /> : page.path === "/appearance" ? <Appearance /> : page.path === "/keyboard" ? <Keyboard /> : <About baba={baba} />}
44
+ </section>
45
+ </div>;
46
+ }
47
+
48
+ /** One setting: what it is on the left, its control on the right. */
49
+ function Setting({ title, summary, children }: { title: ReactNode; summary?: ReactNode; children: ReactNode }) {
50
+ return <div className="gui-setting">
51
+ <div className="gui-setting-text"><div className="gui-setting-title">{title}</div>{summary && <div className="gui-setting-summary">{summary}</div>}</div>
52
+ <div className="gui-setting-control">{children}</div>
53
+ </div>;
54
+ }
55
+
56
+ const message = (e: unknown) => String(e instanceof Error ? e.message : e).replace(/^Error: /, "");
57
+
58
+ function Appearance() {
59
+ const settings = useVossSettings();
60
+ const current = settings?.theme ?? "system";
61
+ const [error, setError] = useState<string | null>(null);
62
+ return <div className="gui-settings-list">
63
+ <Setting title="Theme" summary={<>System follows this computer's light or dark appearance.{error && <span className="gui-setting-note is-error">{error}</span>}</>}>
64
+ <div className="gui-choices" role="radiogroup" aria-label="Theme">
65
+ {themes.map((t) => <Button key={t} variant={t === current ? "default" : "quiet"} role="radio" aria-checked={t === current} disabled={!settings} onClick={() => { setError(null); saveSettings({ theme: t }).catch((e) => setError(message(e))); }}>{t[0]!.toUpperCase() + t.slice(1)}</Button>)}
66
+ </div>
67
+ </Setting>
68
+ </div>;
69
+ }
70
+
71
+ function Keyboard() {
72
+ const settings = useVossSettings();
73
+ const keymap = keymapOf(settings?.keys);
74
+ const remapped = Object.keys(settings?.keys ?? {}).length > 0;
75
+ const [error, setError] = useState<{ id: KeyCommand | null; message: string } | null>(null);
76
+ const change = (keys: Partial<Record<KeyCommand, string | null>>, id: KeyCommand | null) => {
77
+ setError(null);
78
+ saveSettings({ keys }).catch((e) => setError({ id, message: message(e) }));
79
+ };
80
+ return <div className="gui-settings-list">
81
+ {keyCommands.map((c) => {
82
+ const binding = keymap[c.id], own = settings?.keys?.[c.id] !== undefined;
83
+ const note = error?.id === c.id ? error.message : binding && bindingNote(binding);
84
+ return <Setting key={c.id} title={c.title} summary={<>{c.summary}{note && <span className={`gui-setting-note${error?.id === c.id ? " is-error" : ""}`}>{note}</span>}</>}>
85
+ <KeyRecorder label={c.title} binding={binding} onRecord={(b) => {
86
+ const other = keyCommands.find((o) => o.id !== c.id && keymap[o.id] === b);
87
+ if (other) setError({ id: c.id, message: `${formatBinding(b)} already runs ${other.title}.` }); else change({ [c.id]: b }, c.id);
88
+ }} onInvalid={(m) => setError({ id: c.id, message: m })} />
89
+ <Button variant="quiet" className={own ? "" : "is-hidden"} disabled={!own} title={c.default ? `Back to ${formatBinding(c.default)}` : "Back to no key"} onClick={() => change({ [c.id]: null }, c.id)}>Reset</Button>
90
+ </Setting>;
91
+ })}
92
+ <p className="gui-settings-foot bv-muted">
93
+ Click a key, then press the new one; Escape cancels. The launcher's arrows, Enter and Escape are fixed.
94
+ {remapped && <> <Button variant="quiet" onClick={() => change(Object.fromEntries(keyCommands.map((c) => [c.id, null])), null)}>Reset all</Button></>}
95
+ {error?.id === null && <span className="gui-setting-note is-error">{error.message}</span>}
96
+ </p>
97
+ </div>;
98
+ }
99
+
100
+ /** The baba's profile: its name, which is its address; renamed, voss opens it again under the new one and the page follows. */
101
+ function Profile({ baba }: { baba?: Described }) {
102
+ const [name, setName] = useState(baba?.name ?? "");
103
+ const [taken, setTaken] = useState<string[]>([]);
104
+ const [busy, setBusy] = useState(false);
105
+ const [error, setError] = useState<string | null>(null);
106
+ useEffect(() => {
107
+ fetch("/api/projects", { headers: apiHeaders() }).then((r) => r.json() as Promise<{ projects: ProjectEntry[] }>)
108
+ .then((j) => setTaken(j.projects.map((p) => p.name).filter((n) => n !== baba?.name)), () => {});
109
+ }, [baba?.name]);
110
+ if (!baba) return <p className="bv-muted">…</p>;
111
+ const why = name === baba.name ? null : requirementError("name", name, taken);
112
+ const changed = name !== baba.name && !why;
113
+ const save = async () => {
114
+ if (!changed || busy) return;
115
+ setBusy(true); setError(null);
116
+ try {
117
+ const r = await fetch("/api/projects/rename", { method: "POST", headers: { "content-type": "application/json", ...apiHeaders() }, body: JSON.stringify({ name: baba.name, to: name }) });
118
+ const j = await r.json() as { name: string | null; error?: { message: string } };
119
+ if (!r.ok || j.error || !j.name) throw new Error(j.error?.message ?? "voss did not open it again");
120
+ location.assign(`/${encodeURIComponent(j.name)}/settings`);
121
+ } catch (e) { setError(message(e)); setBusy(false); }
122
+ };
123
+ return <form className="gui-settings-list" onSubmit={(e) => { e.preventDefault(); void save(); }}>
124
+ <Setting title="Name" summary={<>What voss calls this baba, at <code>/{baba.name}/</code> and as <code>voss {baba.name}</code> on the CLI; not its folder's name, which stays as it is.
125
+ {(why || error) ? <span className="gui-setting-note is-error">{why ?? error}</span> : changed && <span className="gui-setting-note">Renamed, it moves to <code>/{name}/</code>; links to <code>/{baba.name}/</code> stop working.</span>}</>}>
126
+ <input className="gui-name" value={name} spellCheck={false} autoComplete="off" aria-label="Name" disabled={busy} onChange={(e) => setName(e.target.value.toLowerCase())} />
127
+ <Button variant={changed ? "primary" : "quiet"} type="submit" disabled={!changed || busy}>{busy ? "Renaming…" : "Rename"}</Button>
128
+ </Setting>
129
+ </form>;
130
+ }
131
+
132
+ /** A binding as a key, which records the next press when clicked: a press of a modifier alone waits for the key; Escape and leaving it cancel. */
133
+ function KeyRecorder({ label, binding, onRecord, onInvalid }: { label: string; binding: string | null; onRecord(binding: string): void; onInvalid(message: string): void }) {
134
+ const [recording, setRecording] = useState(false);
135
+ const ref = useRef<HTMLButtonElement>(null);
136
+ return <button ref={ref} type="button" className={`gui-key${recording ? " is-recording" : ""}`} aria-label={recording ? `Press the new key for ${label}` : `${label}: ${binding ? formatBinding(binding) : "no key"}, click to ${binding ? "change" : "set one"}`}
137
+ onClick={() => setRecording((r) => !r)} onBlur={() => setRecording(false)}
138
+ onKeyDown={(e) => {
139
+ if (!recording) return;
140
+ // While it records, a press is the key, never a command: neither the desktop nor the browser sees it.
141
+ e.preventDefault(); e.stopPropagation();
142
+ if (e.key === "Escape" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { setRecording(false); return; }
143
+ const pressed = bindingOf(e.nativeEvent);
144
+ if (!pressed) return;
145
+ const b = normalBinding(pressed);
146
+ setRecording(false);
147
+ if (!b) onInvalid(`${formatBinding(pressed)} is typing, not a command: hold ${isMac() ? "⌘, ⌥ or ⌃" : "Ctrl or Alt"} with it.`);
148
+ else if (b !== binding) onRecord(b);
149
+ }}>{recording ? "Press keys…" : binding ? formatBinding(binding) : <span className="gui-key-none">Not set</span>}</button>;
150
+ }
151
+
152
+ /** What /api/projects answers: the babas voss knows, which voss runs, the user's home. */
153
+ interface Projects { projects: ProjectEntry[]; voss: Voss; home: string }
154
+
155
+ /** A path as people read it: under the home directory as ~/… */
156
+ const tilde = (path: string, home: string) => home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
157
+
158
+ /** One fact: its name, its value, and a copy of the value when it is a path or an id. */
159
+ function Fact({ name, value, copy, mono }: { name: string; value: ReactNode; copy?: string; mono?: boolean }) {
160
+ const [copied, setCopied] = useState(false);
161
+ return <div className="gui-fact">
162
+ <dt>{name}</dt>
163
+ <dd className={mono ? "is-mono" : undefined}>
164
+ <span className="gui-fact-value" title={copy}>{value}</span>
165
+ {copy && <button type="button" className="gui-fact-copy" aria-label={`Copy ${name}`} title={copied ? "Copied" : "Copy"} onClick={() => { void navigator.clipboard?.writeText(copy).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}>{copied ? "Copied" : "Copy"}</button>}
166
+ </dd>
167
+ </div>;
168
+ }
169
+
170
+ function Facts({ title, rows }: { title: string; rows: MaybeRow[] }) {
171
+ return <section className="gui-facts">
172
+ <h2>{title}</h2>
173
+ <dl>{rows.filter((r): r is FactRow => !!r).map((r) => <Fact key={r.name} {...r} />)}</dl>
174
+ </section>;
175
+ }
176
+ interface FactRow { name: string; value: ReactNode; copy?: string; mono?: boolean }
177
+ /** A row, or what a condition left in its place. */
178
+ type MaybeRow = FactRow | false | null | undefined | "";
179
+
180
+ function About({ baba }: { baba?: Described }) {
181
+ const [p, setP] = useState<Projects | null>(null);
182
+ const [failed, setFailed] = useState<string | null>(null);
183
+ const [copied, setCopied] = useState(false);
184
+ useEffect(() => {
185
+ let live = true;
186
+ fetch("/api/projects", { headers: apiHeaders() }).then(async (r) => { if (!r.ok) throw new Error(r.statusText); return r.json() as Promise<Projects>; })
187
+ .then((x) => { if (live) setP(x); }, (e) => { if (live) setFailed(String(e)); });
188
+ return () => { live = false; };
189
+ }, []);
190
+ if (failed) return <p className="bv-error">voss did not answer: {failed}</p>;
191
+ if (!p) return <p className="bv-muted">…</p>;
192
+ const { voss, home } = p, a = voss.about;
193
+ const here = baba && p.projects.find((x) => x.name === baba.name);
194
+ const path = (name: string, full: string): FactRow => ({ name, value: tilde(full, home), copy: full, mono: true });
195
+ const m = baba?.manifest;
196
+ const sections: { title: string; rows: MaybeRow[] }[] = [
197
+ { title: "voss", rows: [
198
+ { name: "Version", value: <>{voss.version}{voss.dev && <span className="gui-fact-tag">checkout</span>}{voss.stale && voss.installed && <span className="gui-fact-tag is-warn">{voss.installed.version} installed, restart to update</span>}</>, copy: voss.version },
199
+ { name: "Commit", value: voss.sha ? voss.sha.slice(0, 12) : voss.dev ? "the working tree" : "not stamped", ...(voss.sha ? { copy: voss.sha, mono: true } : {}) },
200
+ voss.at && { name: "Packed", value: new Date(voss.at).toLocaleString() },
201
+ { name: "Address", value: location.origin, copy: location.origin, mono: true },
202
+ { name: "Process", value: `pid ${voss.pid}${a ? `, since ${new Date(a.started).toLocaleString()}` : ""}` },
203
+ { name: "Runs as", value: voss.kept ? "the user service" : "a command, until it stops" },
204
+ ] },
205
+ ...(a ? [
206
+ { title: "Runtime", rows: [
207
+ { name: "Bun", value: a.runtime.bun, copy: a.runtime.bun },
208
+ { name: "Platform", value: `${a.runtime.platform} ${a.runtime.arch}` },
209
+ path("Executable", a.runtime.executable),
210
+ ] },
211
+ { title: "Installation", rows: [
212
+ path("Framework", a.framework),
213
+ a.cli && path("CLI", a.cli),
214
+ path("State", a.root),
215
+ path("Registry", a.registry),
216
+ path("Page token", a.token),
217
+ a.service && path("User service", a.service.file),
218
+ a.service && path("Service log", a.service.log),
219
+ ] },
220
+ ] : []),
221
+ ...(baba ? [{ title: "This baba", rows: [
222
+ { name: "Name", value: baba.name, copy: baba.name, mono: true },
223
+ { name: "Title", value: baba.title },
224
+ here && path("Directory", here.dir),
225
+ here && path("Code", `${here.dir}/.baba`),
226
+ here?.parent && { name: "Inside", value: here.parent },
227
+ { name: "Generation", value: String(baba.gen) },
228
+ m && { name: "Contract", value: `${m.systems.length} systems, ${Object.keys(m.actions).length} actions, ${Object.keys(m.queries).length} queries, ${(m.apps ?? []).length} apps` },
229
+ baba.error && { name: "Load", value: <span className="gui-fact-error">{baba.error}</span> },
230
+ ] }] : []),
231
+ ];
232
+ // Everything above as plain text, for a bug report or a message.
233
+ const report = sections.map((s) => [`# ${s.title}`, ...s.rows.filter((r): r is FactRow => !!r).map((r) => `${r.name}: ${r.copy ?? (typeof r.value === "string" ? r.value : "")}`)].join("\n")).join("\n\n");
234
+ return <div className="gui-about">
235
+ {sections.map((s) => <Facts key={s.title} {...s} />)}
236
+ <p className="gui-settings-foot bv-muted">
237
+ <Button variant="quiet" onClick={() => { void navigator.clipboard?.writeText(report).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}>{copied ? "Copied" : "Copy all"}</Button>
238
+ As plain text, for a bug report.
239
+ </p>
240
+ </div>;
241
+ }