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

8 KiB

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)

// 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)

// 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).