← the Atlas

surface-app — the app shell for surface apps

reference · evergreen ·implemented ·

A library for the class of @kolu/surface app that is really a desktop application you run against your own server — fresh delivery, server/build identity, the connection/update lifecycle, and desktop install, all owned by one shell.

A library for a specific class of @kolu/surface app: the ones that are really desktop applications you run against your own server (kolu, drishti, the next one). Where surface is the live reactive wire, surface-app is the app shell around it — delivered fresh, installed like a desktop app, and always aware of its relationship to the server it’s bound to.

Status: implemented · shipped in kolu #1154 (packages/surface-app) · adopted by drishti (srid/drishti#47) · renamed from surface-pwa.

What it is

The class of app it serves

This is not a library for “any web app that can be installed.” It’s for a specific, recognizable shape — the one kolu and drishti share:

That class is almost the opposite of a generic PWA (public, multi-tenant, CDN-served, offline-capable, SEO-shaped) — which is why “pwa” was the wrong word and the name is now surface-app.

LayerWhat it owns
@kolu/surface — the wireThe Cell / Collection / Stream / Event / Procedure lattice. How data reaches the app.
@kolu/surface-app — the app shellFresh delivery · server identity · connection & update lifecycle · desktop install. How the app itself reaches the user and stays in step with the server.

The unifying insight

The contract

Five invariants

#1 is load-bearing; the rest are graceful degradation.

  1. One mutable entry point; everything else immutable. The shell (index.html) is the only never-cached resource (Cache-Control: no-store); every content-hashed asset is immutable; a missing /assets/* hash 404s rather than falling through to the shell. The one document that names the bundle is always re-fetched, so staleness is structurally impossible. (immutable presumes content-hashed names; unhashed shell assets stay no-cache.)
  2. Build identity is first-class and single-sourced — and rides the shell, never a hashed asset. Client and server stamp the same commit; the server exposes it. Bundler-agnostic: server reads SURFACE_APP_COMMIT from env; the client commit rides the no-store shell as window.__SURFACE_APP_COMMIT__ (injected by the surfaceApp() Vite plugin or buildSurfaceClient for Bun, and read via shellCommit()) — never a bundler define baked into a content-hashed /assets/* file. A define would pin the sha inside a year-immutable bundle whose name doesn’t change on a stamp-only deploy, so the rewritten bytes never re-fetch and every returning browser stays stuck on the old stamp, looping the update prompt forever (the bug fixed in #1324 ); the always-fresh shell carries identity instead. The env-var name and the flake-rev resolution are single-sourced for nix consumers in nix/commit-stamp.nix (kept equal to resolveCommit’s DEFAULT_COMMIT_ENV_VAR), so a nix-built client and the server wrapper stamp from the same var — no hardcoded literal downstream.
  3. Skew is visible and recoverable, on every form factor. When client ≠ server, a durable indicator shows and a reload that lands fresh is one tap away — desktop rail and mobile.
  4. A service worker is an opt-in you own end-to-end — or, for this class, none. A SW intercepts navigations in front of the network, so the cache contract (#1) can’t reach it; gate on window.isSecureContext, never location.protocol === "https:". An always-connected app has no offline mode, so surface-app ships no caching worker and actively retires any it finds; the one opt-in is a fetch-less notification worker (installFreshStatic({ serviceWorker: "notify" }) + registerServiceWorker(), #1216 ) that registers no fetch handler, so the cache contract (#1) never sees it — kolu opts in for OS notifications.
  5. The client always knows its relationship to the server it’s bound to. Which host, which build, and the live status (connected / reconnecting / server-restarted / stale-build) are first-class — surfaced as a model the app renders. This is the invariant that makes surface-app an app shell, not just a cache policy.

The old triage rule (normal reload stale, hard reload fresh → a cached shell or a service worker; confirm in the browser, not by reasoning about the origin) is a debugging heuristic, not an encoded invariant — it lives in the library’s README review checklist.

The app shell’s parts

  1. Fresh delivery — cache policy (no-store shell · immutable hashed assets · no-cache sw.js · 404 asset-miss) + static-serve + SPA fallback.
  2. Server & build identity — which host + which build the client is bound to; per-host name/theme; the buildInfo cell (extensible interface).
  3. Connection & update lifecycle — surface-app derives the lifecycle (connecting → connected → disconnected → reconnected / restarted, + stale-build) from the WebSocket transport’s open/close plus a server-identity probe (processId tells a transient drop from a restart) — lifting kolu’s generic rpc.ts wholesale, so kolu deletes its lifecycle layer and drishti gets the WS indicator for free. The app passes the ws handle and renders its own chrome (the header dot, the dim overlay) from useSurfaceApp().status() — no hand-wired connection state. (Commit (skew) and processId (restart) stay distinct axes — buildInfo is commit-only; the restart probe is its own.) Since #1231 the server also gates the WS handshake by processId (rejectStaleProcess / STALE_PROCESS_CLOSE_CODE), so a stale tab is told to reload instead of storm-reconnecting.
  4. Desktop install & feel — the manifest as app-window identity (per-host name/theme/icons) and an attention model that fans out to App Badging (installed Chromium, Win/macOS) → document title (setAttention(n), best-effort, degrades per browser). Install state now ships in the model — isInstalled / canInstallPwa on SurfaceAppModel ( #1199 ); the one-click prompt UI lives in @kolu/solid-pwa-install. A canvas favicon remains a documented future affordance. (Badging needs a trusted secure context; the core works without — see below.)
  5. Service-worker stance — never ship a caching worker; retire legacy ones (retireServiceWorker() + self-destructing sw.js, the default), or opt into the fetch-less notification worker (serviceWorker: "notify" + registerServiceWorker(), #1216 ) for OS notifications.
  6. Commit propagation — one resolver (SURFACE_APP_COMMIT env → git rev-parse --short HEAD"dev") feeding a Vite plugin (which injects the commit onto the no-store shell as window.__SURFACE_APP_COMMIT__, plus its type declaration) and the server cell. The client reads it via shellCommit(); it is never a bundler define in a hashed asset (see invariant #2). No app ever writes a sha literal.

Secure context for the desktop layer (HTTPS)

Two layers, two requirements. The freshness core (delivery, skew over the wire, reload) works on plain HTTP and ws:// — no secure context needed, so kolu on a plain-HTTP LAN keeps working. The desktop-feel layer (install, Badging) is gated on window.isSecureContext, which a self-hosted app reached by bare hostname or private/tailnet IP over plain HTTP does not have. The confirmed facts (citations in cache-bug.md):

So the desktop layer needs a trusted HTTPS origin. The recommended paths:

PathTrusted, no warning?Per-device setupAuto-renewBest for
tailscale serve✓ — real LE cert on *.ts.netnone (every tailnet device)recommended (tailnet)
mkcert / local CA✓ where the CA is installedper devicen/a (~825d)single LAN device
Caddy tls internal✓ where the CA is installedper devicemulti-service LAN
self-signed (@kolu/dev-tls)✗ — warns (click-through)per device, every timeat startuplocalhost dev only

Decisions: (1) surface-app does not provide TLS — cert acquisition is a different volatility (deployment/infra), so it stays out. surface-app feature-detects isSecureContext and, when the desktop layer is unavailable, surfaces an actionable hint (not a hard block — the core still works): “reachable, but install/badge need HTTPS — try tailscale serve.” (2) kolu’s existing self-signed generator (packages/server/src/tls.ts, the selfsigned package) is to be extracted into a tiny optional @kolu/dev-tls for the localhost/dev escape hatch only — a deferred follow-up. (3) The trusted recipes are documented, not implemented. This also closes the saga loop: a real cert via tailscale serve gives a genuine secure context — removing any reason for the Chrome insecure-origin flag that orphaned the service worker in the first place.

The pieces

PieceThe question it answersEntrypointInv.
installSurfaceApp · installFreshStatic · installPwaManifest”Serve the SPA fresh, the manifest, AND /sw.js (retirement, or the opt-in fetch-less notification worker via serviceWorker: "notify") — one composed call.”/server#1,2,4
buildInfoServer() · surfaceAppServer()”The buildInfo cell’s server impl (+ the identity.info probe impl) — the deps bundle for the surface-app sibling entry in implementSurfaces; commit auto-resolved.”/server#2,5
surfaceAppSurface · surfaceAppSurfaceWith”surface-app’s complete standalone surface (buildInfo cell + identity.info probe) — served as a sibling via implementSurfaces / surfaceClients / composeSurfaceContracts.”/surface#2,5
buildInfo · defineBuildInfo”The buildInfo cell definition — composed into the surface-app surface; extensible.”/surface#2,5
useSurfaceApp() · SurfaceAppProvider · retireServiceWorker()”The headless model (status/stale/server/reload/setAttention) + SW retirement. You render the UI.”/solid#3,4,5
surfaceApp() vite plugin · buildSurfaceClient() · resolveCommit()”Build the client fresh: content-hashed /assets/*, the commit injected onto the no-store shell as window.__SURFACE_APP_COMMIT__ (read via shellCommit(), never a hashed-asset define), the no-store shell rewrite — Vite plugin or Bun helper. Resolve the commit once (env→git→dev).”/vite · /bun#1,2
cacheControlFor · clientIsStale · SW_SOURCE”The pure, framework-free kernels.”utils/#1,2

The design historical (merge era)

The headless model

Everything the app shell knows about its server is one hook; the app renders whatever chrome it wants from it. The library provides the model, the apps (kolu, drishti) define the UI — a future PR may consolidate the UI back into surface-app if a shared shape proves out.

const app = useSurfaceApp();   // the relationship to the server you're bound to:
app.status()        // "live" | "reconnecting" | "restarted" | "down"
app.stale()         // boolean — running bundle is behind the server's build
app.server()        // T | undefined — what am I bound to (default { commit }; kolu extends)
app.clientCommit    // string — this bundle's baked-in commit
app.reload()        // land the deployed build
app.setAttention(n) // OS app badge if installed (best-effort) + document.title — degrades per browser

Build-skew is one status among connection states — the insight made concrete. status() is derived by the library from the ws handle (open/close) + a processId probe (reconnected vs restarted) — this is kolu’s rpc.ts lifecycle, encapsulated, so the WS indicator + dim overlay that kolu renders today drop straight into drishti. The shape is extensible: kolu’s server() also carries pty-host info (its second staleness axis); drishti’s is just { host, commit, name }. As shipped, createServerLifecycle({ ws, probe }) derives it in-library; the example proves it end-to-end — live → down → restarted on a real server restart.

Build identity is an interface

What “the build” means is the one thing apps vary. Default is the commit; kolu adds a pty-host axis; the staleness predicate is part of the interface. Apps extend rather than fork:

// default — drishti uses exactly this
export const buildInfo = defineBuildInfo({
  schema: z.object({ commit: z.string() }),
  isStale: (srv, cli) => clientIsStale(srv.commit, cli.commit),
});
// kolu extends — adds the pty-host axis without losing the commit one
defineBuildInfo({ schema: z.object({ commit: z.string(), ptyHost: PtyHostRefSchema }),
  isStale: (srv, cli) => clientIsStale(srv.commit, cli.commit) || ptyHostDiverged(srv.ptyHost, cli.ptyHost) });

The matching server impl is a fragment too: buildInfoServer() (commit auto-resolved) spreads into implementSurface; kolu passes its pty-host source. Definition ⊕ impl are composed, never hand-written in the app.

Compose, don’t hand-wire

The spike’s failing — fairly called out in review — was making the example re-implement what the library should own: a hand-written buildInfo store, a hand-rolled /sw.js route, a hardcoded commit string, a per-app __SURFACE_APP_COMMIT__ define and its type. The fix is the principle surface itself follows: the library ships fragments, an app is their composition, and there is no bespoke glue. Build identity is one concept with composable faces the app stitches together — never re-derives:

FaceLibrary fragmentApp composes…
definitionbuildInfo (cell schema)into defineSurface
restart probeserverIdentity (procedure, surfaceApp.info)into defineSurface
surface mergesurfaceAppSurface / surfaceAppSurfaceWith + composeSurfacesone merge of cell + probe into defineSurface
server implsurfaceAppServer() + implementSurfaceApp()one call (owns buildInfo.connect)
client modeluseSurfaceApp()under <SurfaceAppProvider>
client build + commitsurfaceApp() Vite plugin · buildSurfaceClient() (Bun) · resolveCommit()into the client build & server boot
nix commit stampnix/commit-stamp.nix (envVar · revFromSelf · exportLine)into the flake / derivation / server wrapper

And the commit is resolved onceSURFACE_APP_COMMIT env, else git rev-parse --short HEAD, else "dev" (which clientIsStale already treats as never-stale) — then fed to both the client define and the server cell. No app writes a sha.

Every removed line was the app doing the library’s job; every surviving line composes a fragment. That’s the whole principle.

The API historical (merge era)

One package, sub-path entrypoints mirroring @kolu/surface’s exports map. Pure kernels under utils/. The composeSurfaces / implementSurfaceApp / surfaceApp.info snippets in this section are the superseded merge API — the current sibling API is in Composing surfaces.

/server @kolu/surface-app/server

import {
  implementSurfaceApp,
  installSurfaceApp,
  surfaceAppServer,
} from "@kolu/surface-app/server";
// `implementSurfaceApp` — the server-side counterpart to `composeSurfaces`: ONE
// call merges the fragment's buildInfo cell + `surfaceApp.info` probe impls into
// your own deps, runs implementSurface, and flows `buildInfo.connect` internally.
// The app passes only its OWN cells/procedures — no hand-spread, no seed→connect.
const { router, ctx } = implementSurfaceApp(surface, surfaceAppServer(), {
  channel,
  cells: { /* yours only */ },
  procedures: { /* yours only */ },
});
// one call serves the shell fresh + the manifest + /sw.js (retirement). Granular pieces exported too.
installSurfaceApp(app, { clientDist, manifest: { name: `kolu@${host}`, themeColor, icons } });

/surface — compose the library’s surface @kolu/surface-app/surface

import { surfaceAppSurface, composeSurfaces } from "@kolu/surface-app/surface";
// ONE merge: the buildInfo cell + the `surfaceApp.info` restart probe, composed
// with your own spec. (`surfaceAppSurfaceWith(myBuildInfoDef)` for an extender.)
export const surface = defineSurface(
  composeSurfaces(surfaceAppSurface, { cells: { /* yours … */ }, procedures: { /* yours … */ } }),
);

composeSurfaces ships in @kolu/surface-app (a field-wise merge of two SurfaceSpecs — cells/collections/streams/events shallow, procedures two-level, throwing on a duplicate key). surface-app’s contributions are namespaced under surfaceApp (probe at surface.surfaceApp.info) so they never collide with the app’s own keys — the app wires the cell + probe as one fragment, not two separate spreads.

/solid — behaviour + headless model @kolu/surface-app/solid

import { retireServiceWorker, SurfaceAppProvider, useSurfaceApp } from "@kolu/surface-app/solid";
retireServiceWorker();
// pass your control-plane client + this bundle's baked commit (the bundler define):
<SurfaceAppProvider controlPlane={app} clientCommit={__SURFACE_APP_COMMIT__}> …your app… </SurfaceAppProvider>
// then render your own chrome from useSurfaceApp() — README has the snippets.

No styled components ship: a tailwind app (kolu) and a different-CSS app (drishti) render their own from the same model. controlPlane takes one client; a many-client app passes its control-plane client, since the model is global.

/vite · /bun — the client build, owned upstream @kolu/surface-app/vite · /bun

// Vite path — the plugin resolves + injects the commit
import { surfaceApp } from "@kolu/surface-app/vite";
plugins: [solid(), surfaceApp()]   // injects __SURFACE_APP_COMMIT__ + ships its type — no define, no env.d.ts, no sha literal.

// Bun path — buildSurfaceClient owns hash-naming + the define + the no-store shell rewrite
import { buildSurfaceClient } from "@kolu/surface-app/bun";
await buildSurfaceClient({ entrypoint, distDir, htmlTemplate, entryHtmlPlaceholder, plugins, extraAssets, publicDir });

Both stamp the same commit via resolveCommit (env → git → "dev"). The Bun consumer (drishti) composes buildSurfaceClient rather than hand-rolling Bun.build + hashing + shell rewrite — the content-hashed /assets/* layout (the prerequisite for immutable) is the library’s job, not the app’s. One resolver, one source of truth.

Using it — kolu & drishti

Both are SolidJS + Hono on @hono/node-server, both vendor @kolu/* via npins (zero flake inputs), so adding surface-app is one overlay line each. Same model + same UX; kolu only differs by extending build identity for its pty-host axis.

// drishti — surface: ONE merge of surface-app's fragment (cell + surfaceApp.info probe) + its own
export const adminSurface = defineSurface(composeSurfaces(surfaceAppSurface, { collections: { hosts }, procedures: { hosts } }));
// drishti — server: ONE call composes the surface-app impls (buildInfo cell + surfaceApp.info probe)
// into the app's own deps, runs implementSurface, and flows the cell's connect (commit auto-resolved; /sw.js served by the lib)
const { router, ctx } = implementSurfaceApp(adminSurface, surfaceAppServer(), { channel, collections: { hosts }, procedures: { hosts } });
installSurfaceApp(app, { clientDist, manifest: { name: "drishti", themeColor: "#0e7490", icons } });
// drishti — vite: one plugin resolves + injects the commit
plugins: [solid(), surfaceApp()]
// drishti — client: provider (this bundle's baked commit) + your own chrome from useSurfaceApp()
retireServiceWorker();
<SurfaceAppProvider controlPlane={adminClient} clientCommit={__SURFACE_APP_COMMIT__}> <Header>drishti <DrishtiStatus/></Header> </SurfaceAppProvider>

Zero hardcoded commits, defines, stores, or /sw.js routes — every line composes a library fragment. kolu is identical but passes its pty-host source to buildInfoServer + the extended defineBuildInfo. To see skew in dev, boot the server with SURFACE_APP_COMMIT=<other> — a real deploy-simulating override, not a sha baked into the client.

drishti’s adoption (srid/drishti#47) landed the content-hashed Bun.build prerequisite first — its old Bun.build emitted unhashed filenames (main.js), which can’t be cached immutable (#1) — then sourced @kolu/surface-app hermetically through Nix and ported the identity rail onto the shared model.

UI — example, not shipped

The library ships the headless model, not styled components. These are the chrome you’d build from useSurfaceApp() — kolu in tailwind, drishti in its own CSS. The README carries the wiring.

A server-identity rail — host · build, live status, and skew + reload as one state. A single row showing the server build (d5aed3c) versus the client build (617b80d), a durable ≠ srv chip when they diverge, and a ⟳ Reload button:

● SRV d5aed3c · CLIENT 617b80d   [≠ srv]   ⟳ Reload

A prominent one-tap recovery prompt (durable, not a transient toast):

⟳  A new version is available
   This tab is running an older build (617b80d → d5aed3c).        [ Reload ]

On mobile the rail collapses to the chip + reload; the prompt becomes a bottom banner. Both are built from the same useSurfaceApp() model, so kolu’s reflect pty divergence and drishti’s reflect commit skew with no change to the library.

Composing surfaces — multiple surfaces, one transport

Status: implemented · #1201 · kolu#1197 · dissolves the four composition seams above without touching SurfaceSpec. surface-app shrinks; only a thin plural layer is added to surface core.

The XY unwind

The issue (#1197) prescribed nested sub-surfaces (mounts) — recurse a whole Surface inside SurfaceSpec. That solves the stated Y (one merged spec) but at the cost of making the core spec recursive and changing every derivation. The real X — a library can’t be handed over as one unit because its cell is flat and its probe is two-level — dissolves once you stop merging: a standalone surface is one unit, and “compose” just means “serve more than one.” SurfaceSpec, defineSurface, the contract derivation, the client proxy, and the channel-key construction are all untouched.

The seam

Each surface is defined exactly as today — no new field. surface core gains a thin plural layer — composeSurfaceContracts (contract), implementSurfaces (server), surfaceClients (client) — each reading one shared keyed surfaces map, so the keys can’t drift across the three.

// each is a normal, standalone surface — ZERO change to defineSurface / derivation / client proxy
const surfaceAppSurface = defineSurface({ cells: { buildInfo }, procedures: { identity: { info } } });
const adminSurface      = defineSurface({ collections: { hosts }, procedures: { hosts } });

// ONE keyed map — the single (browser-safe) source of which surfaces exist under which keys
const surfaces = { surfaceApp: surfaceAppSurface, admin: adminSurface };
const contract = composeSurfaceContracts(surfaces);   // → { surface: { surfaceApp, admin } }

// SERVER — reuse `surfaces`; add only the server-only deps, keyed the same way (no { surface, deps } wrapper)
const { router, ctx } = implementSurfaces(surfaces, { channel }, {
  surfaceApp: surfaceAppServer(),   // the lib's deps bundle; its async buildInfo connect fires in the runtime
  admin:      adminImpl,
});

// CLIENT — reuse `surfaces`; one connection split into a per-key client bundle
const c = surfaceClients(link, surfaces);
c.surfaceApp.cells.buildInfo.use();   // surface.surfaceApp.buildInfo
c.admin.collections.hosts.use();      // surface.admin.hosts...
app — declares standalone surfacesimplementSurfaces / surfaceClients (thin plural layer)one transport (WS) — multiplexedsurfaceAppSurface (buildInfo cell + identity probe)adminSurface (hosts collection + procedures)keys each surface by its registration namenamespaces each surface's channel bus by keysurface.surfaceApp.buildInfo · surface.surfaceApp.identity.infosurface.admin.hosts... registered under 'surfaceApp'registered under 'admin'one router, one ctx, keyed
Independent surfaces are multiplexed over one transport; the plural layer keys each by its registration name. SurfaceSpec and every derivation are untouched.

What it dissolves

Seam todayAfter
composeSurfaces / ComposedSurfaceSpecgone — no merge; the surfaces are siblings
assertNoOverride dup-key throwgone — each surface is its own top key; nothing to collide
implementSurfaceApp()goneimplementSurfaces(surfaces, base, deps) takes each surface’s deps in a keyed map; surfaceAppServer() survives as surface-app’s per-key deps bundle
app-visible connect (republish late buildInfo)goneconnect? is a cell-impl dep the runtime fires once after wiring

The plural layer wraps the existing singular walk once per surface, handling the two real mechanics:

// 1. namespace each surface's channel bus by its key so two surfaces' `buildInfo:changed` can't collide
walkSurface(t.surface[key], surface, { ...deps, channel: (n) => baseChannel(`${key}/${n}`) })
// 2. key each surface's router + ctx under its registration name (instead of the hardcoded single `surface`)

surface-app’s /surface and /server entrypoints lose composeSurfaces, surfaceAppServer, implementSurfaceApp, assertNoOverride; the surfaceAppSurface definition + its server impl stay (now served as a sibling). The headless model, installSurfaceApp, and the client build are untouched.

Why this is the right boundary

Why the wire break is free

Wire paths gain the surface key (surface.buildInfosurface.surfaceApp.buildInfo; channel buildInfo:changedsurfaceApp/buildInfo:changed). A hard break for a long-lived client — except this is the one app class where client and server ship together and a skewed client is designed to reload to the deployed build (invariant #1). No compat shim: kolu, the surface examples, and drishti all re-derive in lockstep with the server they ship beside.

Scope & migration

A @kolu/surface change that adds composeSurfaceContracts + implementSurfaces + surfaceClients and leaves SurfaceSpec / defineSurface / the contract derivation / the client proxy alone. All three read one shared keyed surfaces map. Then every consumer splits its one merged surface into siblings:

A pure refactor in behavior — no on-screen change, no new user-facing surface — so existing surface + surface-app tests are the safety net (extended to cover the plural layer’s per-key channel namespacing and router/ctx keying).

Rationale & status

What it absorbs

Why no offline / no service worker

For this class a caching worker is definitionally wrong, not an opinion — and the rationale ships so the next engineer doesn’t re-add one. The sanctioned exception is the fetch-less notification worker ( #1216 ): it registers no fetch handler, so the interception downside below can’t reach it; the lifecycle liability still applies, which is why surface-app owns the worker end-to-end (it self-destructs any caches it finds).

Direction

Name

@kolu/surface-app — “the app shell for a surface wire.” Dropped “pwa”: it connotes offline / installable-for-offline, the opposite of this always-connected class.

Phasing

  1. Build @kolu/surface-app (packages/surface-app in the kolu monorepo) — /server, /surface, /solid (behaviour + headless useSurfaceApp()), /lifecycle, /vite, utils/, commit-stamp helper, and the desktop-feel affordances behind the secure-context gate. Rewired kolu, extending build identity for pty-host. Shipped the README (no-SW rationale + triage checklist + UI-wiring snippets). Registered in the electricities tracker. done — kolu #1154 .
  2. Adopt in drishti (separate PRs, drishti repo) — first a small PR for content-hashed Bun.build output; then port drishti onto the library (default identity, same UX). donesrid/drishti#47.
  3. Extract @kolu/dev-tls — pull kolu’s self-signed generator (packages/server/src/tls.ts) into a tiny optional package for the localhost/dev escape hatch; document the trusted recipes (tailscale serve / mkcert / Caddy). surface-app itself only feature-detects the secure context and hints. deferred
  4. Blog post — sibling to surface-framework, seeded by cache-bug.md. deferred
  5. Compose surfaces as siblings in @kolu/surfaceimplementSurfaces / surfaceClients serve a keyed map of standalone surfaces over one transport, so surface-app is just a sibling surface and composeSurfaces / implementSurfaceApp / the dup-key throw / the app-visible connect all dissolve — with no change to SurfaceSpec. A thin plural layer on surface core (paired drishti adoption to follow). Plan of record: Composing surfaces. implemented — kolu #1201 · kolu#1197.

Origin: #1149 . Model: @kolu/surface. Bug saga + design history (incl. the surface-pwa → surface-app pivot): cache-bug.md. Sibling: electricity.html. · Shipped 2026-06-04.