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
@@ -4,9 +4,9 @@ import { useColumn, useHand, useResource } from "../web/hooks.ts";
4
4
  import { useScope } from "../web/core.tsx";
5
5
  import { useBaba } from "../web/state.tsx";
6
6
  import type { Hand } from "../web/app.tsx";
7
- import { Bob } from "./bob.ts";
8
7
  import type { IconShape } from "./icons.ts";
9
8
  import { canonicalPath, matchRoute } from "./routes.ts";
9
+ import { commandOf, formatBinding, keymapOf, type Keymap, type KeyOverrides } from "./keys.ts";
10
10
  import { searchApps, type DesktopSystem, type DesktopApp, type DesktopWindow, type Theme, type Session } from "./index.ts";
11
11
 
12
12
  /** What an app receives from the shell: where it is, what its window remembers, and the shell's verbs, so it never names an action. */
@@ -46,7 +46,9 @@ export interface DesktopProps {
46
46
  address?: boolean;
47
47
  /** Where the address begins: the baba's own, `/NAME`, unless given. */
48
48
  base?: string;
49
- /** Where this baba was opened from, voss's picker: a link before the title on the bar, and the launcher's last row, Leave. A page inside a baba goes nowhere else. */
49
+ /** The person's own settings, voss's, the same on every baba: the theme over the baba's, and the keys remapped. */
50
+ settings?: { theme?: Theme; keys?: KeyOverrides } | null;
51
+ /** Where this baba was opened from, voss's picker: the launcher's last row, Leave. A page inside a baba goes nowhere else. */
50
52
  root?: Root;
51
53
  }
52
54
  /** The way out of a baba: the picker's name and address. */
@@ -54,8 +56,10 @@ export interface Root { label: string; href: string }
54
56
  type Row<T> = T & { entity: Entity };
55
57
  /** The plus of a new session in the launcher. */
56
58
  const plusIcon: readonly IconShape[] = [{ tag: "path", attrs: { d: "M5 12h14" } }, { tag: "path", attrs: { d: "M12 5v14" } }];
57
- /** The way out, in the launcher: an arrow leaving a box. */
58
- const leaveIcon: readonly IconShape[] = [{ tag: "path", attrs: { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" } }, { tag: "polyline", attrs: { points: "16 17 21 12 16 7" } }, { tag: "line", attrs: { x1: "21", x2: "9", y1: "12", y2: "12" } }];
59
+ /** The way out, on the mark and in the launcher: an arrow leaving a box to the left, toward the bar's edge, where voss is; Lucide's log-out, mirrored. */
60
+ const leaveIcon: readonly IconShape[] = [{ tag: "path", attrs: { d: "M15 21h4a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-4" } }, { tag: "polyline", attrs: { points: "8 17 3 12 8 7" } }, { tag: "line", attrs: { x1: "3", x2: "15", y1: "12", y2: "12" } }];
61
+ /** The search's icon in the launcher: a magnifier. */
62
+ const searchIcon: readonly IconShape[] = [{ tag: "circle", attrs: { cx: "11", cy: "11", r: "8" } }, { tag: "path", attrs: { d: "m21 21-4.3-4.3" } }];
59
63
  /** A session's icon in the launcher: a window with its bar. */
60
64
  const sessionIcon: readonly IconShape[] = [{ tag: "rect", attrs: { x: "3", y: "5", width: "18", height: "14", rx: "2" } }, { tag: "path", attrs: { d: "M3 9.5h18" } }];
61
65
  /** In an old hash, the tab's own session rather than a named one. */
@@ -76,8 +80,13 @@ export interface Picture {
76
80
  sessions?: SessionRow[];
77
81
  /** The baba's theme; the session's old field when a picture has none. */
78
82
  theme?: Theme;
83
+ /** The keys the person remapped, voss's settings'; every other command has its default. */
84
+ keys?: KeyOverrides;
79
85
  }
80
86
 
87
+ /** How the launcher shows: as the mark's tab, a place with its own address, or quick, over the app in front, which Escape goes back to. */
88
+ export type LauncherMode = "tab" | "quick";
89
+
81
90
  /** The address of a window: the baba's base, the app, its path, which is left off at the app's home; the base alone is the launcher. */
82
91
  export function desktopAddress(base: string, app?: string, path = "/") {
83
92
  return app ? `${base}/${encodeURIComponent(app)}${path === "/" ? "" : path}` : `${base}/`;
@@ -116,7 +125,7 @@ function addressedSession(): string | undefined {
116
125
  }
117
126
 
118
127
  /** Only the desktop columns are subscribed here; a busy pond does not redraw the bar. */
119
- export function Desktop({ system, apps, surfaces, session: fixed, title = "Desktop", address = true, base: givenBase, root }: DesktopProps) {
128
+ export function Desktop({ system, apps, surfaces, session: fixed, title = "Desktop", address = true, base: givenBase, root, settings: person }: DesktopProps) {
120
129
  const renderers = apps ?? surfaces ?? {};
121
130
  const scope = useScope();
122
131
  const base = givenBase ?? `/${encodeURIComponent(scope)}`;
@@ -125,7 +134,11 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
125
134
  const iconRows = useColumn(selectors.icons, system.desktopIcon);
126
135
  const sessions = useColumn(selectors.sessions, system.desktopSession);
127
136
  const windows = useColumn(selectors.windows, system.desktopWindow);
128
- const theme = useResource(system.desktopSettings)?.theme ?? "system"; // undefined before the first frame
137
+ // The person's settings: voss's pages look and key alike on every baba. Until they arrive, the theme the page's head put on the root element
138
+ // from this browser's memory of them, else the system's; only a desktop inside another page wears the baba's own.
139
+ const babaTheme = useResource(system.desktopSettings)?.theme;
140
+ const remembered = address ? document.documentElement.dataset.theme as Theme | undefined : undefined;
141
+ const theme: Theme = person?.theme ?? (address ? remembered ?? "system" : babaTheme ?? "system");
129
142
  const { fire, ready, connected } = useBaba<DesktopSystem>();
130
143
  const [error, setError] = useState<string | null>(null);
131
144
  // The tab's session: the one fixed by the page, else the address's, else the one this tab had, else none yet, and the tab enters a new one.
@@ -151,6 +164,7 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
151
164
  return { entity, key: value.key, touched: value.touched ?? 0, windows: wins.length, front: front ? front.title || (front.path === "/" ? s?.title ?? null : front.path) : null };
152
165
  }).sort((a, b) => b.touched - a.touched || b.entity - a.entity),
153
166
  theme,
167
+ ...(person?.keys ? { keys: person.keys } : {}),
154
168
  } : null;
155
169
  const hand: Hand = { fire: (name, args) => {
156
170
  setError(null);
@@ -185,7 +199,12 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
185
199
  const pending = useRef<string | null>(null);
186
200
  const initialized = useRef(false);
187
201
  // Asked by the address for the launcher, or for an app, which closes it: counts, so every arrival asks again.
188
- const [launch, setLaunch] = useState(0);
202
+ // What the address names is known before the first frame: the launcher shows at once for the base alone, and holds back for an app on its way, so neither flashes the other.
203
+ const first = useMemo(() => address ? parseDesktopAddress(base, location.pathname) : null, []);
204
+ const [launch, setLaunch] = useState(first && !first.app ? 1 : 0);
205
+ // An app the address asked for, on its way: until it is in front the launcher holds back, so opening an app's address never flashes it.
206
+ const [arriving, setArriving] = useState(!!first?.app);
207
+ useEffect(() => { if (!arriving) return; const t = setTimeout(() => setArriving(false), 2000); return () => clearTimeout(t); }, [arriving]);
189
208
  const [dismiss, setDismiss] = useState(0);
190
209
  /** Goes to a session: another one, or a new one. The address is written first, to what is in front there, so the adapter agrees with the key rather than pulling the tab back; it replaces the entry, since the address names no session and Back could not return to this one. */
191
210
  const go = (next: string | null) => {
@@ -213,9 +232,11 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
213
232
  const expected = desktopAddress(base, route.app, path);
214
233
  if (expected === current.current) { if (location.pathname !== expected) history.replaceState(null, "", expected); return; }
215
234
  pending.current = expected;
235
+ setArriving(true);
216
236
  void fire("desktop-open", { session: key, app: route.app, path }).catch((e) => {
217
237
  if (!live) return;
218
238
  pending.current = null;
239
+ setArriving(false);
219
240
  setError(String(e));
220
241
  if (current.current) history.replaceState(null, "", current.current);
221
242
  });
@@ -228,9 +249,12 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
228
249
  }, [address, ready, key, !!picture, base, fire]);
229
250
  useEffect(() => {
230
251
  if (!address || !initialized.current || !here) return;
252
+ // Nothing in front yet while the address's app is on its way: the ask is not over, and the address stays as it is.
253
+ if (pending.current && here === desktopAddress(base)) return;
231
254
  // What the address asked for has come, or something else came first: either way the ask is over. Arriving where the address asked rewrites its spelling, no new entry.
232
255
  const asked = pending.current === here;
233
256
  pending.current = null;
257
+ setArriving(false);
234
258
  if (here === agreed.current) return;
235
259
  agreed.current = here;
236
260
  if (location.pathname !== here) {
@@ -238,25 +262,41 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
238
262
  else history.pushState(null, "", here);
239
263
  }
240
264
  }, [address, here, base]);
241
- /** The launcher closed with nothing new chosen: the address says again what is in front. A choice is left to the writer above, which pushes it, so Back returns to the launcher. */
242
- const launcherShown = (open: boolean) => {
243
- if (open || !address || !current.current || current.current !== agreed.current) return;
244
- if (location.pathname === `${base}/` && current.current !== `${base}/`) history.replaceState(null, "", current.current);
265
+ /**
266
+ * The launcher's tab is a place: chosen on the page, its address is pushed, with the address it was chosen over, so Back returns there.
267
+ * Left with nothing new chosen, the address goes back to what is in front: by Back when this page pushed the entry over that very address,
268
+ * else in place, since the entry before it may be another app's (Back from a later one came here). A choice is left to the writer above, which pushes it.
269
+ * The quick launcher, ⌘P's, is not a place: the address never hears of it.
270
+ */
271
+ const launcherShown = (mode: LauncherMode | null) => {
272
+ if (!address || !current.current) return;
273
+ const at = location.pathname === `${base}/`;
274
+ if (mode === "tab") { if (!at) history.pushState({ launcher: true, over: current.current }, "", `${base}/`); return; }
275
+ if (current.current !== agreed.current || !at || current.current === `${base}/`) return;
276
+ const entry = history.state as { launcher?: boolean; over?: string } | null;
277
+ if (entry?.launcher && entry.over === current.current) history.back(); else history.replaceState(null, "", current.current);
245
278
  };
246
279
 
247
280
  return <>
248
281
  {error && <div className="desktop-error" role="alert">{error}<button onClick={() => setError(null)}>Dismiss</button></div>}
249
282
  {!connected && ready && <p className="desktop-error" role="status">Reconnecting to the desktop…</p>}
250
- {picture ? <DesktopFrame picture={picture} title={title} hand={hand} root={root} go={fixed ? undefined : go} launch={launch} dismiss={dismiss} onLauncher={launcherShown} render={(app, props) => {
283
+ {picture ? <DesktopFrame picture={picture} title={title} hand={hand} root={root} documentTitle={address} launcherHref={address ? `${base}/` : undefined} go={fixed ? undefined : go} launch={launch} dismiss={dismiss} arriving={arriving} onLauncher={launcherShown} render={(app, props) => {
251
284
  const View = renderers[app.key];
252
285
  return View ? <View {...props} /> : <p className="desktop-empty">No component is composed for the app {app.title}.</p>;
253
- }} /> : <p className="desktop-empty" role="status">Opening desktop…</p>}
286
+ }} /> : <DesktopSkeleton title={title} />}
254
287
  </>;
255
288
  }
256
289
 
257
- /** The two eyes, the mark of Baba Voss, drawn in the current color. Without `large`, the larger eye is away, it is the launcher's dot then, and the smaller one moves to the middle. */
258
- export function Eyes({ size = 16, large = true, settle = false }: { size?: number; large?: boolean; settle?: boolean }) {
259
- return <svg className={`desktop-eyes${large ? "" : " is-alone"}${settle ? " is-settling" : ""}`} viewBox="0 0 100 100" width={size} height={size} aria-hidden="true" fill="currentColor"><circle className="desktop-eye-large" cx="33.772" cy="50" r="18" /><circle className="desktop-eye-small" cx="71.5" cy="50" r="12.728" /></svg>;
290
+ /** The desktop before its state arrives: the bar and the mark on the desk, as the desktop will draw them, so the page never flashes another look while it loads. */
291
+ export function DesktopSkeleton({ title }: { title?: string }) {
292
+ return <div className="desktop" aria-busy="true" aria-label="Opening the desktop">
293
+ <header className="desktop-bar"><span className="desktop-pill desktop-pill-mark"><span className="desktop-pill-label"><span className="desktop-pill-icon"><Eyes size={18} /></span>{title && <PillText text={title} />}</span></span></header>
294
+ </div>;
295
+ }
296
+
297
+ /** The two eyes, the mark of Baba Voss, drawn in the current color. Without `large`, the larger eye is away and the smaller one moves to the middle. */
298
+ export function Eyes({ size = 16, large = true }: { size?: number; large?: boolean }) {
299
+ return <svg className={`desktop-eyes${large ? "" : " is-alone"}`} viewBox="0 0 100 100" width={size} height={size} aria-hidden="true" fill="currentColor"><circle className="desktop-eye-large" cx="33.772" cy="50" r="18" /><circle className="desktop-eye-small" cx="71.5" cy="50" r="12.728" /></svg>;
260
300
  }
261
301
 
262
302
  /**
@@ -291,7 +331,7 @@ const middleCloses = (close: (() => void) | undefined) => close ? {
291
331
  onAuxClick: (e: { button: number; preventDefault(): void }) => { if (e.button === 1) { e.preventDefault(); close(); } },
292
332
  } : {};
293
333
 
294
- function WindowPill({ label, active, hand, icon, shortcut, enter, onSelect, onClose }: { label: string; active: boolean; hand?: Hand; icon: ReactNode; shortcut?: boolean; enter?: boolean; onSelect(): void; onClose(): void }) {
334
+ function WindowPill({ label, active, hand, icon, shortcut, enter, onSelect, onClose }: { label: string; active: boolean; hand?: Hand; icon: ReactNode; shortcut?: string; enter?: boolean; onSelect(): void; onClose(): void }) {
295
335
  const pill = useRef<HTMLSpanElement>(null);
296
336
  // Arriving, a pill drops into its place from above the strip, which clips it, and lands with a small bounce; it takes its place at once, so no other pill moves.
297
337
  useLayoutEffect(() => {
@@ -303,23 +343,32 @@ function WindowPill({ label, active, hand, icon, shortcut, enter, onSelect, onCl
303
343
  }, []);
304
344
  return <span ref={pill} className={`desktop-pill${active ? " is-active" : ""}`} role="tab" aria-selected={active} title={label} {...middleCloses(hand && onClose)}>
305
345
  <button type="button" className="desktop-pill-label" disabled={!hand} onClick={onSelect}><span className="desktop-pill-icon">{icon}</span><PillText text={label} /></button>
306
- {hand && <button type="button" className="desktop-pill-close" aria-label={`Close ${label}`} title={`Close ${label}${shortcut ? " (⌘/Ctrl W)" : ""}`} onClick={onClose}><Cross /></button>}
346
+ {hand && <button type="button" className="desktop-pill-close" aria-label={`Close ${label}`} title={`Close ${label}${shortcut ? ` (${shortcut})` : ""}`} onClick={onClose}><Cross /></button>}
307
347
  </span>;
308
348
  }
309
349
 
310
- /** The strip of pills: it scrolls sideways, and fades into whatever is behind it at an edge with more beyond it. */
311
- function Pills({ children, label }: { children: ReactNode; label: string }) {
312
- const ref = useRef<HTMLDivElement>(null);
350
+ /** The classes that fade a scrolling box into whatever is behind it at each edge with more beyond it: sideways, `is-faded-left` and `is-faded-right`; down, `is-faded-top` and `is-faded-bottom`. */
351
+ function useScrollFade<T extends HTMLElement>(axis: "x" | "y") {
352
+ const ref = useRef<T>(null);
313
353
  const [fade, setFade] = useState("");
314
354
  useEffect(() => {
315
355
  const el = ref.current;
316
356
  if (!el || typeof ResizeObserver === "undefined") return;
317
- const measure = () => setFade(`${el.scrollLeft > 1 ? " is-faded-left" : ""}${el.scrollLeft + el.clientWidth < el.scrollWidth - 1 ? " is-faded-right" : ""}`);
357
+ const measure = () => {
358
+ const [at, size, whole, start, end] = axis === "x" ? [el.scrollLeft, el.clientWidth, el.scrollWidth, "left", "right"] : [el.scrollTop, el.clientHeight, el.scrollHeight, "top", "bottom"];
359
+ setFade(`${at > 1 ? ` is-faded-${start}` : ""}${at + size < whole - 1 ? ` is-faded-${end}` : ""}`);
360
+ };
318
361
  measure();
319
362
  const ro = new ResizeObserver(measure); ro.observe(el); for (const child of el.children) ro.observe(child);
320
363
  el.addEventListener("scroll", measure, { passive: true });
321
364
  return () => { ro.disconnect(); el.removeEventListener("scroll", measure); };
322
365
  });
366
+ return { ref, fade };
367
+ }
368
+
369
+ /** The strip of pills: it scrolls sideways, and fades into whatever is behind it at an edge with more beyond it. */
370
+ function Pills({ children, label }: { children: ReactNode; label: string }) {
371
+ const { ref, fade } = useScrollFade<HTMLDivElement>("x");
323
372
  return <div ref={ref} className={`desktop-pills${fade}`} role="tablist" aria-label={label} onWheel={(e) => { const el = ref.current; if (el && !e.deltaX && e.deltaY && el.scrollWidth > el.clientWidth) { el.scrollLeft += e.deltaY; } }}>{children}</div>;
324
373
  }
325
374
 
@@ -350,46 +399,72 @@ function rank(texts: string[], words: string[]): number | null {
350
399
  }
351
400
 
352
401
  /** The same drawing is used live and for scenario frames. It never fetches state. */
353
- export function DesktopFrame({ picture, title, hand, render, root, go, launch, dismiss, onLauncher }: {
402
+ export function DesktopFrame({ picture, title, hand, render, root, documentTitle = false, launcherHref, go, launch, dismiss, arriving = false, onLauncher }: {
354
403
  picture: Picture; title: string; hand?: Hand; render(app: Row<DesktopApp>, props: AppProps): ReactNode;
355
- /** The picker this baba was opened from: a link on the bar, and Leave, the launcher's last row. */
404
+ /** The picker this baba was opened from: Leave, the launcher's last row. */
356
405
  root?: Root;
406
+ /** Names the page after the tab in front, the window's title then the desktop's, or the desktop alone on the launcher's; for a frame that is the whole page. */
407
+ documentTitle?: boolean;
408
+ /** The launcher's own address, which a ⌘/Ctrl or middle click on the mark opens in a new browser tab. */
409
+ launcherHref?: string;
357
410
  /** Takes the tab to another session by key, or to a new one with null; without it the launcher lists no sessions. */
358
411
  go?: (key: string | null) => void;
359
412
  /** Opens the launcher each time it changes: the page's address asked for it. */
360
413
  launch?: number;
414
+ /** An app is on its way to the front: with nothing in front meanwhile, the page shows an empty sheet, not the launcher. */
415
+ arriving?: boolean;
361
416
  /** Closes the launcher each time it changes: the page's address named an app. */
362
417
  dismiss?: number;
363
- /** Told whether the launcher shows, each time that changes after the first drawing. */
364
- onLauncher?: (open: boolean) => void;
418
+ /** Told how the launcher shows, or that it does not, each time that changes after the first drawing. */
419
+ onLauncher?: (mode: LauncherMode | null) => void;
365
420
  }) {
366
421
  const { session: w, apps, windows } = picture;
422
+ const keymap: Keymap = keymapOf(picture.keys);
423
+ const keyOf = (c: keyof Keymap) => { const b = keymap[c]; return b === null ? undefined : formatBinding(b); };
367
424
  const sessions = go ? picture.sessions ?? [] : [];
368
425
  const frame = useRef<HTMLDivElement>(null), input = useRef<HTMLInputElement>(null);
369
426
  const resultsId = useId();
370
427
  const fire = (name: string, args: Record<string, unknown>) => hand?.fire(name, { ...args, session: w.key });
371
428
  const open = (app: string, path?: string) => fire("desktop-open", { app, ...(path === undefined ? {} : { path }) });
372
- // The launcher is the page's: it shows while its search has focus, or while nothing is in front. The mark and ⌘K give the search focus; Escape and a click elsewhere take it.
373
- const [focused, setFocused] = useState(false);
429
+ // The launcher shows two ways. The mark chooses its tab, a place: it takes the app's place, the mark filled, its address the page's. ⌘P opens it quick, over
430
+ // the app in front, which stays its tab and shows dimmed behind; Escape goes back to it as it was. While nothing is in front the tab is all there is.
431
+ const [mode, setMode] = useState<LauncherMode | null>(() => launch ? "tab" : null);
374
432
  // A choice holds the launcher over the page until the state shows what was chosen, so the app behind never flashes between; a moment at most.
375
- const [awaiting, setAwaiting] = useState<{ from: Entity | null } | null>(null);
433
+ const [awaiting, setAwaiting] = useState<{ from: Entity | null; mode: LauncherMode } | null>(null);
376
434
  useEffect(() => { if (awaiting && w.selected !== awaiting.from) setAwaiting(null); }, [w.selected, awaiting]);
377
435
  useEffect(() => { if (!awaiting) return; const t = setTimeout(() => setAwaiting(null), 800); return () => clearTimeout(t); }, [awaiting]);
378
- const launcher = focused || w.selected === null || awaiting !== null;
379
- useEffect(() => { if (launch) setFocused(true); }, [launch]);
380
- useEffect(() => { if (dismiss) { setFocused(false); input.current?.blur(); } }, [dismiss]);
436
+ const shownMode: LauncherMode | null = w.selected === null ? (arriving && mode === null ? null : "tab") : mode ?? awaiting?.mode ?? null;
437
+ const launcher = shownMode !== null, quick = shownMode === "quick";
438
+ /** Where focus was when the quick launcher opened, so leaving it gives focus back. */
439
+ const before = useRef<HTMLElement | null>(null);
440
+ const openQuick = () => { before.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null; setMode("quick"); };
441
+ /** Leaves the launcher, whichever way it shows; leaving the quick one gives focus back to where it was. */
442
+ const leave = () => {
443
+ const back = quick ? before.current : null;
444
+ setMode(null); input.current?.blur(); before.current = null;
445
+ if (back?.isConnected) back.focus({ preventScroll: true });
446
+ };
447
+ useEffect(() => { if (launch) setMode("tab"); }, [launch]);
448
+ useEffect(() => { if (dismiss) { setMode(null); input.current?.blur(); } }, [dismiss]);
449
+ // The quick launcher is over the app in front: with that gone it has nothing to go back to, and ends, so a window arriving later is not covered.
450
+ useEffect(() => { if (w.selected === null) setMode((m) => (m === "quick" ? null : m)); }, [w.selected]);
381
451
  // Told only when it changes: as the frame first draws, the address has not been read yet.
382
452
  const told = useRef(onLauncher); told.current = onLauncher;
383
- const shown = useRef(launcher);
384
- useEffect(() => { if (shown.current !== launcher) { shown.current = launcher; told.current?.(launcher); } }, [launcher]);
385
- // Each time the launcher closes the mark's eyes settle back into place, so the mark is keyed by how many times it has closed.
386
- const [settles, setSettles] = useState(0);
387
- const [was, setWas] = useState(launcher);
388
- if (was !== launcher) { setWas(launcher); if (!launcher) setSettles(settles + 1); }
453
+ const shown = useRef(shownMode);
454
+ useEffect(() => { if (shown.current !== shownMode) { shown.current = shownMode; told.current?.(shownMode); } }, [shownMode]);
389
455
  // Pills that arrive after the first drawing grow into the strip; those already open when the page opens are simply there.
390
456
  const firstDrawn = useRef(false);
391
457
  useEffect(() => { firstDrawn.current = true; }, []);
392
- const toggle = () => { if (focused) { setFocused(false); input.current?.blur(); } else setFocused(true); };
458
+ /** The mark is a tab that is never closed: choosing it shows the launcher's tab, and choosing it again only gives the search focus. With ⌘/Ctrl, or the middle button, it opens the launcher in a new browser tab. */
459
+ const chooseMark = (e: { metaKey: boolean; ctrlKey: boolean; button: number }) => {
460
+ if (launcherHref && (e.metaKey || e.ctrlKey || e.button === 1)) { window.open(launcherHref, "_blank"); return; }
461
+ setMode("tab"); input.current?.focus();
462
+ };
463
+ /** A pill chosen while the launcher shows leaves it for that window. */
464
+ const selectWindow = (o: Entity) => {
465
+ if (o !== w.selected) { fire("desktop-select", { window: o }); if (launcher) setAwaiting({ from: w.selected, mode: shownMode! }); }
466
+ leave();
467
+ };
393
468
  const selectedWindow = windows.find((o) => o.entity === w.selected);
394
469
  const selectedApp = apps.find((s) => s.entity === selectedWindow?.app);
395
470
  // What is typed is the page's: it clears when the launcher closes, and never waits on the state.
@@ -398,78 +473,55 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
398
473
  const search = (next: string, cursor = 0) => { setText(next); setAt(cursor); };
399
474
  useEffect(() => { if (!launcher) search(""); }, [launcher]);
400
475
  const label = (o: Row<DesktopWindow>, s: Row<DesktopApp>) => o.title || (o.path === "/" ? s.title : o.path);
401
- // The palette: what is open first, then what can be opened; both answer the words typed, best first, and the keyboard runs down the whole list.
476
+ const tabTitle = (launcher && !quick) || !selectedWindow || !selectedApp ? null : label(selectedWindow, selectedApp);
477
+ useEffect(() => { if (documentTitle) document.title = tabTitle ? `${tabTitle} · ${title}` : title; }, [documentTitle, tabTitle, title]);
478
+ // The palette: what can be opened, answering the words typed, best first, and the keyboard runs down the whole list. What is open is on the bar, and ⌘J and ⌘K walk it.
402
479
  const words = text.toLowerCase().trim().split(/\s+/).filter(Boolean);
403
480
  const ranked = <T,>(items: T[], texts: (item: T) => string[]) => items.map((item, order) => ({ item, order, rank: rank(texts(item), words) })).filter((r): r is { item: T; order: number; rank: number } => r.rank !== null).sort((a, b) => a.rank - b.rank || a.order - b.order).map((r) => r.item);
404
- const openRows = ranked(windows.flatMap((o) => { const s = apps.find((s) => s.entity === o.app); return s ? [{ o, s }] : []; }), ({ o, s }) => [label(o, s), s.title, s.group]);
405
481
  const found = ranked(apps, (s) => [s.title, s.key, s.group]);
406
- // What is open first, then what can be opened; the other sessions after, and a new one; last, the way out of the baba.
482
+ // The apps first; the other sessions after, and a new one; last, the way out of the baba.
407
483
  const sessionRows = ranked(sessions, (x) => [x.key, x.front ?? ""]);
408
484
  const newSession = go && rank(["new session"], words) !== null;
409
485
  const leaving = root && rank([`leave ${title}`, root.label], words) !== null;
410
- const S0 = openRows.length + found.length;
486
+ const S0 = found.length;
411
487
  const L = S0 + sessionRows.length + (newSession ? 1 : 0);
412
488
  const count = L + (leaving ? 1 : 0);
413
489
  const cursor = Math.min(at, Math.max(0, count - 1));
414
490
  const choose = (i: number) => {
415
491
  if (i >= L) { location.assign(root!.href); return; }
416
- if (i >= S0) { const x = sessionRows[i - S0]; go?.(x ? x.key : null); setFocused(false); input.current?.blur(); return; }
417
- const row = openRows[i], s = row ? undefined : found[i - openRows.length];
418
- // What will be in front: the window chosen, or the app's own one where it allows only one; a fresh window is not known until it is made.
419
- const target = row ? row.o.entity : s && !s.many ? windows.filter((o) => o.app === s.entity).at(-1)?.entity : undefined;
420
- if (row) fire("desktop-select", { window: row.o.entity }); else if (s) fire("desktop-open", { app: s.key, fresh: true });
421
- if ((row || s) && target !== w.selected) setAwaiting({ from: w.selected });
422
- setFocused(false); input.current?.blur();
492
+ if (i >= S0) { const x = sessionRows[i - S0]; go?.(x ? x.key : null); setMode(null); input.current?.blur(); return; }
493
+ const s = found[i];
494
+ // What will be in front: the app's own window where it allows only one; a fresh window is not known until it is made.
495
+ const target = s && !s.many ? windows.filter((o) => o.app === s.entity).at(-1)?.entity : undefined;
496
+ if (s) fire("desktop-open", { app: s.key, fresh: true });
497
+ if (s && target !== w.selected) setAwaiting({ from: w.selected, mode: shownMode ?? "tab" });
498
+ setMode(null); input.current?.blur();
423
499
  };
424
- useEffect(() => { if (focused && hand) input.current?.focus(); }, [focused, !!hand]);
500
+ useEffect(() => { if (mode && hand) input.current?.focus(); }, [mode, !!hand]);
425
501
  useEffect(() => { frame.current?.querySelector(`[data-result="${cursor}"]`)?.scrollIntoView({ block: "nearest" }); }, [cursor]);
426
- // One dot says which row is chosen: the mark's larger eye, which drops out of the mark on a string when the launcher opens and is pulled from row to row. The bob moves it; this only says where.
427
- const dot = useRef<HTMLSpanElement>(null), string = useRef<HTMLSpanElement>(null), overlay = useRef<HTMLDivElement>(null);
428
- /** Where the string is tied, the middle of the mark's lower edge, and the dot's radius, measured when the launcher opens. */
429
- const tie = useRef({ x: 0, y: 0, r: 0 });
430
- const bob = useRef<Bob | null>(null);
431
- bob.current ??= new Bob((at) => {
432
- const el = dot.current, line = string.current, t = tie.current;
433
- if (!el || !line) return;
434
- el.style.transform = `translate(${at.x}px, ${at.y}px)`;
435
- // The string runs from the tie to the dot's edge, at whatever angle the dot hangs.
436
- const dx = at.x - t.x, dy = at.y - t.y, reach = Math.hypot(dx, dy), length = Math.max(0, reach - t.r);
437
- line.style.opacity = length > 1 ? "1" : "0";
438
- line.style.transform = `translate(${t.x}px, ${t.y}px) rotate(${reach ? -Math.asin(dx / reach) : 0}rad) scaleY(${length})`;
439
- }, () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
440
- useEffect(() => () => bob.current?.stop(), []);
441
- /** Aims the dot at the chosen row's middle, on the mark's vertical; a scroll moves it with the list at once. */
442
- const place = (now = false) => {
443
- const o = overlay.current, el = dot.current;
444
- const row = o?.querySelector<HTMLElement>(`[data-result="${cursor}"]`)?.closest<HTMLElement>(".desktop-row");
445
- if (!el) return;
446
- el.style.opacity = row ? "1" : "0";
447
- if (!row) { if (string.current) string.current.style.opacity = "0"; return; }
448
- // Layout offsets, not rects: a row still arriving is transformed, and the dot aims at where it will rest.
449
- const body = row.closest<HTMLElement>(".desktop-launcher-body");
450
- bob.current!.to({ x: tie.current.x, y: row.offsetTop - (body?.scrollTop ?? 0) + row.offsetHeight / 2 }, now);
451
- };
452
- useLayoutEffect(() => {
453
- if (!launcher) { bob.current!.stop(); return; }
454
- const o = overlay.current, mark = o?.querySelector<HTMLElement>(".desktop-logo"), eye = o?.querySelector<SVGCircleElement>(".desktop-eye-large"), el = dot.current;
455
- if (!o || !mark || !eye || !el) return;
456
- const O = o.getBoundingClientRect(), M = mark.getBoundingClientRect(), E = eye.getBoundingClientRect();
457
- tie.current = { x: M.left + M.width / 2 - O.left, y: M.bottom - O.top, r: E.width / 2 };
458
- // Born where the larger eye is, at the eye's own size, which it keeps; then it falls.
459
- el.style.width = el.style.height = `${E.width}px`; el.style.margin = `${-E.width / 2}px 0 0 ${-E.width / 2}px`;
460
- bob.current!.drop({ x: E.left + E.width / 2 - O.left, y: E.top + E.height / 2 - O.top });
461
- }, [launcher]);
462
- useEffect(() => { if (launcher) place(); });
502
+ // The rows fade into the palette's background at an edge with more beyond it, as the bar's pills do sideways.
503
+ const { ref: body, fade: bodyFade } = useScrollFade<HTMLDivElement>("y");
463
504
  useEffect(() => {
464
505
  if (!hand) return;
465
506
  const key = (e: KeyboardEvent) => {
466
507
  // An embedded picture only handles keys while it contains focus.
467
508
  if (document.activeElement !== document.body && !frame.current?.contains(document.activeElement)) return;
468
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k" && !e.altKey) { e.preventDefault(); if (!focused) setFocused(true); else if (w.selected !== null) toggle(); }
469
- else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "w" && !e.altKey && !launcher && w.selected !== null) { e.preventDefault(); fire("desktop-close", {}); }
509
+ // The commands the person's keymap runs; a press something else already took is left to it.
510
+ const command = e.defaultPrevented ? null : commandOf(keymap, e);
511
+ // The quick launcher opens, and closes again; over the launcher's tab it only gives the search focus.
512
+ if (command === "launcher") { e.preventDefault(); if (!launcher) openQuick(); else if (quick) leave(); else input.current?.focus(); }
513
+ // The tabs on the bar, walked as they sit, the mark's tab among them, round at the ends.
514
+ else if ((command === "previousTab" || command === "nextTab") && windows.length) {
515
+ e.preventDefault();
516
+ const tabs: (Entity | null)[] = [null, ...windows.map((o) => o.entity)];
517
+ const here = launcher && !quick ? 0 : tabs.indexOf(w.selected);
518
+ const next = tabs[(here + (command === "nextTab" ? 1 : -1) + tabs.length) % tabs.length]!;
519
+ if (next === null) { setMode("tab"); input.current?.focus(); } else selectWindow(next);
520
+ }
521
+ else if (command === "closeTab" && !launcher && w.selected !== null) { e.preventDefault(); fire("desktop-close", {}); }
470
522
  else if (launcher && e.key === "Escape") {
471
523
  e.preventDefault();
472
- if (text) search(""); else if (w.selected !== null) toggle();
524
+ if (text) search(""); else if (w.selected !== null) leave();
473
525
  }
474
526
  };
475
527
  addEventListener("keydown", key); return () => removeEventListener("keydown", key);
@@ -480,15 +532,20 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
480
532
  const pills = windows.map((o) => {
481
533
  const s = apps.find((s) => s.entity === o.app);
482
534
  if (!s) return null;
483
- const active = w.selected === o.entity;
484
- return <WindowPill key={o.entity} label={label(o, s)} active={active} hand={hand} shortcut={active} icon={<AppIcon drawing={s.drawing} />} enter={firstDrawn.current} onSelect={() => fire("desktop-select", { window: o.entity })} onClose={() => fire("desktop-close", { window: o.entity })} />;
535
+ const active = (!launcher || quick) && w.selected === o.entity;
536
+ return <WindowPill key={o.entity} label={label(o, s)} active={active} hand={hand} shortcut={active ? keyOf("closeTab") : undefined} icon={<AppIcon drawing={s.drawing} />} enter={firstDrawn.current} onSelect={() => selectWindow(o.entity)} onClose={() => fire("desktop-close", { window: o.entity })} />;
485
537
  });
486
- // The bar: the mark, which is the launcher and so the way to another app; a pill per open window, the one in front filled.
487
- // The launcher covers the page: the bar's row becomes the search, the pills sit on the row below exactly as they are, and the apps, as pills under their groups, fill the rest.
538
+ // The bar holds tabs only: the mark, the launcher's tab, which is never closed; a pill per open window; the tab in front filled.
539
+ // The launcher's tab takes the content's place under the bar, a palette in its middle: the search, then what is open and what can be. The quick one is the same palette over the app.
488
540
  return <div ref={frame} className="desktop" data-theme={picture.theme ?? w.theme}>
489
541
  <header className="desktop-bar" aria-label="Bar">
490
- <button type="button" className="desktop-logo" title={`${title}: launcher (⌘/Ctrl K)`} aria-label="Open launcher" aria-expanded={launcher} disabled={!hand} onClick={toggle}><Eyes key={settles} size={18} settle={settles > 0} /></button>
491
- {windows.length === 0 && <span className="desktop-bar-title">{root && <><a className="desktop-bar-root" href={root.href} title={`Leave ${title}: back to ${root.label}`}>{root.label}</a><span className="desktop-muted"> / </span></>}{title}{go || w.key !== "main" ? <span className="desktop-muted"> · {w.key}</span> : ""}</span>}
542
+ {/* The mark is the launcher's tab, named after the baba, a pill as every tab is; pointing at it turns its eyes into the way out, which leaves the baba for voss's picker, the session kept to come back to. */}
543
+ <span className={`desktop-pill desktop-pill-mark${launcher && !quick ? " is-active" : ""}`}>
544
+ <button type="button" className="desktop-pill-label" title={`${title}: launcher`} aria-label="Launcher" aria-pressed={launcher && !quick} disabled={!hand} onClick={chooseMark} onMouseDown={(e) => { if (e.button === 1) e.preventDefault(); }} onAuxClick={(e) => { if (e.button === 1) chooseMark(e); }}>
545
+ <span className="desktop-pill-icon"><Eyes size={18} /></span><PillText text={title} />
546
+ </button>
547
+ {hand && root && <button type="button" className="desktop-pill-close" aria-label={`Leave ${title}`} title={`Leave ${title}: back to ${root.label}`} onClick={() => location.assign(root.href)}><AppIcon drawing={leaveIcon} /></button>}
548
+ </span>
492
549
  <Pills label="Open apps">{pills}</Pills>
493
550
  </header>
494
551
  <main className="desktop-content">
@@ -496,7 +553,7 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
496
553
  const s = apps.find((s) => s.entity === o.app);
497
554
  if (!s) return null;
498
555
  const active = w.selected === o.entity;
499
- return <section key={o.entity} className="desktop-app" hidden={!active} aria-label={label(o, s)}>
556
+ return <section key={o.entity} className={`desktop-app${active && launcher && !quick ? " is-behind" : ""}`} hidden={!active} aria-label={label(o, s)}>
500
557
  <AppBoundary><>{render(s, {
501
558
  session: w.key, window: o.entity, path: o.path, ...routeOf(s.routes, o.path), active, memory: (o.memory ?? {}) as Record<string, unknown>,
502
559
  navigate: (path) => fire("desktop-open", { app: s.key, path }),
@@ -507,40 +564,26 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
507
564
  </section>;
508
565
  })}
509
566
  </main>
510
- {launcher && <div ref={overlay} className="desktop-launcher" aria-label="Launcher" onPointerDown={(e) => {
511
- // Pressing anywhere in the launcher keeps the search's focus, empty space included; choosing a row, the mark, Escape or ⌘K are what close it.
567
+ {launcher && <div className={`desktop-launcher${quick ? " is-quick" : ""}`} aria-label="Launcher" onPointerDown={(e) => {
568
+ // Pressing anywhere in the launcher keeps the search's focus, empty space included; choosing a row, a pill, Escape or ⌘P are what leave it. Around the quick one's palette, a press goes back.
512
569
  if (!(e.target as HTMLElement).closest("input")) e.preventDefault();
570
+ if (quick && !(e.target as HTMLElement).closest(".desktop-palette")) leave();
513
571
  }}>
514
- <div className="desktop-bar desktop-launcher-search">
515
- <button type="button" className="desktop-logo is-selected" title="Close launcher (Esc)" aria-label="Close launcher" aria-expanded="true" disabled={!hand} onClick={toggle}><Eyes size={18} large={false} /></button>
516
- <input ref={input} className="desktop-search" autoFocus={focused} aria-label="Search" placeholder="Type an app" value={text} disabled={!hand} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} onChange={(e) => search(e.target.value)} role="combobox" aria-expanded="true" aria-controls={resultsId} aria-activedescendant={count ? `${resultsId}-${cursor}` : undefined} onKeyDown={(e) => {
572
+ <div className="desktop-palette">
573
+ <div className="desktop-launcher-search">
574
+ <span className="desktop-pill-icon"><AppIcon drawing={searchIcon} /></span>
575
+ <input ref={input} className="desktop-search" autoFocus={mode !== null} aria-label="Search" placeholder="Type an app" value={text} disabled={!hand} onChange={(e) => search(e.target.value)} role="combobox" aria-expanded="true" aria-controls={resultsId} aria-activedescendant={count ? `${resultsId}-${cursor}` : undefined} onKeyDown={(e) => {
517
576
  if (["ArrowDown", "ArrowUp"].includes(e.key) && count) { e.preventDefault(); setAt((cursor + (e.key === "ArrowDown" ? 1 : -1) + count) % count); }
518
577
  else if (e.key === "Enter" && count) { e.preventDefault(); choose(cursor); }
519
578
  }} />
520
579
  </div>
521
- <span ref={string} className="desktop-string" aria-hidden="true" />
522
- <span ref={dot} className="desktop-dot" aria-hidden="true" />
523
- <div id={resultsId} className="desktop-launcher-body" role="listbox" aria-label="Open windows and apps" onScroll={() => place(true)}>
524
- {openRows.length > 0 && <div className="desktop-group">
525
- <h2 className="desktop-group-title" style={{ "--i": 0 } as CSSProperties}>Open</h2>
526
- {openRows.map(({ o, s }, k) => {
527
- const i = k, front = w.selected === o.entity;
528
- return <span key={o.entity} className={`desktop-row desktop-row-window${cursor === i ? " is-cursor" : ""}${front ? " is-active" : ""}`} style={{ "--i": i + 1 } as CSSProperties} {...middleCloses(hand && (() => fire("desktop-close", { window: o.entity })))}>
529
- <button type="button" id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className="desktop-row-main" disabled={!hand} title={label(o, s)} onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
530
- <span className="desktop-pill-icon"><AppIcon drawing={s.drawing} /></span>
531
- <span className="desktop-row-label">{label(o, s)}</span>
532
- {label(o, s) !== s.title && <span className="desktop-row-note">{s.title}</span>}
533
- </button>
534
- {hand && <button type="button" className="desktop-pill-close" aria-label={`Close ${label(o, s)}`} title={`Close ${label(o, s)}`} onClick={() => fire("desktop-close", { window: o.entity })}><Cross /></button>}
535
- </span>;
536
- })}
537
- </div>}
580
+ <div ref={body} id={resultsId} className={`desktop-launcher-body${bodyFade}`} role="listbox" aria-label="Apps">
538
581
  {groups.map((group) => <div key={group} className="desktop-group">
539
- {group && <h2 className="desktop-group-title" style={{ "--i": openRows.length + found.indexOf(appsOf(group)[0]!) + (openRows.length ? 1 : 0) } as CSSProperties}>{group}</h2>}
582
+ {group && <h2 className="desktop-group-title">{group}</h2>}
540
583
  {appsOf(group).map((s) => {
541
- const i = openRows.length + found.indexOf(s);
584
+ const i = found.indexOf(s);
542
585
  const open = windows.filter((o) => o.app === s.entity).length;
543
- return <button type="button" key={s.key} id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className={`desktop-row desktop-row-app${cursor === i ? " is-cursor" : ""}`} style={{ "--i": i + (openRows.length ? 2 : 1) } as CSSProperties} disabled={!hand} title={s.many ? `Open another ${s.title}` : open ? `Bring ${s.title} to the front` : `Open ${s.title}`} onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
586
+ return <button type="button" key={s.key} id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className={`desktop-row desktop-row-app${cursor === i ? " is-cursor" : ""}`} disabled={!hand} title={s.many ? `Open another ${s.title}` : open ? `Bring ${s.title} to the front` : `Open ${s.title}`} onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
544
587
  <span className="desktop-pill-icon"><AppIcon drawing={s.drawing} /></span>
545
588
  <span className="desktop-row-label">{s.title}</span>
546
589
  <span className="desktop-row-note">{words.length && s.group ? s.group : ""}{open > 1 ? `${words.length && s.group ? " · " : ""}${open} open` : ""}</span>
@@ -548,10 +591,10 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
548
591
  })}
549
592
  </div>)}
550
593
  {(sessionRows.length > 0 || newSession) && <div className="desktop-group desktop-sessions">
551
- <h2 className="desktop-group-title" style={{ "--i": S0 + (openRows.length ? 1 : 0) + (found.length ? 1 : 0) } as CSSProperties}>Sessions</h2>
594
+ <h2 className="desktop-group-title">Sessions</h2>
552
595
  {sessionRows.map((x, k) => {
553
596
  const i = S0 + k, note = `${x.windows ? `${x.windows} open` : "empty"}${x.front ? ` · ${x.front}` : ""}`;
554
- return <span key={x.entity} className={`desktop-row desktop-row-window desktop-row-session${cursor === i ? " is-cursor" : ""}`} style={{ "--i": i + 1 + (openRows.length ? 1 : 0) + (found.length ? 1 : 0) } as CSSProperties} {...middleCloses(hand && (() => hand.fire("desktop-leave", { session: x.key })))}>
597
+ return <span key={x.entity} className={`desktop-row desktop-row-window desktop-row-session${cursor === i ? " is-cursor" : ""}`} {...middleCloses(hand && (() => hand.fire("desktop-leave", { session: x.key })))}>
555
598
  <button type="button" id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className="desktop-row-main" disabled={!hand} title={`Go to the session ${x.key}`} onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
556
599
  <span className="desktop-pill-icon"><AppIcon drawing={sessionIcon} /></span>
557
600
  <span className="desktop-row-label">{x.key}</span>
@@ -560,14 +603,14 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
560
603
  {hand && <button type="button" className="desktop-pill-close" aria-label={`End the session ${x.key}`} title={`End the session ${x.key}`} onClick={() => hand.fire("desktop-leave", { session: x.key })}><Cross /></button>}
561
604
  </span>;
562
605
  })}
563
- {newSession && (() => { const i = S0 + sessionRows.length; return <button type="button" id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className={`desktop-row desktop-row-app${cursor === i ? " is-cursor" : ""}`} style={{ "--i": i + 1 + (openRows.length ? 1 : 0) + (found.length ? 1 : 0) } as CSSProperties} disabled={!hand} title="Open a new session in this tab" onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
606
+ {newSession && (() => { const i = S0 + sessionRows.length; return <button type="button" id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className={`desktop-row desktop-row-app${cursor === i ? " is-cursor" : ""}`} disabled={!hand} title="Open a new session in this tab" onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
564
607
  <span className="desktop-pill-icon"><AppIcon drawing={plusIcon} /></span>
565
608
  <span className="desktop-row-label">New session</span>
566
609
  <span className="desktop-row-note">this tab is on {w.key}</span>
567
610
  </button>; })()}
568
611
  </div>}
569
612
  {leaving && <div className="desktop-group desktop-leave">
570
- <button type="button" id={`${resultsId}-${L}`} data-result={L} role="option" aria-selected={cursor === L} className={`desktop-row desktop-row-app${cursor === L ? " is-cursor" : ""}`} style={{ "--i": L + 2 + (openRows.length ? 1 : 0) + (found.length ? 1 : 0) } as CSSProperties} title={`Leave ${title}: back to ${root!.label}`} onMouseEnter={() => setAt(L)} onClick={() => choose(L)}>
613
+ <button type="button" id={`${resultsId}-${L}`} data-result={L} role="option" aria-selected={cursor === L} className={`desktop-row desktop-row-app${cursor === L ? " is-cursor" : ""}`} title={`Leave ${title}: back to ${root!.label}`} onMouseEnter={() => setAt(L)} onClick={() => choose(L)}>
571
614
  <span className="desktop-pill-icon"><AppIcon drawing={leaveIcon} /></span>
572
615
  <span className="desktop-row-label">Leave {title}</span>
573
616
  <span className="desktop-row-note">{root!.label}</span>
@@ -578,10 +621,11 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
578
621
  <footer className="desktop-launcher-legend" aria-label="Shortcuts">
579
622
  <span><kbd>↑</kbd><kbd>↓</kbd> move</span>
580
623
  <span><kbd>↵</kbd> open</span>
581
- <span><kbd>esc</kbd> {text ? "clear" : "close"}</span>
582
- <span><kbd>⌘K</kbd> launcher</span>
583
- <span><kbd>⌘W</kbd> close window</span>
624
+ {(text || w.selected !== null) && <span><kbd>esc</kbd> {text ? "clear" : "back"}</span>}
625
+ {quick && keyOf("launcher") && <span><kbd>{keyOf("launcher")}</kbd> close</span>}
626
+ {windows.length > 0 && keyOf("previousTab") && keyOf("nextTab") && <span><kbd>{keyOf("previousTab")}</kbd><kbd>{keyOf("nextTab")}</kbd> tabs</span>}
584
627
  </footer>
628
+ </div>
585
629
  </div>}
586
630
  </div>;
587
631
  }
package/src/door/core.ts CHANGED
@@ -73,7 +73,7 @@ export interface Index {
73
73
  error?: string;
74
74
  systems: { name: string }[];
75
75
  /** What the baba tells agents: whether it has a context, and its skills by name. */
76
- promptware: { context: boolean; skills: string[] };
76
+ instructions: { context: boolean; skills: string[] };
77
77
  calls: IndexCall[];
78
78
  components: Record<string, { system: string; key?: string; accept?: true }>;
79
79
  resources: string[];
@@ -115,7 +115,7 @@ export function index(m: Manifest, extra: IndexExtra = {}): Index {
115
115
  ...(extra.gen !== undefined ? { gen: extra.gen } : {}),
116
116
  ...(extra.error ? { error: extra.error } : {}),
117
117
  systems: m.systems.map((c) => ({ name: c.name })),
118
- promptware: { context: !!m.promptware.context, skills: Object.keys(m.promptware.skills) },
118
+ instructions: { context: !!m.instructions.context, skills: Object.keys(m.instructions.skills) },
119
119
  calls: entries(m).map(({ name, kind, c }) => ({ name, kind, system: c.owner, summary: c.summary, example: exampleCall(name, c, extra.prog) })),
120
120
  components: Object.fromEntries(Object.entries(m.components).map(([n, c]) => {
121
121
  const key = keyField(c.schema);