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.
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'sno-storefor 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, underpublicPath. WebScripts(aremoteobject 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'sscripts("web")changes; the surface follows them) without waiting for pages (P5).- Routes and the page are
Httpcontributions of the surface's facet; the page route ispage: 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,onlineand network change, replace a silent socket, stop on an outdated protocol (reload the page) or whencanRetrysays no);onConnectionreportsconnecting | connected | waiting. - Loads the combined plugins file, evaluates each registered facet with
modules(therequirethey get answers only host packages and@earendil-works/chord/plugins's browser half if any), loads them withcorein onehost.load, then holdsWebScripts: on a new set it fetches the changed scripts (<script src>, one each), and callshost.reloadwith the complete set (P4: in place; a failed plugin keeps its old facet, reported to the app); a changedbasehash reloads the page. - CSP: scripts are
'self'plus the nonce for the page's inline bootstrap; noeval.
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
TuiScriptsobject as the web. TuiScope(P9):new TuiScope(host, changed)withhold,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 athemespreference per user ({ dark, light }theme ids) stored by the kit's users plugin or a local object for single-user apps. - Web:
startPagewatchesThemesand the preference withprefers-color-scheme, and writes the active theme as CSS variables on:root(--accent, ..., pluswebextras), with no reload; the base bundle's stylesheet is the app's. - TUI:
TuiScope's host gets athemelookup by role (true colour, else the nearest of 256 colours,tuioverrides first). - CSS modules (
*.module.css) work in plugin builds (esbuild's local-css loader); documented inplugin-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),
WebScriptsupdated 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,TuiScopedisposal; 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).