babavoss 0.0.1 → 0.12.0

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 (158) hide show
  1. package/NOTICE +7 -0
  2. package/bin/voss.ts +48 -0
  3. package/gui/babavoss-web.js +234 -0
  4. package/gui/chunk-5hpp1ypv.js +11710 -0
  5. package/gui/chunk-9rq662fd.js +8627 -0
  6. package/gui/chunk-k3cm8j6r.js +433 -0
  7. package/gui/chunk-pk09y98y.js +50 -0
  8. package/gui/chunk-smz02qa6.js +186 -0
  9. package/gui/chunk-wwqypxre.js +49 -0
  10. package/gui/gui.js +2015 -0
  11. package/gui/react-compiler-runtime.js +39 -0
  12. package/gui/react-dom-client.js +21 -0
  13. package/gui/react-dom.js +44 -0
  14. package/gui/react-jsx-runtime.js +19 -0
  15. package/gui/react.js +105 -0
  16. package/gui/theme.css +3053 -0
  17. package/index.ts +15 -0
  18. package/package.json +50 -4
  19. package/src/baba/check.ts +90 -0
  20. package/src/baba/config.ts +261 -0
  21. package/src/baba/find.ts +12 -0
  22. package/src/baba/init.ts +176 -0
  23. package/src/baba/node.ts +483 -0
  24. package/src/baba/project.ts +63 -0
  25. package/src/baba/registry.ts +35 -0
  26. package/src/baba/worker.ts +55 -0
  27. package/src/bench/index.ts +6 -0
  28. package/src/bench/measure.ts +132 -0
  29. package/src/bench/scenarios.ts +136 -0
  30. package/src/build/builder.ts +74 -0
  31. package/src/build/failure.ts +78 -0
  32. package/src/build/guard.ts +85 -0
  33. package/src/build/mdx-register.ts +3 -0
  34. package/src/build/mdx.ts +38 -0
  35. package/src/build/project.ts +38 -0
  36. package/src/build/views.ts +146 -0
  37. package/src/builder/main.ts +29 -0
  38. package/src/desktop/bob.ts +76 -0
  39. package/src/desktop/desktop.css +111 -0
  40. package/src/desktop/icons.ts +50 -0
  41. package/src/desktop/index.ts +323 -0
  42. package/src/desktop/routes.ts +98 -0
  43. package/src/desktop/view.tsx +673 -0
  44. package/src/door/core.ts +384 -0
  45. package/src/ecs/baba.ts +431 -0
  46. package/src/ecs/codec.ts +334 -0
  47. package/src/ecs/handles.ts +91 -0
  48. package/src/ecs/replica.ts +150 -0
  49. package/src/ecs/runtime.ts +603 -0
  50. package/src/ecs/scheduler.ts +75 -0
  51. package/src/ecs/snapshot.ts +102 -0
  52. package/src/ecs/state.ts +759 -0
  53. package/src/ecs/system.ts +256 -0
  54. package/src/ecs/table.ts +420 -0
  55. package/src/ecs/testbed.ts +97 -0
  56. package/src/exec/host.ts +177 -0
  57. package/src/exec/main.ts +98 -0
  58. package/src/exec/watch.ts +7 -0
  59. package/src/exec/wire.ts +29 -0
  60. package/src/generated/build.ts +4 -0
  61. package/src/gui/css.d.ts +1 -0
  62. package/src/gui/gui.tsx +245 -0
  63. package/src/gui/index.ts +51 -0
  64. package/src/gui/inspector.tsx +47 -0
  65. package/src/gui/levels.tsx +73 -0
  66. package/src/gui/promptware.tsx +68 -0
  67. package/src/gui/runner.tsx +118 -0
  68. package/src/gui/theme.css +498 -0
  69. package/src/gui/theme.ts +25 -0
  70. package/src/gui/wizard.tsx +227 -0
  71. package/src/guide/add-a-desktop.mdx +100 -0
  72. package/src/guide/compose-an-interface.mdx +84 -0
  73. package/src/guide/index.ts +13 -0
  74. package/src/guide/reach-outside.mdx +112 -0
  75. package/src/guide/spec-a-system.mdx +93 -0
  76. package/src/guide/systems-together.mdx +72 -0
  77. package/src/guide/write-a-system.mdx +183 -0
  78. package/src/guide/write-promptware.mdx +90 -0
  79. package/src/http/server.ts +310 -0
  80. package/src/kernel/build.ts +21 -0
  81. package/src/kernel/builder.ts +105 -0
  82. package/src/kernel/children.ts +117 -0
  83. package/src/kernel/context.ts +90 -0
  84. package/src/kernel/lock.ts +46 -0
  85. package/src/kernel/names.ts +14 -0
  86. package/src/kernel/schema.ts +130 -0
  87. package/src/kernel/where.ts +12 -0
  88. package/src/kit/index.ts +232 -0
  89. package/src/maker/system.ts +213 -0
  90. package/src/mcp/daemon.ts +61 -0
  91. package/src/mcp/main.ts +208 -0
  92. package/src/mcp/rpc.ts +64 -0
  93. package/src/mcp/tools.ts +125 -0
  94. package/src/prompt/evals.ts +42 -0
  95. package/src/prompt/index.ts +242 -0
  96. package/src/prompt/jsx-dev-runtime.ts +1 -0
  97. package/src/prompt/jsx-runtime.ts +49 -0
  98. package/src/prompt/mdx.d.ts +1 -0
  99. package/src/promptware/compile.ts +183 -0
  100. package/src/promptware/define.ts +16 -0
  101. package/src/promptware/disk.ts +72 -0
  102. package/src/promptware/markdown.d.ts +6 -0
  103. package/src/promptware/sync.ts +437 -0
  104. package/src/promptware/system.ts +215 -0
  105. package/src/runtime/bridge.ts +85 -0
  106. package/src/runtime/connect.ts +54 -0
  107. package/src/runtime/env.ts +35 -0
  108. package/src/runtime/harness.ts +80 -0
  109. package/src/runtime/main.ts +119 -0
  110. package/src/runtime/worker.ts +33 -0
  111. package/src/server/edge.ts +332 -0
  112. package/src/server/main.ts +45 -0
  113. package/src/server/messages.ts +97 -0
  114. package/src/server/protocol.ts +37 -0
  115. package/src/services/args.ts +45 -0
  116. package/src/services/exec.ts +69 -0
  117. package/src/services/fs.ts +139 -0
  118. package/src/services/http.ts +30 -0
  119. package/src/services/index.ts +113 -0
  120. package/src/services/secrets.ts +18 -0
  121. package/src/shell/address.ts +21 -0
  122. package/src/shell/args.ts +219 -0
  123. package/src/shell/client.ts +107 -0
  124. package/src/shell/codes.ts +26 -0
  125. package/src/shell/positional.ts +20 -0
  126. package/src/shell/run.ts +470 -0
  127. package/src/shell/service.ts +167 -0
  128. package/src/shell/state.ts +204 -0
  129. package/src/spec/adapters.ts +72 -0
  130. package/src/spec/diff.ts +26 -0
  131. package/src/spec/files.ts +17 -0
  132. package/src/spec/index.ts +155 -0
  133. package/src/spec/run.ts +97 -0
  134. package/src/spec/take.ts +54 -0
  135. package/src/test/index.ts +8 -0
  136. package/src/test/prove.ts +56 -0
  137. package/src/test/records.ts +23 -0
  138. package/src/test/specs.ts +56 -0
  139. package/src/test/steps.ts +100 -0
  140. package/src/test/voss-dir.ts +17 -0
  141. package/src/transport/messages.ts +110 -0
  142. package/src/transport/transport.ts +62 -0
  143. package/src/wall/probe.ts +67 -0
  144. package/src/wall/profile.ts +103 -0
  145. package/src/wall/spawn.ts +59 -0
  146. package/src/web/app.tsx +53 -0
  147. package/src/web/core.tsx +140 -0
  148. package/src/web/form.ts +155 -0
  149. package/src/web/hooks.ts +135 -0
  150. package/src/web/index.tsx +17 -0
  151. package/src/web/list.ts +19 -0
  152. package/src/web/maker.tsx +766 -0
  153. package/src/web/objects.tsx +213 -0
  154. package/src/web/socket.ts +84 -0
  155. package/src/web/state.tsx +69 -0
  156. package/src/web/store.ts +221 -0
  157. package/src/web/ui.tsx +135 -0
  158. package/README.md +0 -5
@@ -0,0 +1,146 @@
1
+ // Views for the browser: the names the page shares with every view, the
2
+ // bundler for one view, and the resolver that binds the framework's own
3
+ // names to its source, so nothing here needs a node_modules to find them.
4
+ import { join } from "node:path";
5
+ import { existsSync, realpathSync, statSync } from "node:fs";
6
+ import { guardImports } from "./guard.ts";
7
+ import { specsModule } from "../test/specs.ts";
8
+ import { buildFailure, ViewFailed, within } from "./failure.ts";
9
+ import type { BuildOutput, ViewSpec } from "./builder.ts";
10
+ import { framework } from "../kernel/where.ts";
11
+
12
+ /** The names a view leaves external; the page's import map resolves them to the modules built with the GUI. */
13
+ export const shared: Record<string, string> = {
14
+ "react": "react.js",
15
+ "react-dom": "react-dom.js",
16
+ "react-dom/client": "react-dom-client.js",
17
+ "react/jsx-runtime": "react-jsx-runtime.js",
18
+ "react/compiler-runtime": "react-compiler-runtime.js",
19
+ "babavoss/web": "babavoss-web.js",
20
+ };
21
+
22
+ /** The framework's names, to its source: the running voss is the framework. */
23
+ export const names: Record<string, string> = {
24
+ "babavoss": join(framework, "index.ts"),
25
+ "babavoss/desktop": join(framework, "src/desktop/index.ts"),
26
+ "babavoss/kit": join(framework, "src/kit/index.ts"),
27
+ "babavoss/web": join(framework, "src/web/index.tsx"),
28
+ "babavoss/test": join(framework, "src/test/index.ts"),
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"),
33
+ };
34
+
35
+ /**
36
+ * \`@/\` is the baba's own \`.baba\` directory, whatever its tsconfig says: how an app reaches the kit, \`@/ui/button\`.
37
+ * Bun's bundler would follow tsconfig paths, but the name is bound here so every baba spells it the same. A scoped package, \`@scope/name\`, never starts with \`@/\`.
38
+ */
39
+ export function aliasOf(baba: string): import("bun").BunPlugin {
40
+ const tries = ["", ".tsx", ".ts", ".jsx", ".js", ".css", "/index.tsx", "/index.ts", "/index.jsx", "/index.js"];
41
+ return {
42
+ name: "babavoss-alias",
43
+ setup(b) {
44
+ b.onResolve({ filter: /^@\// }, (a) => {
45
+ const base = join(baba, a.path.slice(2));
46
+ for (const t of tries) { const p = base + t; try { if (existsSync(p) && statSync(p).isFile()) return { path: p }; } catch { /* next */ } }
47
+ return undefined;
48
+ });
49
+ },
50
+ };
51
+ }
52
+
53
+ /** "babavoss:specs": every spec of the baba at `baba`, its .baba directory, found where it lives, for the Maker's page. */
54
+ export function specsOf(baba: string): import("bun").BunPlugin {
55
+ return {
56
+ name: "babavoss-specs",
57
+ setup(b) {
58
+ b.onResolve({ filter: /^babavoss:specs$/ }, () => ({ path: "specs", namespace: "babavoss-specs" }));
59
+ b.onLoad({ filter: /.*/, namespace: "babavoss-specs" }, () => ({ contents: specsModule(baba), loader: "ts", resolveDir: baba }));
60
+ },
61
+ };
62
+ }
63
+
64
+ export const resolver: import("bun").BunPlugin = {
65
+ name: "babavoss",
66
+ setup(b) {
67
+ b.onResolve({ filter: /^babavoss(\/.*)?$/ }, (a) => (names[a.path] ? { path: names[a.path]! } : undefined));
68
+ },
69
+ };
70
+
71
+ /**
72
+ * Bundles one view: the project's own dependencies inside, the shared names
73
+ * external, the React Compiler on. Every output comes back, the entry
74
+ * first; URLs the bundle emits for its chunks and assets start with
75
+ * `publicPath`. Fails with a ViewFailed whose paths are relative to
76
+ * `root`, the project's `.baba`.
77
+ */
78
+ export async function bundleView(spec: ViewSpec): Promise<BuildOutput[]> {
79
+ const { root, publicPath } = spec;
80
+ // The bundler reports real paths; so is the entry given to it, and so is the root a failure is relative to.
81
+ let entry = spec.entry;
82
+ try { entry = realpathSync(entry); } catch {}
83
+ const extra = await loadBuildPlugins(spec.plugins, spec.dir, root);
84
+ const r = await Bun.build({
85
+ throw: false,
86
+ publicPath,
87
+ naming: { entry: "[name].[ext]", chunk: "[name]-[hash].[ext]", asset: "[name]-[hash].[ext]" },
88
+ // Packages that offer only development or production builds, with no default.
89
+ conditions: ["production"],
90
+ splitting: true,
91
+ entrypoints: [entry], target: "browser", format: "esm",
92
+ external: Object.keys(shared),
93
+ jsx: { runtime: "automatic", importSource: "react", development: false },
94
+ reactCompiler: true,
95
+ plugins: [guardImports, aliasOf(spec.dir), specsOf(spec.dir), ...extra],
96
+ define: { "process.env.NODE_ENV": '"production"' },
97
+ });
98
+ if (!r.success) {
99
+ // The bundler reports real paths; the root may be reached through a link.
100
+ let real = root;
101
+ try { real = realpathSync(root); } catch {}
102
+ throw new ViewFailed(buildFailure(r.logs, real));
103
+ }
104
+ const outputs: BuildOutput[] = [];
105
+ for (const o of r.outputs) outputs.push({ path: o.path.replace(/^\.\//, ""), kind: o.kind, type: o.type, bytes: new Uint8Array(await o.arrayBuffer()) });
106
+ outputs.sort((a, b) => (a.kind === "entry-point" ? -1 : 0) - (b.kind === "entry-point" ? -1 : 0));
107
+ for (const a of spec.assets) outputs.push(...await assetOutputs(a, spec.dir, root));
108
+ return outputs;
109
+ }
110
+
111
+ /**
112
+ * The bundler plugins a package declared, loaded from its own node_modules.
113
+ * This runs the package's code: it happens in the builder, behind the wall.
114
+ */
115
+ export async function loadBuildPlugins(names: string[], dir: string, root: string): Promise<import("bun").BunPlugin[]> {
116
+ const out: import("bun").BunPlugin[] = [];
117
+ for (const name of names) {
118
+ let plugin: unknown;
119
+ try {
120
+ const mod = await import(Bun.resolveSync(name, dir)) as { default?: unknown };
121
+ plugin = mod.default ?? mod;
122
+ } catch (err) {
123
+ throw new ViewFailed({ stage: "build", message: `the build plugin ${name} could not be loaded from ${within(root, dir)}: ${err instanceof Error ? err.message : String(err)}` });
124
+ }
125
+ if (!plugin || typeof plugin !== "object" || typeof (plugin as { setup?: unknown }).setup !== "function") {
126
+ throw new ViewFailed({ stage: "build", message: `the build plugin ${name} is not a bundler plugin: its default export has no setup()` });
127
+ }
128
+ out.push(plugin as import("bun").BunPlugin);
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** A directory a package declared: every file under it, served as `assets/<path>` beside the view. */
134
+ async function assetOutputs(rel: string, dir: string, root: string): Promise<BuildOutput[]> {
135
+ const base = join(dir, rel);
136
+ let real: string;
137
+ try { real = realpathSync(base); } catch { throw new ViewFailed({ stage: "build", message: `the asset directory ${rel} is not under ${within(root, dir)}` }); }
138
+ const home = realpathSync(dir);
139
+ if (real !== home && !real.startsWith(home + "/")) throw new ViewFailed({ stage: "build", message: `the asset directory ${rel} leaves ${within(root, dir)}` });
140
+ const out: BuildOutput[] = [];
141
+ for (const p of [...new Bun.Glob("**/*").scanSync({ cwd: real, onlyFiles: true, dot: false })].sort()) {
142
+ const f = Bun.file(join(real, p));
143
+ out.push({ path: `assets/${p}`, kind: "asset", type: f.type, bytes: new Uint8Array(await f.arrayBuffer()) });
144
+ }
145
+ return out;
146
+ }
@@ -0,0 +1,29 @@
1
+ // The builder role: one process per project, walled with its project
2
+ // readable, speaking IPC to the kernel. It bundles what it is asked to,
3
+ // backend or view, and answers with every output as bytes, or a failure.
4
+ // The project's code runs here at build time, macros and bundler plugins
5
+ // alike, and nowhere else.
6
+ import { ParentTransport } from "../transport/transport.ts";
7
+ import { PROTOCOL, type ToBuilder, type FromBuilder } from "../transport/messages.ts";
8
+ import { bundleProject } from "../build/project.ts";
9
+ import { bundleView } from "../build/views.ts";
10
+ import { ViewFailed, type ViewFailure } from "../build/failure.ts";
11
+
12
+ const kernel = new ParentTransport<FromBuilder, ToBuilder>();
13
+
14
+ const failureOf = (err: unknown): ViewFailure => err instanceof ViewFailed ? err.failure : { stage: "build", message: err instanceof Error ? err.message : String(err) };
15
+
16
+ kernel.onMessage(async (m) => {
17
+ if (m.type === "hello") return;
18
+ try {
19
+ if (m.type === "build-project") {
20
+ const code = await bundleProject(m.project);
21
+ kernel.send({ type: "built", id: m.id, ok: true, outputs: [{ path: "index.js", kind: "entry-point", type: "text/javascript", bytes: new TextEncoder().encode(code) }] });
22
+ } else if (m.type === "build-view") {
23
+ kernel.send({ type: "built", id: m.id, ok: true, outputs: await bundleView(m.spec) });
24
+ }
25
+ } catch (err) {
26
+ kernel.send({ type: "built", id: m.id, ok: false, failure: failureOf(err) });
27
+ }
28
+ });
29
+ kernel.send({ type: "hello", protocol: PROTOCOL, role: "builder" });
@@ -0,0 +1,76 @@
1
+ // The launcher's dot as a body on a string: a point with a velocity, moved by
2
+ // a small simulation rather than by CSS transitions, so a fall can gather
3
+ // speed, the string can catch it, and a new aim mid-flight keeps the momentum.
4
+ // The page says where the dot should be (`aim`); the bob decides how it gets
5
+ // there and draws itself and its string on every frame until it rests.
6
+
7
+ export interface Point { x: number; y: number }
8
+ /** A spring toward the aim: stiffness pulls, damping calms. Below critical damping (2√stiffness) it overshoots. */
9
+ export interface Spring { stiffness: number; damping: number }
10
+ /** Falling: gravity until the string is taut. Caught: the string springs it to rest. Gliding: a spring between rows. */
11
+ export type Phase = "falling" | "caught" | "gliding";
12
+ export interface BobState { at: Point; v: Point; phase: Phase }
13
+
14
+ /** Pixels per second squared: a fall of a few rows lasts about a tenth of a second. */
15
+ export const gravity = 9000;
16
+ /** The string catching the fall: stiff and loose, so it bounces. */
17
+ export const catchSpring: Spring = { stiffness: 1400, damping: 28 };
18
+ /** Moving between rows: quick, with a small overshoot. */
19
+ export const glideSpring: Spring = { stiffness: 1100, damping: 40 };
20
+ /** Sideways onto the string's vertical, while falling. */
21
+ export const swingSpring: Spring = { stiffness: 1600, damping: 56 };
22
+
23
+ const axis = (at: number, v: number, to: number, s: Spring, dt: number) => {
24
+ const a = s.stiffness * (to - at) - s.damping * v;
25
+ const nv = v + a * dt;
26
+ return [at + nv * dt, nv] as const;
27
+ };
28
+
29
+ /** One step of the simulation. A falling bob is pulled down by gravity until it reaches the aim, where the string goes taut and catches it. */
30
+ export function step(s: BobState, aim: Point, dt: number): BobState {
31
+ const [x, vx] = axis(s.at.x, s.v.x, aim.x, s.phase === "gliding" ? glideSpring : swingSpring, dt);
32
+ if (s.phase === "falling") {
33
+ const vy = s.v.y + gravity * dt, y = s.at.y + vy * dt;
34
+ return y < aim.y ? { at: { x, y }, v: { x: vx, y: vy }, phase: "falling" } : { at: { x, y: aim.y }, v: { x: vx, y: vy }, phase: "caught" };
35
+ }
36
+ const [y, vy] = axis(s.at.y, s.v.y, aim.y, s.phase === "caught" ? catchSpring : glideSpring, dt);
37
+ return { at: { x, y }, v: { x: vx, y: vy }, phase: s.phase };
38
+ }
39
+
40
+ /** Whether a bob has come to rest at its aim. */
41
+ export const resting = (s: BobState, aim: Point) => s.phase !== "falling" && Math.abs(s.at.x - aim.x) < .05 && Math.abs(s.at.y - aim.y) < .05 && Math.hypot(s.v.x, s.v.y) < 2;
42
+
43
+ /** Runs a bob on animation frames and draws it with `draw`; it sleeps while at rest. */
44
+ export class Bob {
45
+ private state: BobState | null = null;
46
+ private aim: Point = { x: 0, y: 0 };
47
+ private frame = 0;
48
+ private last = 0;
49
+ constructor(private draw: (at: Point) => void, private still = () => false) {}
50
+ /** Starts a fall from a point, from rest. */
51
+ drop(from: Point) { this.state = { at: { ...from }, v: { x: 0, y: 0 }, phase: "falling" }; this.draw(from); this.wake(); }
52
+ /** Where the dot should be. With `now`, it is there at once (a scroll moves it with the list). */
53
+ to(aim: Point, now = false) {
54
+ const moved = Math.abs(aim.y - this.aim.y) > .5;
55
+ this.aim = aim;
56
+ if (!this.state || now || this.still()) { this.state = { at: { ...aim }, v: { x: 0, y: 0 }, phase: "gliding" }; this.draw(aim); return; }
57
+ // A new aim after the catch is a move between rows; a fall keeps falling toward it.
58
+ if (this.state.phase === "caught" && moved) this.state = { ...this.state, phase: "gliding" };
59
+ this.wake();
60
+ }
61
+ stop() { cancelAnimationFrame(this.frame); this.frame = 0; this.state = null; }
62
+ private wake() {
63
+ if (this.frame) return;
64
+ this.last = performance.now();
65
+ const tick = (now: number) => {
66
+ // Fixed small steps keep a stiff spring stable on a slow frame.
67
+ let left = Math.min(.05, (now - this.last) / 1000); this.last = now;
68
+ while (left > 0 && this.state) { const dt = Math.min(left, 1 / 240); this.state = step(this.state, this.aim, dt); left -= dt; }
69
+ if (!this.state) { this.frame = 0; return; }
70
+ if (resting(this.state, this.aim)) { this.state = { at: { ...this.aim }, v: { x: 0, y: 0 }, phase: "gliding" }; this.draw(this.aim); this.frame = 0; return; }
71
+ this.draw(this.state.at);
72
+ this.frame = requestAnimationFrame(tick);
73
+ };
74
+ this.frame = requestAnimationFrame(tick);
75
+ }
76
+ }
@@ -0,0 +1,111 @@
1
+ /* Layer order first, since this file is inlined first: the theme's layer sits above Tailwind's preflight, below its utilities. */
2
+ @layer theme, base, voss, components, utilities;
3
+ @layer voss {
4
+ /* A bar on top, one app or the launcher below it, using the shared theme. */
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
+ /* Everything that sits on the desk is ink over it, translucent, never a surface of its own. */
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; }
9
+ .desktop button { cursor: pointer; }
10
+ .desktop button:disabled { cursor: default; }
11
+ .desktop button:focus-visible, .desktop select:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
12
+
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
+ .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); }
19
+ .desktop-eyes { display: block; }
20
+ /* With the larger eye away, the smaller one moves to the middle; in user units, 50 - 71.5. */
21
+ .desktop-eye-large { transition: opacity .12s; }
22
+ .desktop-eye-small { transition: transform .22s cubic-bezier(.3, 1.2, .3, 1); }
23
+ .desktop-eyes.is-alone .desktop-eye-large { opacity: 0; }
24
+ .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
+ /* One pill per open window, the one in front filled; pointing at a pill shows its × over its right end. */
37
+ .desktop-pills { display: flex; align-items: stretch; gap: 4px; min-width: 0; overflow-x: auto; scrollbar-width: none; scroll-behavior: smooth; }
38
+ .desktop-pills::-webkit-scrollbar { display: none; }
39
+ /* Where the strip has more beyond an edge, it fades into whatever is behind it there. */
40
+ .desktop-pills.is-faded-left { mask-image: linear-gradient(to right, transparent, #000 32px); }
41
+ .desktop-pills.is-faded-right { mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent); }
42
+ .desktop-pills.is-faded-left.is-faded-right { mask-image: linear-gradient(to right, transparent, #000 32px, #000 calc(100% - 32px), transparent); }
43
+ .desktop-pill { position: relative; display: flex; align-items: center; flex-shrink: 0; max-width: 180px; border-radius: 999px; color: var(--muted-foreground); }
44
+ .desktop-pill:hover, .desktop-pill.is-cursor { background: var(--desktop-ink); color: var(--foreground); }
45
+ .desktop-pill.is-active { background: var(--desktop-ink-strong); color: var(--foreground); }
46
+ /* Every pill is 28px tall, the bar's inner height between its paddings; its icon sits centred in a 22px box, 3px from the top, the bottom and the left, 3px before the text; the × takes the very same box, its circle the box, drawn as lines so it centres exactly. The launcher's rows use the same measures. */
47
+ .desktop-pill-label { display: flex; align-items: center; gap: 3px; min-width: 0; padding: 0 10px 0 3px; border: 0; border-radius: 999px; background: transparent; white-space: nowrap; height: 100%; }
48
+ .desktop-pill-icon { display: grid; place-items: center; width: 22px; height: 22px; flex-shrink: 0; }
49
+ /* The icon and its × trade places with a small bounce: the one arriving grows from half its size past its own and settles, the one leaving shrinks away. */
50
+ .desktop-pill-icon, .desktop-pill-close { transition: transform .26s cubic-bezier(.3, 1.6, .4, 1), opacity .08s; }
51
+ .desktop-pill:hover .desktop-pill-label > .desktop-pill-icon, .desktop-pill:has(:focus-visible) .desktop-pill-label > .desktop-pill-icon { opacity: 0; transform: scale(.5); }
52
+ .desktop-pill-close { position: absolute; top: 3px; left: 3px; display: grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px; background: transparent; color: var(--foreground); opacity: 0; transform: scale(.5); pointer-events: none; }
53
+ .desktop-pill-close svg { display: block; }
54
+ .desktop-pill:hover .desktop-pill-close, .desktop-pill:has(:focus-visible) .desktop-pill-close { opacity: 1; transform: none; pointer-events: auto; }
55
+ .desktop-pill-close:hover { background: var(--desktop-ink-strong); }
56
+ /* Text that does not fit fades at the pill's end and, while pointed at, scrolls past in a loop, both copies moving by one text and one gap. */
57
+ .desktop-pill-text { display: block; overflow: hidden; }
58
+ .desktop-pill-track { display: flex; gap: 24px; }
59
+ .desktop-pill-track > span { flex-shrink: 0; }
60
+ /* The left fade is a registered length so it can ease in: it arrives with the scroll, after the same delay, over a moment, not on the pointer's arrival. */
61
+ @property --desktop-fade-left { syntax: "<length>"; inherits: false; initial-value: 0px; }
62
+ .desktop-pill-text.is-overflowing { mask-image: linear-gradient(to right, transparent, #000 var(--desktop-fade-left), #000 calc(100% - 20px), transparent); }
63
+ .desktop-pill:hover .desktop-pill-text.is-overflowing { animation: desktop-marquee-fade .5s ease-out 1s forwards; }
64
+ .desktop-pill:hover .desktop-pill-text.is-overflowing .desktop-pill-track { animation: desktop-marquee-start .6s cubic-bezier(.33, .66, .66, 1) 1s forwards; }
65
+ .desktop-pill:hover .desktop-pill-text.is-overflowing .desktop-pill-track > span { animation: desktop-marquee var(--scroll) linear 1s infinite; }
66
+ @keyframes desktop-marquee { to { transform: translateX(var(--shift)); } }
67
+ @keyframes desktop-marquee-start { to { transform: translateX(var(--ease)); } }
68
+ @keyframes desktop-marquee-fade { to { --desktop-fade-left: 12px; } }
69
+ @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; }
71
+ /* 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
+ .desktop-app { flex: 1; min-height: 0; overflow: auto; background: var(--background); border-top: 1px solid var(--border); }
73
+ .desktop-app[hidden] { display: none; }
74
+
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; }
78
+ .desktop-search::placeholder { color: var(--muted-foreground); }
79
+ /* Where this level is, at the search row's end, quiet. */
80
+ .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); }
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
+ .desktop-row-main { flex: 1; align-self: stretch; }
93
+ .desktop-row.is-cursor { color: var(--foreground); translate: -6px 0; }
94
+ .desktop-row-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
95
+ .desktop-row-note { padding-left: 8px; font-size: 11px; color: var(--muted-foreground); white-space: nowrap; }
96
+ .desktop-row-window:hover .desktop-pill-icon, .desktop-row-window:has(:focus-visible) .desktop-pill-icon { opacity: 0; transform: scale(.5); }
97
+ .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; } }
102
+ .desktop-icon { display: block; flex-shrink: 0; }
103
+ .desktop-muted { color: var(--muted-foreground); font-size: 11px; margin: 8px 0 8px 6px; }
104
+ /* 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); }
106
+ .desktop-launcher-legend span { display: inline-flex; align-items: center; gap: 4px; }
107
+ .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
+ .desktop-empty { padding: 24px; }
109
+ .desktop-error { padding: 8px 12px; background: var(--muted); color: var(--destructive); }
110
+ .desktop-error button { margin-left: 12px; }
111
+ }
@@ -0,0 +1,50 @@
1
+ // App icons are Lucide's, by name. The framework imports nothing from
2
+ // Lucide: the desktop system reads the icon from the Lucide package the baba
3
+ // installed, through the kernel's files, once, and keeps its drawing in the
4
+ // state as shapes. Only the shapes Lucide draws with are kept, with their
5
+ // geometry and nothing else, so a drawing is data and never markup.
6
+ import type { Fs } from "../kernel/context.ts";
7
+
8
+ export const iconTags = ["path", "circle", "ellipse", "line", "polyline", "polygon", "rect"] as const;
9
+ export type IconTag = typeof iconTags[number];
10
+ /** One shape of a 24×24 Lucide drawing, stroked in the current colour. */
11
+ export interface IconShape { tag: IconTag; attrs: Record<string, string> }
12
+
13
+ const geometry = new Set(["d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "y1", "x2", "y2", "width", "height", "points"]);
14
+ const keep = (pairs: Iterable<RegExpMatchArray>) => Object.fromEntries([...pairs].filter((m) => geometry.has(m[1]!)).map((m) => [m[1]!, m[2]!]));
15
+ const isTag = (t: string): t is IconTag => (iconTags as readonly string[]).includes(t);
16
+
17
+ /** A Lucide icon's name: lowercase words joined by dashes, as lucide.dev writes it. */
18
+ export const iconName = /^[a-z0-9]+(-[a-z0-9]+)*$/;
19
+
20
+ /** The shapes of an SVG file, as `lucide-static` ships them. */
21
+ export function shapesOfSvg(text: string): IconShape[] {
22
+ const out: IconShape[] = [];
23
+ for (const m of text.matchAll(/<([a-z]+)\b([^>]*?)\/?>/g)) if (isTag(m[1]!)) out.push({ tag: m[1]!, attrs: keep(m[2]!.matchAll(/([a-z0-9-]+)="([^"]*)"/g)) });
24
+ return out;
25
+ }
26
+
27
+ /** The shapes of a `lucide-react` icon module, from its `["circle", { cx: "6", … }]` data. */
28
+ export function shapesOfModule(text: string): IconShape[] {
29
+ const out: IconShape[] = [];
30
+ for (const m of text.matchAll(/\[\s*"([a-z]+)"\s*,\s*\{([^}]*)\}\s*\]/g)) if (isTag(m[1]!)) out.push({ tag: m[1]!, attrs: keep(m[2]!.matchAll(/([a-z0-9]+)\s*:\s*"([^"]*)"/g)) });
31
+ return out;
32
+ }
33
+
34
+ /** Where a baba's Lucide icon lives: `lucide-static`'s SVG first, the published format, then `lucide-react`'s module, which every baba the kit made has. */
35
+ export const iconSources = (name: string) => [
36
+ { path: `.baba/node_modules/lucide-static/icons/${name}.svg`, parse: shapesOfSvg },
37
+ { path: `.baba/node_modules/lucide-react/dist/esm/icons/${name}.mjs`, parse: shapesOfModule },
38
+ ];
39
+
40
+ /** Reads one icon's shapes; none when no source has it. */
41
+ export async function readIcon(name: string, fs: Pick<Fs, "read">): Promise<IconShape[]> {
42
+ if (!iconName.test(name)) return [];
43
+ for (const source of iconSources(name)) {
44
+ let text: string;
45
+ try { text = await fs.read(source.path); } catch { continue; }
46
+ const shapes = source.parse(text);
47
+ if (shapes.length) return shapes;
48
+ }
49
+ return [];
50
+ }