pi-mono/packages/chord/docs/slice-11.md
Mario Zechner e9c0ff4d84 feat(chord): Chord v2 is the package's root
src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins,
/plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch;
Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/,
test/old, docs/old). Durable and env import the foundations from their subpaths.
2026-11-02 22:36:55 +00:00

114 lines
8 KiB
Markdown

# Slice 11: web and TUI surfaces (spec)
Status: **approved** (owner, 2026-10-30/11-01: P1-P9 in the decisions "Slice 11's points" and "Slice 11, P3" to "P9",
then "go ahead, just do all the things"). Built after slices 12 and 13: the surfaces are generic plumbing that the app
kit wires in (`startApp` serves the web surface; a TUI client entry). The app keeps everything visible: HTML shell,
CSS, components, themes, navigation, layout, keys.
Read in full before writing code: this spec; `decisions.md` (slice 11's entries, the plugin model, the app kit);
`design.md` §3 (clients), §21, §23 (all, "Web and TUI" included); `slice-10.md`, `slice-12.md`, `slice-13.md`; p2's
`src/hosts/web.ts`, `src/hosts/web/main.tsx`, `src/web/reconnect.ts`, `src/runtime/web.tsx`, `src/hosts/bundles.ts`,
`src/hosts/tui.ts`, `src/hosts/tui-client.ts`, `src/hosts/tui-sandbox.ts`, `src/hosts/tui-bootstrap.mjs`,
`src/hosts/tui-worker-runtime.ts`, `src/runtime/tui.ts` (`TuiScope`).
## 1. `chord/surfaces` (the web surface's server side)
```ts
// inside startApp (slice 13), or by hand:
const web = webSurface(server, {
kind: "web", plugins,
entry: "./src/web/main.tsx", // the app's web start code: the base bundle's entry
page: (request, assets) => shell(request.nonce, assets), // the app's HTML; assets: base script, plugins file, styles
}, ctx);
// its facet (routes to Http, the WebScripts object) goes into the server's core facets
```
- **Base bundle** per client kind: built on the server by esbuild from `entry`, with the kind's host packages inlined
once (the app's API modules for the kind, Chord, React), content-hashed (`/_chord/web/base.<hash>.js`), cached
forever (`Cache-Control: immutable`, overriding the baseline's `no-store` for these routes only); rebuilt when base
code changes (the server restarts anyway: built at start).
- **Plugin scripts:** each web facet served as a script that registers itself (`chordFacet("<plugin>", "<digest>",
function (require, module, exports) { … })`, P2), at `/_chord/web/plugin/<plugin>.<digest>.js`, immutable; the
current set as one combined file `/_chord/web/plugins.<set-hash>.js` (P3); stylesheets and assets of the build
likewise, under `publicPath`.
- **`WebScripts`** (a `remote` object the surface serves, params `{}`, any principal that may load the app): its state
is the current set `{ plugins: [{ name, digest, url, css? }], base }`; a rollout that ships the web kind updates it
(the plugins component's `scripts("web")` changes; the surface follows them) without waiting for pages (P5).
- Routes and the page are `Http` contributions of the surface's facet; the page route is `page: true` (navigation from
another site allowed) and public or not as the app says.
## 2. `chord/web` (browser side)
```ts
// src/web/main.tsx (the app's entry, in the base bundle)
startPage<P>({
connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`),
modules: { "@acme/app/v3": api, "@acme/app/v3/web": apiWeb, react: React, "react/jsx-runtime": jsx },
core: [{ name: "@acme/web", facet: webCore }],
canRetry: async () => true,
onConnection: (state) => setBanner(state),
render: (host) => createRoot(document.getElementById("root")!).render(<HostProvider host={host}><App /></HostProvider>),
});
```
- A client host (no address); the connection loop (P6, p2's `keepConnected`: reconnect 250 ms to 5 s, wake on
visibility, `online` and network change, replace a silent socket, stop on an outdated protocol (reload the page) or
when `canRetry` says no); `onConnection` reports `connecting | connected | waiting`.
- Loads the combined plugins file, evaluates each registered facet with `modules` (the `require` they get answers only
host packages and `@earendil-works/chord/plugins`'s browser half if any), loads them with `core` in one
`host.load`, then holds `WebScripts`: on a new set it fetches the changed scripts (`<script src>`, one each), and
calls `host.reload` with the complete set (P4: in place; a failed plugin keeps its old facet, reported to the app);
a changed `base` hash reloads the page.
- CSP: scripts are `'self'` plus the nonce for the page's inline bootstrap; no `eval`.
## 3. `chord/react`
`HostProvider`, `useHost()`, `useHold(Type, params)` (a reference held for the component's life, released on unmount,
re-held when params change), `useWatch(watchable)` (the current value, re-rendering on change; `useSyncExternalStore`),
`useRegistry(R)`, `useRefState(ref)`. A component unmounted by a reload has not left (design §21): releases are plain
releases, presence is the app's. No components, no styling.
## 4. `chord/tui` and `chord/tui/launcher`
- **Launcher** (trusted, a Node process): `startTerminal({ url, token | tokenFile, pairing?, ... })` connects (a token
from the owner token file, slice 13, or device pairing), downloads the base bundle and the TUI kind's plugin scripts
over HTTP, and starts the **sandbox**: a child process on pipes (decision "The terminal's sandbox is a child
process", replacing p2's worker thread: empty environment, Node's permission model granting nothing, no SQLite; no
terminal, file system, child processes or network except the bridge), always (P8), whose runtime gets a
bridge terminal (input events in, frames out), the Chord link (messages relayed by the launcher) and the evaluated
scripts. The launcher keeps credentials, the real terminal and local preferences, and exposes a small set of actions
to the sandbox (clipboard, open URL), each confirmed by the launcher's own UI when it touches the machine.
- **Runtime** (inside the sandbox, in the base bundle): a client host like the page's, the same connection loop over
the bridged link, in-place reloads through a `TuiScripts` object as the web.
- **`TuiScope`** (P9): `new TuiScope(host, changed)` with `hold`, `watch`, `registry`, `dispose`; no pi-tui dependency.
## 4b. Looks and themes (decision "Looks and themes", `docs/theming/`)
- The kit ships `Themes` (`defineRegistry<Theme, readonly Theme[]>`, in the shared module the kit's apps re-export),
`Theme = { id, name, mode: "dark" | "light", colors: { accent, bg, fg, muted, error, warning, success, ...app roles },
web?, tui? }`, and a `themes` preference per user (`{ dark, light }` theme ids) stored by the kit's users plugin or a
local object for single-user apps.
- Web: `startPage` watches `Themes` and the preference with `prefers-color-scheme`, and writes the active theme as CSS
variables on `:root` (`--accent`, ..., plus `web` extras), with no reload; the base bundle's stylesheet is the app's.
- TUI: `TuiScope`'s host gets a `theme` lookup by role (true colour, else the nearest of 256 colours, `tui` overrides
first).
- CSS modules (`*.module.css`) work in plugin builds (esbuild's local-css loader); documented in `plugin-authoring.md`.
- Version 1 of `Themes`; a later role is added by a version bump mapping old entries (missing roles from the default
theme).
## 5. In the app kit
`startApp` gains `web: { entry, page }` and `tui: { entry }` (optional); with them it creates the surfaces and adds
their facets. Without, an app has no web or TUI surface (an API-only app).
## 6. Tests, examples
- One test file per source file. Web server side: the base bundle (hash, immutable, rebuilt per start), plugin
scripts and the combined file (registration wrapper, no eval, digests), `WebScripts` updated by a rollout, the page
route and CSP. Browser side under jsdom or a real browser (the repo's browser smoke setup, if it fits): the loop
(backoff, wake events, outdated protocol, `canRetry`), loading the combined file, an in-place reload keeping a
component's state, a base change reloading. React hooks (hold released on unmount, watch re-render, registry).
TUI: the sandbox cannot read a file or spawn a process (a test plugin that tries), the bridge, an in-place reload,
`TuiScope` disposal; pairing a TUI client against a kit app.
- Examples: `56-web` (a kit app with a page, two web plugins, an edit applied in place), `57-tui` (a kit app with a
TUI client, sandboxed), `58-themes` (a theme plugin restyling a page and a TUI live).