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.
- package/gui/babavoss-web.js +4 -1
- package/gui/{chunk-9gmmm54v.js → chunk-qgzmajan.js} +619 -574
- package/gui/gui.js +1538 -277
- package/gui/theme.css +2007 -1128
- package/index.ts +1 -1
- package/package.json +5 -5
- package/src/{promptware → agents}/compile.ts +23 -31
- package/src/{promptware → agents}/define.ts +2 -2
- package/src/{promptware → agents}/disk.ts +7 -4
- package/src/{promptware → agents}/sync.ts +2 -2
- package/src/{promptware → agents}/system.ts +17 -17
- package/src/baba/check.ts +3 -3
- package/src/baba/config.ts +5 -2
- package/src/baba/init.ts +13 -18
- package/src/baba/node.ts +2 -2
- package/src/baba/project.ts +2 -2
- package/src/baba/requirements.ts +39 -0
- package/src/baba/setup.ts +30 -0
- package/src/baba/worker.ts +13 -5
- package/src/build/mdx.ts +4 -4
- package/src/build/project.ts +6 -1
- package/src/build/views.ts +3 -3
- package/src/desktop/desktop.css +35 -39
- package/src/desktop/index.ts +9 -7
- package/src/desktop/keys.ts +153 -0
- package/src/desktop/view.tsx +174 -130
- package/src/door/core.ts +2 -2
- package/src/ecs/baba.ts +27 -17
- package/src/generated/build.ts +1 -1
- package/src/gui/gui.tsx +21 -31
- package/src/gui/index.ts +6 -2
- package/src/gui/{promptware.tsx → instructions.tsx} +12 -12
- package/src/gui/levels.tsx +241 -59
- package/src/gui/lockup.ts +11 -0
- package/src/gui/settings.tsx +241 -0
- package/src/gui/setup.tsx +77 -0
- package/src/gui/theme.css +205 -58
- package/src/gui/theme.ts +9 -5
- package/src/gui/voss-settings.ts +48 -0
- package/src/gui/wizard.tsx +17 -40
- package/src/guide/add-a-desktop.mdx +7 -7
- package/src/guide/index.ts +4 -4
- package/src/guide/write-a-system.mdx +3 -3
- package/src/guide/{write-promptware.mdx → write-agentic-instructions.mdx} +17 -24
- package/src/http/server.ts +140 -17
- package/src/{prompt → instructions}/evals.ts +1 -1
- package/src/{prompt → instructions}/index.ts +9 -8
- package/src/{prompt → instructions}/jsx-runtime.ts +3 -3
- package/src/mcp/main.ts +3 -3
- package/src/mcp/tools.ts +4 -4
- package/src/runtime/harness.ts +2 -2
- package/src/server/edge.ts +31 -3
- package/src/server/main.ts +2 -1
- package/src/server/messages.ts +47 -7
- package/src/shell/run.ts +18 -18
- package/src/spec/index.ts +1 -1
- package/src/web/core.tsx +2 -2
- package/src/web/index.tsx +1 -1
- package/src/desktop/bob.ts +0 -76
- /package/src/{promptware → agents}/markdown.d.ts +0 -0
- /package/src/{prompt → instructions}/jsx-dev-runtime.ts +0 -0
- /package/src/{prompt → instructions}/mdx.d.ts +0 -0
package/src/build/project.ts
CHANGED
|
@@ -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
|
|
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";
|
package/src/build/views.ts
CHANGED
|
@@ -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/
|
|
31
|
-
"babavoss/
|
|
32
|
-
"babavoss/
|
|
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
|
/**
|
package/src/desktop/desktop.css
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
16
|
-
.desktop-
|
|
17
|
-
|
|
18
|
-
.desktop-
|
|
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
|
|
76
|
-
.desktop-launcher { position: absolute; inset: 0; z-index: 5; display: flex; flex-
|
|
77
|
-
|
|
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
|
|
82
|
-
.desktop-launcher-body { flex: 1; min-height: 0; overflow: auto;
|
|
83
|
-
/*
|
|
84
|
-
.desktop-
|
|
85
|
-
.desktop-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.desktop-group
|
|
89
|
-
.desktop-
|
|
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);
|
|
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
|
-
|
|
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
|
|
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:
|
|
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; }
|
package/src/desktop/index.ts
CHANGED
|
@@ -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
|
|
21
|
-
// not a session's
|
|
22
|
-
//
|
|
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
|
|
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
|
|
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: "
|
|
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
|
+
}
|