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
@@ -12,9 +12,14 @@ const harness = join(framework, "src/runtime/worker.ts");
12
12
  const ENTRY = "/babavoss/entry.ts";
13
13
 
14
14
  export async function bundleProject(project: Project): Promise<string> {
15
+ // The agentic instructions: every .mdx under .baba/agents/, in name order, each a document the baba is given.
16
+ const docs = [...new Bun.Glob("agents/*.mdx").scanSync({ cwd: project.baba })].sort().map((f) => join(project.baba, f));
15
17
  const text = [
16
- `import v from ${JSON.stringify(project.entry)};`,
18
+ `import entry from ${JSON.stringify(project.entry)};`,
19
+ `import { instructed } from "babavoss";`,
17
20
  `import { serve } from ${JSON.stringify(harness)};`,
21
+ ...docs.map((f, i) => `import doc${i} from ${JSON.stringify(f)};`),
22
+ `const v = instructed(entry, [${docs.map((_, i) => `doc${i}`).join(", ")}]);`,
18
23
  `export default v;`,
19
24
  `serve(v);`,
20
25
  ].join("\n") + "\n";
@@ -27,9 +27,9 @@ export const names: Record<string, string> = {
27
27
  "babavoss/web": join(framework, "src/web/index.tsx"),
28
28
  "babavoss/test": join(framework, "src/test/index.ts"),
29
29
  "babavoss/bench": join(framework, "src/bench/index.ts"),
30
- "babavoss/prompt": join(framework, "src/prompt/index.ts"),
31
- "babavoss/prompt/jsx-runtime": join(framework, "src/prompt/jsx-runtime.ts"),
32
- "babavoss/prompt/jsx-dev-runtime": join(framework, "src/prompt/jsx-dev-runtime.ts"),
30
+ "babavoss/instructions": join(framework, "src/instructions/index.ts"),
31
+ "babavoss/instructions/jsx-runtime": join(framework, "src/instructions/jsx-runtime.ts"),
32
+ "babavoss/instructions/jsx-dev-runtime": join(framework, "src/instructions/jsx-dev-runtime.ts"),
33
33
  };
34
34
 
35
35
  /**
@@ -5,34 +5,24 @@
5
5
  .desktop { position: relative; display: flex; flex-direction: column; height: 100dvh; min-height: 320px; overflow: hidden; color: var(--foreground); background: var(--desk); font: 13px/20px var(--font-sans, system-ui);
6
6
  /* Everything that sits on the desk is ink over it, translucent, never a surface of its own. */
7
7
  --desktop-ink: color-mix(in oklch, var(--foreground) 6%, transparent); --desktop-ink-strong: color-mix(in oklch, var(--foreground) 11%, transparent); --desktop-ink-top: color-mix(in oklch, var(--foreground) 20%, transparent); }
8
- .desktop button, .desktop input, .desktop select { font: inherit; color: inherit; }
8
+ /* Of no weight, so a control's own class, a primary button's colours among them, always wins over it. */
9
+ :where(.desktop) :where(button, input, select) { font: inherit; color: inherit; }
9
10
  .desktop button { cursor: pointer; }
10
11
  .desktop button:disabled { cursor: default; }
11
12
  .desktop button:focus-visible, .desktop select:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
12
13
 
13
- /* The bar sits on the desk itself: the mark at the far left, always; then a pill per open window, or the desktop's name while nothing is open. */
14
+ /* The bar sits on the desk itself and holds tabs only: the mark at the far left, always, then a pill per open window. */
14
15
  .desktop-bar { position: relative; z-index: 3; flex-shrink: 0; display: flex; align-items: stretch; height: 40px; padding: 6px 8px; gap: 4px; user-select: none; }
15
- .desktop-logo { display: grid; place-items: center; width: 28px; padding: 0; border: 0; border-radius: 999px; background: transparent; color: var(--foreground); }
16
- .desktop-logo:hover { background: var(--desktop-ink); }
17
- .desktop-logo.is-selected { background: var(--desktop-ink-top); }
18
- .desktop-logo.is-selected { color: var(--primary); }
16
+ /* The mark is the launcher's tab, a pill with the baba's name, filled while the launcher shows; it never closes, its way out leaves the baba. */
17
+ .desktop-pill-mark { color: var(--foreground); }
18
+ /* With no way out, an embedded desktop's, its eyes stay where they are. */
19
+ .desktop-pill-mark:not(:has(.desktop-pill-close)) .desktop-pill-label > .desktop-pill-icon { opacity: 1; transform: none; }
19
20
  .desktop-eyes { display: block; }
20
21
  /* With the larger eye away, the smaller one moves to the middle; in user units, 50 - 71.5. */
21
22
  .desktop-eye-large { transition: opacity .12s; }
22
23
  .desktop-eye-small { transition: transform .22s cubic-bezier(.3, 1.2, .3, 1); }
23
24
  .desktop-eyes.is-alone .desktop-eye-large { opacity: 0; }
24
25
  .desktop-eyes.is-alone .desktop-eye-small { transform: translateX(-21.5px); }
25
- /* When the launcher closes the eyes settle back, with a small overshoot: the smaller one from the middle to its place, the larger one back from where it left as the dot. */
26
- .desktop-eyes circle { transform-box: fill-box; transform-origin: center; }
27
- .desktop-eyes.is-settling .desktop-eye-small { animation: desktop-eye-home .34s cubic-bezier(.3, 1.6, .4, 1); }
28
- .desktop-eyes.is-settling .desktop-eye-large { animation: desktop-eye-back .34s cubic-bezier(.3, 1.6, .4, 1); }
29
- @keyframes desktop-eye-home { from { transform: translateX(-21.5px); } }
30
- @keyframes desktop-eye-back { from { transform: scale(0); } }
31
- @media (prefers-reduced-motion: reduce) { .desktop-eyes.is-settling circle { animation: none; } }
32
- .desktop-bar-title { display: flex; align-items: center; padding: 0 8px; color: var(--muted-foreground); white-space: pre; }
33
- .desktop-bar-root { color: var(--muted-foreground); text-decoration: none; border-radius: 4px; padding: 0 2px; margin: 0 -2px; }
34
- .desktop-bar-root:hover, .desktop-bar-root:focus-visible { color: var(--foreground); background: var(--muted); outline: none; }
35
- .desktop-bar-gap { flex: 1; }
36
26
  /* One pill per open window, the one in front filled; pointing at a pill shows its × over its right end. */
37
27
  .desktop-pills { display: flex; align-items: stretch; gap: 4px; min-width: 0; overflow-x: auto; scrollbar-width: none; scroll-behavior: smooth; }
38
28
  .desktop-pills::-webkit-scrollbar { display: none; }
@@ -67,42 +57,48 @@
67
57
  @keyframes desktop-marquee-start { to { transform: translateX(var(--ease)); } }
68
58
  @keyframes desktop-marquee-fade { to { --desktop-fade-left: 12px; } }
69
59
  @media (prefers-reduced-motion: reduce) { .desktop-pill:hover .desktop-pill-text.is-overflowing, .desktop-pill:hover .desktop-pill-text.is-overflowing .desktop-pill-track, .desktop-pill:hover .desktop-pill-text.is-overflowing .desktop-pill-track > span { animation: none; } }
70
- .desktop-content { flex: 1; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
60
+ .desktop-content { flex: 1; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; isolation: isolate; }
61
+ /* Isolated, so nothing an app stacks rises over the launcher. */
71
62
  /* An app's window is a sheet under the bar, a line along its top; it scrolls, and the app brings its own gutters. */
72
63
  .desktop-app { flex: 1; min-height: 0; overflow: auto; background: var(--background); border-top: 1px solid var(--border); }
73
64
  .desktop-app[hidden] { display: none; }
65
+ /* The app in front while the launcher shows: kept as it is, scroll and all, but not drawn, so nothing of it shows through. */
66
+ .desktop-app.is-behind { visibility: hidden; }
74
67
 
75
- /* The launcher covers the page: the bar's row is the search, the pills sit on the next row as they are, and the apps fill the rest as pills under their groups. */
76
- .desktop-launcher { position: absolute; inset: 0; z-index: 5; display: flex; flex-direction: column; background: var(--desk); }
77
- .desktop-search { flex: 1; min-width: 0; height: 100%; padding: 0 10px 0 6px; background: transparent; border: 0; font-size: 13px; }
68
+ /* The launcher is the mark's tab: it takes the content's place under the bar as any app's window does, a sheet with a line along its top, and a palette sits in its middle, a little above, as a command palette does. It appears at once. */
69
+ .desktop-launcher { position: absolute; inset: 40px 0 0; z-index: 5; display: flex; justify-content: center; align-items: flex-start; padding: max(16px, 14vh) 16px 16px; background: var(--background); border-top: 1px solid var(--border); }
70
+ /* The quick launcher, ⌘P's, over the app in front: the app shows through, dimmed, and the palette floats on a piece of desk. A press around it goes back. */
71
+ .desktop-launcher.is-quick { background: color-mix(in oklch, var(--desk) 55%, transparent); border-top-color: transparent; }
72
+ .desktop-launcher.is-quick .desktop-palette { max-height: min(100%, 520px); padding: 8px; border-radius: 26px; background: var(--desk); }
73
+ .desktop-palette { display: flex; flex-direction: column; gap: 8px; width: min(560px, 100%); max-height: 100%; }
74
+ /* The search is a pill of ink, its icon as far in from the pill's round end as the pill is tall allows; with focus it gains a line. */
75
+ .desktop-launcher-search { display: flex; align-items: center; flex-shrink: 0; height: 36px; padding: 0 4px 0 6px; gap: 3px; border: 1px solid transparent; border-radius: 999px; background: var(--desktop-ink); color: var(--muted-foreground); }
76
+ .desktop-launcher-search:focus-within { border-color: var(--border); }
77
+ .desktop-search { flex: 1; min-width: 0; height: 100%; padding: 0 10px 0 0; background: transparent; border: 0; font-size: 13px; color: var(--foreground); }
78
78
  .desktop-search::placeholder { color: var(--muted-foreground); }
79
- /* Where this level is, at the search row's end, quiet. */
80
79
  .desktop .desktop-search:focus { outline: none; }
81
- /* The palette under the search: one column of rows, and one dot. The dot, under the mark, says which row is chosen, by the keys or the pointer, and glides between rows; the chosen row's content draws toward it. */
82
- .desktop-launcher-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 8px 24px calc(8px + 28px + 4px); }
83
- /* The larger eye, out of the mark, at its own size: its centre is where the transform puts it, so it can be born where the eye was. It hangs from the mark by a string, a hairline straight down from the mark's lower edge, on the eye's own vertical, to the dot's upper edge. */
84
- .desktop-dot { position: absolute; top: 0; left: 0; width: 6.5px; height: 6.5px; margin: -3.25px 0 0 -3.25px; border-radius: 50%; background: var(--primary); opacity: 0; pointer-events: none; transition: opacity .12s; will-change: transform; z-index: 1; }
85
- .desktop-string { position: absolute; top: 0; left: 0; width: 1px; height: 1px; margin-left: -0.5px; transform-origin: 0 0; background: color-mix(in oklch, var(--foreground) 35%, transparent); opacity: 0; pointer-events: none; transition: opacity .12s; will-change: transform; }
86
- /* A row is a pill laid out like the bar's, stacked: the same 3px inset, 22px icon box, 3px gap and 10px end, its × in the icon's place. Rows begin where the bar's pills begin, after the mark's column, so icons, group titles and the search's text share one left edge. */
87
- .desktop-group { display: flex; flex-direction: column; gap: 4px; margin: 0 0 8px; }
88
- .desktop-group-title { margin: 10px 0 2px 6px; font-size: 10px; line-height: 16px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--muted-foreground); }
89
- .desktop-row { position: relative; display: flex; align-items: center; width: 100%; height: 28px; padding: 0; border: 0; border-radius: 999px; background: transparent; color: var(--muted-foreground); text-align: left; transition: translate .18s cubic-bezier(.3, 1.2, .3, 1); }
80
+ /* The rows under the search: one column, the chosen one filled as a pill. */
81
+ .desktop-launcher-body { flex: 1; min-height: 0; overflow: auto; }
82
+ /* Where the rows have more beyond an edge, they fade into what is behind them there. */
83
+ .desktop-launcher-body.is-faded-top { mask-image: linear-gradient(to bottom, transparent, #000 32px); }
84
+ .desktop-launcher-body.is-faded-bottom { mask-image: linear-gradient(to bottom, #000 calc(100% - 32px), transparent); }
85
+ .desktop-launcher-body.is-faded-top.is-faded-bottom { mask-image: linear-gradient(to bottom, transparent, #000 32px, #000 calc(100% - 32px), transparent); }
86
+ /* A row is a pill laid out like the bar's, stacked: the same 3px inset, 22px icon box, 3px gap and 10px end, its × in the icon's place; group titles start where the labels do. */
87
+ .desktop-group { display: flex; flex-direction: column; gap: 2px; margin: 0 0 8px; }
88
+ .desktop-group-title { margin: 10px 0 2px 28px; font-size: 10px; line-height: 16px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--muted-foreground); }
89
+ .desktop-row { position: relative; display: flex; align-items: center; flex-shrink: 0; width: 100%; height: 28px; padding: 0; border: 0; border-radius: 999px; background: transparent; color: var(--muted-foreground); text-align: left; }
90
90
  .desktop-row-app, .desktop-row-main { display: flex; align-items: center; gap: 3px; min-width: 0; padding: 0 10px 0 3px; border: 0; border-radius: 999px; background: transparent; color: inherit; text-align: left; }
91
- /* One state only, chosen: the text brightens and the row eases toward the dot, its × with it. */
92
91
  .desktop-row-main { flex: 1; align-self: stretch; }
93
- .desktop-row.is-cursor { color: var(--foreground); translate: -6px 0; }
92
+ .desktop-row.is-cursor { color: var(--foreground); background: var(--desktop-ink-strong); }
94
93
  .desktop-row-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
95
94
  .desktop-row-note { padding-left: 8px; font-size: 11px; color: var(--muted-foreground); white-space: nowrap; }
96
95
  .desktop-row-window:hover .desktop-pill-icon, .desktop-row-window:has(:focus-visible) .desktop-pill-icon { opacity: 0; transform: scale(.5); }
97
96
  .desktop-row-window:hover .desktop-pill-close, .desktop-row-window:has(:focus-visible) .desktop-pill-close { opacity: 1; transform: none; pointer-events: auto; }
98
- /* Rows and titles drop into place from above, one after another, quickly. */
99
- .desktop-row, .desktop-group-title { animation: desktop-arrive .16s cubic-bezier(.2, .7, .2, 1) both; animation-delay: calc(min(var(--i, 0), 14) * 14ms); }
100
- @keyframes desktop-arrive { from { opacity: 0; transform: translateY(-6px); } }
101
- @media (prefers-reduced-motion: reduce) { .desktop-row, .desktop-group-title { animation: none; } .desktop-eye-small, .desktop-row, .desktop-pill-icon, .desktop-pill-close { transition: none; } }
97
+ @media (prefers-reduced-motion: reduce) { .desktop-eye-small, .desktop-pill-icon, .desktop-pill-close { transition: none; } }
102
98
  .desktop-icon { display: block; flex-shrink: 0; }
103
- .desktop-muted { color: var(--muted-foreground); font-size: 11px; margin: 8px 0 8px 6px; }
99
+ .desktop-muted { color: var(--muted-foreground); font-size: 11px; margin: 8px 0 8px 28px; }
104
100
  /* The legend at the foot: the keys, as small pills, and what they do. */
105
- .desktop-launcher-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; flex-shrink: 0; padding: 10px 16px 12px; font-size: 11px; color: var(--muted-foreground); }
101
+ .desktop-launcher-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; flex-shrink: 0; padding: 4px 3px; font-size: 11px; color: var(--muted-foreground); }
106
102
  .desktop-launcher-legend span { display: inline-flex; align-items: center; gap: 4px; }
107
103
  .desktop-launcher-legend kbd { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--desktop-ink); color: var(--foreground); font: inherit; font-size: 10px; }
108
104
  .desktop-empty { padding: 24px; }
@@ -17,9 +17,10 @@
17
17
  // key in its own storage, so a reload lands back in it; a link with
18
18
  // `?session=KEY` joins it. A call that names no session reaches the current one, the last
19
19
  // touched, so the CLI's `desktop-open` lands in the tab last used; a session
20
- // left empty for a day goes on its own. The theme is the baba's, one resource,
21
- // not a session's. The launcher is the page's: it shows while its search has
22
- // focus, or while nothing is in front. An app's icon is a Lucide name; the
20
+ // left empty for a day goes on its own. The baba's own theme is one resource,
21
+ // not a session's, which the Maker's frames show; voss's pages wear the
22
+ // person's, from voss's settings. The launcher is the page's: the mark's tab,
23
+ // or quick over the app in front, and what is shown while nothing is. An app's icon is a Lucide name; the
23
24
  // system reads its drawing from the baba's Lucide package once and keeps it on
24
25
  // the app, so every door draws the same icon. An app may declare its routes,
25
26
  // the paths its windows may be at; a path none of them takes is refused.
@@ -41,6 +42,7 @@ export const sessionNames = [
41
42
  "laurel", "magnolia", "myrtle", "sequoia", "sycamore", "acacia", "baobab", "ginkgo",
42
43
  ] as const;
43
44
  export type { AppSpec } from "../ecs/baba.ts";
45
+ export { keyCommands, keymapOf, commandOf, bindingOf, formatBinding, normalBinding, bindingNote, applyKeyChanges, type KeyCommand, type Keymap, type KeyOverrides, type KeyChanges } from "./keys.ts";
44
46
  export { route, matchRoute, canonicalPath, type RouteParams, type RouteMatch } from "./routes.ts";
45
47
  import type { AppSpec } from "../ecs/baba.ts";
46
48
  /** The old name. */
@@ -76,7 +78,7 @@ export function desktop(options: DesktopOptions) {
76
78
  desktopAnswer: { session: s.nullable(s.entity()), error: s.nullable(s.string()) },
77
79
  /** An app's icon, drawn: the shapes of the Lucide icon its `icon` names, none when the baba has no such icon. */
78
80
  desktopIcon: { name: s.string(), shapes: s.array(s.object({ tag: s.enum(...iconTags), attrs: s.object({}, { open: true }) })) },
79
- /** The baba's settings, the same on every page: its theme. */
81
+ /** The baba's settings: its theme, which voss's own pages follow voss's settings over; kept for the Maker's frames and older states. */
80
82
  desktopSettings: resource(settingsShape, { theme: "system" }),
81
83
  },
82
84
  effects: {
@@ -262,7 +264,7 @@ export function desktop(options: DesktopOptions) {
262
264
  }),
263
265
 
264
266
  p.action("desktop-settings", {
265
- summary: "set the baba's theme, the same on every page", args: { ...sessionArg, theme: s.optional(s.enum(...themes)) },
267
+ summary: "set the baba's own theme, as its frames in the Maker show it; voss's pages follow the theme in voss's settings", args: { ...sessionArg, theme: s.optional(s.enum(...themes)) },
266
268
  run: (w, a) => {
267
269
  sync(w); if (a.theme) w.set(settings, { theme: a.theme });
268
270
  },
@@ -293,8 +295,8 @@ export const vossApps = [
293
295
  { key: "maker", title: "Maker", icon: "hammer", group: "Voss" },
294
296
  { key: "state", title: "State", icon: "database", group: "Voss" },
295
297
  { key: "contract", title: "Contract", icon: "square-terminal", group: "Voss" },
296
- { key: "promptware", title: "Promptware", icon: "book-open-text", group: "Voss" },
297
- { key: "settings", title: "Settings", icon: "settings", group: "Voss" },
298
+ { key: "instructions", title: "Instructions", icon: "book-open-text", group: "Voss" },
299
+ { key: "settings", title: "Settings", icon: "settings", group: "Voss", routes: ["/", "/appearance", "/keyboard", "/about"] },
298
300
  ] as const satisfies readonly AppSpec[];
299
301
  export type VossApp = typeof vossApps[number]["key"];
300
302
  /** The app a baba that declares none has: its interface, whatever it is, in one window. */
@@ -0,0 +1,153 @@
1
+ // The shell's keys: the commands a key can run, their default bindings, and a
2
+ // binding's one spelling. A binding is written `Mod+Shift+K`: `Mod` is ⌘ on a
3
+ // Mac and Ctrl elsewhere, then `Ctrl`, `Alt`, `Shift`, `Meta` as the keyboard
4
+ // names them, in that order, and one key named as it sits on the keyboard,
5
+ // not by what it types, so ⌥ on a Mac still binds the letter. The keys are
6
+ // the person's, not a baba's: voss keeps those remapped in its own settings,
7
+ // the same on every baba's desktop. A command not remapped has its default,
8
+ // and a command may have none, so it runs only once a key is given it:
9
+ // closing a tab, whose ⌘W browsers and the apps around them keep for closing
10
+ // themselves, a press away from losing the page. The launcher's own keys, the
11
+ // arrows, Enter and Escape, are fixed: they are how a palette is worked.
12
+
13
+ /** The commands a key can run on the desktop, in the order the settings list them. */
14
+ export const keyCommands: readonly { id: "launcher" | "previousTab" | "nextTab" | "closeTab"; title: string; summary: string; default: string | null }[] = [
15
+ { id: "launcher", title: "Quick launcher", summary: "Open the launcher over the app in front, and close it again.", default: "Mod+P" },
16
+ { id: "previousTab", title: "Previous tab", summary: "Go to the tab on the left; from the first, the launcher's.", default: "Mod+J" },
17
+ { id: "nextTab", title: "Next tab", summary: "Go to the tab on the right; from the last, the launcher's.", default: "Mod+K" },
18
+ { id: "closeTab", title: "Close tab", summary: "Close the window in front; its × and a middle click do too.", default: null },
19
+ ];
20
+ export type KeyCommand = typeof keyCommands[number]["id"];
21
+ /** A command's binding, each command's own or its default; null for a command no key runs. */
22
+ export type Keymap = Record<KeyCommand, string | null>;
23
+ /** What the settings keep: only the commands remapped. */
24
+ export type KeyOverrides = Partial<Record<KeyCommand, string>>;
25
+
26
+ const modifiers = ["Mod", "Ctrl", "Alt", "Shift", "Meta"] as const;
27
+ type Modifier = typeof modifiers[number];
28
+ /** Keys by `KeyboardEvent.code`: letters and digits, the punctuation, the arrows and the function keys. */
29
+ const named: Record<string, string> = {
30
+ Minus: "-", Equal: "=", BracketLeft: "[", BracketRight: "]", Backslash: "\\", Semicolon: ";", Quote: "'", Backquote: "`", Comma: ",", Period: ".", Slash: "/",
31
+ ArrowLeft: "Left", ArrowRight: "Right", ArrowUp: "Up", ArrowDown: "Down", Space: "Space", Tab: "Tab", Enter: "Enter", Backspace: "Backspace", Delete: "Delete",
32
+ Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown",
33
+ };
34
+ const keyOfCode = (code: string): string | null => {
35
+ const m = /^(?:Key([A-Z])|Digit([0-9])|Numpad([0-9])|(F(?:[1-9]|1[0-2])))$/.exec(code);
36
+ if (m) return m[1] ?? m[2] ?? m[3] ?? m[4]!;
37
+ return named[code] ?? null;
38
+ };
39
+ const keys = new Set([..."ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", ...Object.values(named), ...Array.from({ length: 12 }, (_, i) => `F${i + 1}`)]);
40
+
41
+ /** A binding's parts, or null when it is not one: one key, after modifiers each at most once; a key with no modifier only when it is a function key. */
42
+ export function parseBinding(text: string): { mods: Modifier[]; key: string } | null {
43
+ const parts = text.split("+").map((p) => p.trim());
44
+ // A binding of the plus key itself is not offered: the key is `=` with Shift.
45
+ if (parts.some((p) => !p)) return null;
46
+ const key = parts.pop()!;
47
+ const k = key.length === 1 ? key.toUpperCase() : key;
48
+ const mods: Modifier[] = [];
49
+ for (const p of parts) {
50
+ const m = modifiers.find((x) => x.toLowerCase() === p.toLowerCase());
51
+ if (!m || mods.includes(m)) return null;
52
+ mods.push(m);
53
+ }
54
+ if (!keys.has(k)) return null;
55
+ // A plain key, or Shift with one, is what typing is: only function keys may go without a modifier.
56
+ if (!mods.some((m) => m !== "Shift") && !/^F\d+$/.test(k)) return null;
57
+ return { mods: modifiers.filter((m) => mods.includes(m)), key: k };
58
+ }
59
+
60
+ /** A binding in its one spelling, modifiers in order; null when it is not one. */
61
+ export function normalBinding(text: string): string | null {
62
+ const b = parseBinding(text);
63
+ return b ? [...b.mods, b.key].join("+") : null;
64
+ }
65
+
66
+ /** Whether this is a Mac, where `Mod` is ⌘; elsewhere it is Ctrl. */
67
+ export const isMac = (): boolean => {
68
+ // Read off the global, not typed by the DOM: the desktop system imports this module, and a baba's backend has no browser.
69
+ const nav = (globalThis as { navigator?: { userAgentData?: { platform?: string }; platform?: string; userAgent?: string } }).navigator;
70
+ return !!nav && /mac|iphone|ipad/i.test(nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent ?? "");
71
+ };
72
+
73
+ /** The binding a key press spells, or null for a press of a modifier alone or of a key not offered. */
74
+ export function bindingOf(e: { code: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }, mac = isMac()): string | null {
75
+ const key = keyOfCode(e.code);
76
+ if (!key) return null;
77
+ const mods: Modifier[] = [];
78
+ if (mac ? e.metaKey : e.ctrlKey) mods.push("Mod");
79
+ if (mac && e.ctrlKey) mods.push("Ctrl");
80
+ if (e.altKey) mods.push("Alt");
81
+ if (e.shiftKey) mods.push("Shift");
82
+ if (!mac && e.metaKey) mods.push("Meta");
83
+ return [...mods, key].join("+");
84
+ }
85
+
86
+ /** Each command's binding: the person's own where they remapped one, else the default. */
87
+ export function keymapOf(overrides: KeyOverrides | undefined): Keymap {
88
+ return Object.fromEntries(keyCommands.map((c) => [c.id, overrides?.[c.id] ?? c.default])) as Keymap;
89
+ }
90
+
91
+ /** A binding as this keyboard presses it: off a Mac Ctrl is Mod, on one Meta is Mod, so a binding written with either still fires. */
92
+ export function bindingOn(binding: string, mac = isMac()): string | null {
93
+ const b = parseBinding(binding);
94
+ if (!b) return null;
95
+ const same: Modifier = mac ? "Meta" : "Ctrl";
96
+ const mods = new Set(b.mods.map((m) => (m === same ? "Mod" : m)));
97
+ return [...modifiers.filter((m) => mods.has(m)), b.key].join("+");
98
+ }
99
+
100
+ /** The command a key press runs, if any. */
101
+ export function commandOf(keymap: Keymap, e: Parameters<typeof bindingOf>[0], mac = isMac()): KeyCommand | null {
102
+ const b = bindingOf(e, mac);
103
+ if (!b) return null;
104
+ return keyCommands.find((c) => keymap[c.id] !== null && bindingOn(keymap[c.id]!, mac) === b)?.id ?? null;
105
+ }
106
+
107
+ /** A binding as people read it: `⌘⇧K` on a Mac, `Ctrl+Shift+K` elsewhere. */
108
+ export function formatBinding(text: string, mac = isMac()): string {
109
+ const b = parseBinding(text);
110
+ if (!b) return text;
111
+ const arrows: Record<string, string> = { Left: "←", Right: "→", Up: "↑", Down: "↓" };
112
+ if (mac) {
113
+ const sign: Record<Modifier, string> = { Mod: "⌘", Ctrl: "⌃", Alt: "⌥", Shift: "⇧", Meta: "⌘" };
114
+ return [...b.mods.map((m) => sign[m]), arrows[b.key] ?? b.key].join("");
115
+ }
116
+ const word: Record<Modifier, string> = { Mod: "Ctrl", Ctrl: "Ctrl", Alt: "Alt", Shift: "Shift", Meta: "Win" };
117
+ return [...b.mods.map((m) => word[m]), arrows[b.key] ?? b.key].join("+");
118
+ }
119
+
120
+ /** What the browser keeps for itself, or does that people rely on: a page can take some of these, never all, and taking them costs something. */
121
+ const browserKeys: Record<string, string> = {
122
+ "Mod+L": "the browser's address bar", "Mod+T": "a new browser tab", "Mod+N": "a new browser window", "Mod+Shift+N": "a private window",
123
+ "Mod+Shift+T": "reopening a closed tab", "Mod+Q": "quitting the browser", "Mod+R": "reloading the page", "Mod+F": "finding in the page",
124
+ "Mod+W": "closing the browser tab, or the app the page runs in", "Mod+Shift+W": "closing the browser window", "Mod+Tab": "switching browser tabs", "Ctrl+Tab": "switching browser tabs", "Mod+C": "copy", "Mod+V": "paste", "Mod+X": "cut", "Mod+Z": "undo", "Mod+A": "select all",
125
+ ...Object.fromEntries(Array.from({ length: 9 }, (_, i) => [`Mod+${i + 1}`, "switching browser tabs"])),
126
+ };
127
+ /** Why a binding is a poor choice, when the browser or the system already uses it; null when it is free. */
128
+ export function bindingNote(binding: string): string | null {
129
+ const n = normalBinding(binding);
130
+ const why = n && browserKeys[n];
131
+ return why ? `Also ${why}: the browser may keep it.` : null;
132
+ }
133
+
134
+ /** A change to the keys: a binding per command, or null for its default. */
135
+ export type KeyChanges = Partial<Record<KeyCommand, string | null>>;
136
+ /** The remapped keys after a change: each binding spelled once, a default not kept as a remapping; refused when a binding is not one, or when two commands would share one. */
137
+ export function applyKeyChanges(now: KeyOverrides | undefined, changes: KeyChanges): KeyOverrides {
138
+ const next: KeyOverrides = { ...now };
139
+ for (const c of keyCommands) {
140
+ const v = changes[c.id];
141
+ if (v === undefined) continue;
142
+ if (v === null) { delete next[c.id]; continue; }
143
+ const n = normalBinding(v);
144
+ if (!n) throw new Error(`${v} is not a binding: modifiers from Mod, Ctrl, Alt, Shift, Meta, then one key, as Mod+Shift+K`);
145
+ if (n === c.default) delete next[c.id]; else next[c.id] = n;
146
+ }
147
+ const bound = keyCommands.map((c) => [c, next[c.id] ?? c.default] as const);
148
+ for (const [c, b] of bound) {
149
+ const other = b !== null && bound.find(([o, ob]) => o.id !== c.id && ob === b);
150
+ if (other) throw new Error(`${b} would run both ${c.title} and ${other[0].title}`);
151
+ }
152
+ return next;
153
+ }