Design: Std.App — one builder, one --target, unifying Sky.Live / Sky.Spa / Sky.Tui / Sky.Cli / Sky.Webview

Status: SHIPPED on branch feat/unified-app-builder (2026-08-25). This document is the design record — early sections retain the exploration (including superseded ideas like --html/--wasm, which the single extendible --target replaced; and the 2-param App sketch, superseded by App fallback seed page model msg). For the current, live API, read docs/skyapp/overview.md and sky doc Std.App. The "Implementation status" + "roadmap" sections below track what shipped (incl. App.withRequest) vs the design rationale.

The question it answered: can the app-shape variants become ONE app builder whose target is a build choice (--target family[:variant]), instead of a code choice (which module you import)? Yes.

Long-term: once Std.App is complete and rolled out, the per-shape front doors (Live.app, Spa.app, …) are deprecated in favour of Std.App alone — see docs/design/std-app-consolidation-roadmap.md (a gated PLAN; nothing is deprecated yet).

1. The problem — too many front doors

Today a user picks the shape at the code level, at the entry point:

ShapeEntryDelivery
Sky.LiveLive.app cfgserver-driven web
Sky.SpaSpa.app cfgclient-wasm web (+ desktop/iOS/Android)
Sky.TuiTui.app cfgterminal
Sky.CliCli.program cfgstdin/stdout
Sky.WebviewWebview.app cfgdesktop

Five imports, five entry points, five mental models to choose between before you write a line. Switching target means rewriting main and re-reading a different module's docs. New users bounce off the choice ("Live or Spa? what's the difference?") before they've built anything. The variants read as five frameworks; they are really one framework with five deployment shapes.

2. The observation — they are already the same program

The configs are near-identical (checked in the stdlib source):

-- all five, core fields IDENTICAL:
{ init          : flags -> ( model, Cmd msg )
, update        : msg -> model -> ( model, Cmd msg )
, view          : model -> Element msg      -- Std.Ui Element, renders everywhere
, subscriptions : model -> Sub msg
}

Everything else is an additive, target-relevant field:

And Std.Ui.Element already renders to web (Sky.Live), terminal (Sky.Tui) and desktop (Sky.Webview) — the pinned cross-platform view layer. So the view is already target-agnostic. The variants are not different programming models; they are the same Model / Msg / update / view with different runtimes.

3. The three real axes

What actually varies is a 3-tuple, and the five variants are just named points in it:

  1. Execution location — where the loop + effects run: server (Live) · client-wasm (Spa) · local process (Tui / Cli / Webview).
  2. Render backend — how view becomes pixels/cells/bytes: server-HTML-over-SSE · client-wasm-DOM · native-webview · ANSI · text.
  3. Delivery target — where it ships: browser · terminal · desktop · iOS · Android · tablet.

Most of the 3-tuple space is invalid or collapses to one obvious choice, which is exactly why it should be defaults + a couple of flags, not five modules.

4. Proposal — ONE extendible --target; everything else is derived

Decision (2026-08-25, @anzel): drop --html / --wasm. There is exactly ONE axis the user chooses — the target — a small, extendible, hierarchical enum. Execution model and renderer are derived from the target, so there is no second flag to get wrong and no invalid combination to reject. One entry point:

import Std.App as App

main =
    App.app { init = init, update = update, view = view, subscriptions = subs }
        |> App.withRoutes [ App.route "/" Home ]    -- capability: routing (web)
        |> App.withWindow (App.window "My App")      -- capability: window (desktop)
        |> App.withInput onEvent                     -- capability: input (terminal)
sky build --target web             # server-driven HTML + SSE   (safe web default)
sky build --target desktop         # native window
sky build --target mobile          # native app, on-device
sky build --target tablet
sky build --target terminal        # TUI
sky run   --target terminal:cli    # text / stdin loop

Targets form a shallow hierarchy — a family, and an optional platform under it via ::

--targetplatform (family:platform)execution model (derived)= today
web—server-driven HTML / SSESky.Live
desktopmac · windows · linuxnative window (webview)Sky.Webview
tabletipad · android · windowson-device wasmSky.Spa shell
mobileios · androidon-device wasmSky.Spa shell
terminaltui · clilocal processSky.Tui / Sky.Cli

--target mobile:ios, --target desktop:mac, --target terminal:cli. The family carries the execution model; the platform picks the concrete SDK / shell. Adding a platform (desktop:bsd, a new mobile OS) is one enum entry — extendible by construction. Adding a family (watch, embedded) is a new runtime + one entry; the flag surface never grows.

Mode is derived, not chosen. web is server-driven; the native families (desktop / tablet / mobile) run on-device; terminal is a local process. The one case a user might want a wasm client in a browser (offline PWA) is web-delivery + on-device execution — model it as a platform, --target web:offline (or web:pwa), not a resurrected --wasm flag. It stays inside the single axis.

Because there is exactly ONE axis over a validated hierarchy, you cannot mix the wrong flags — there is no second flag, and --target web:ios (a platform that belongs to a different family) is rejected at parse time with "did you mean mobile:ios?".

5. Mandatory per-target config — one capability vocabulary, checked at build

The DX wart @anzel named: terminal needs an input handler, web needs routes, desktop needs a window — mandatory config that DIFFERS per target, and today is three different onKey/onLine/routes shapes. The fix is a capability model: builders add capabilities, each target requires a set, and the build refuses a missing one — with a copy-pasteable fix.

Rules that make it consistent + mixing-proof:

The four things fall out of this: one axis (no bad combos), a validated hierarchy (no bad platforms), a capability check (no missing mandatory config), and — with App.targets — all of it verified before you even build.

6. The one genuine fork: web-html vs web-wasm (= "split or not")

The hardest question (@anzel): for web, html or wasm — split or not? State it sharply: "html vs wasm" and "split or not" are the same question — where the update loop runs — and it forks for exactly one target: web. Everywhere else the answer is forced, so it is never a user choice:

targetloop runssplit?why it's forced
mobile / tablet / desktopon device (wasm)alwaysa phone can't run your Db.query; effects MUST route to a backend
terminallocal processneverthe process holds the filesystem / DB; nothing to split
webserver OR clientderived from thata browser is the one place both models are legitimate

So split is derived, never a flag: server-driven ⇒ no split (the server runs everything), client ⇒ split (loop on the client, effects to a backend via the existing spa-split). You pick a target; the split follows.

That leaves web as the sole family with a real two-way choice — so give it two values under the single axis, not two flags:

The naming does real work: web = a website, web:app = a web app. Nobody is surprised that web:app is the richer, client-side, installable one, and it keeps the fork inside the one axis. No --html, no --wasm, no --split — the single most-confusing pair of flags is gone, replaced by one sub-target on the one target where the choice is real.

The payoff of auto-split: the same source compiles both ways. An app with a Db.query in an update branch builds as web (that branch runs server-side) or web:app (that branch becomes an RPC) with no code change — so a team can start server-driven for SEO and add an offline web:app build later without a rewrite. "Split or not" stops being an architecture you commit to in code and becomes a build target you pick per deploy.

7. Why terminal doesn't fork like web — split is a sandbox, not a backend

Natural question (@anzel): a TUI could call a remote backend too — an AI TUI hitting a remote API — so shouldn't terminal have a splitting terminal:app like web:app? No, and the reason pins down what auto-split actually is. Three things get conflated; only one is a target choice:

  1. Renderer / delivery — HTML, wasm-DOM, native window, ANSI, line-text. Set by the family.
  2. Execution location (split or not) — forced by whether the family runs in a sandbox that forbids direct effects. Browser wasm has no DB driver and no filesystem, so a Db.query in your update MUST be lifted to a backend — that lifting is auto-split. The server and a local process hold the DB driver and the filesystem already; nothing to lift.
  3. Calling a remote API — Http.post to some service. ANY app on ANY target can do this. A normal effect, not a target choice, not auto-split.

The AI-TUI-calls-a-backend case is #3, not #2. The TUI's whole loop runs locally in the terminal; it makes an HTTP call the same way a web server or a mobile app does. Nothing is split, because nothing is sandboxed away — a terminal can already do everything.

Auto-split is forced by a sandbox, never chosen because "I have a backend." Only the browser / native-wasm families are sandboxed, so only they split. That is why the fork lives on web (and is implicit for mobile / tablet / desktop, which are always client-sandboxed) and never on terminal. Terminal's variant is a renderer, its one genuine axis:

(A pure one-shot main = Task.run cmd isn't a TEA loop and doesn't go through App.app at all — sky build handles it directly. The target model is for interactive apps.)

8. Backend-or-not is adaptive across every client family — no web:spa

Does web:app become "just an SPA" when it needs no backend? Do mobile/desktop apps need a backend at all? One target per family; whether a backend exists is derived, not declared — and it's uniform across web:app, mobile, tablet, desktop. Auto-split inspects the effects your update actually uses and routes each to the nearest place it can run:

So the generated backend is the minimal set of effects that cannot run client-side — and what "cannot" means widens as the sandbox tightens (browser ≫ native device). If that set is empty, there is no backend at all. --embed is the opposite end (bundle PostgreSQL into a self-contained server) for when you do want one.

Forcing web:spa (client-only) vs web:app (client+backend) — or the equivalent for mobile — would make the user commit up front to whether their effects need a server, which they often don't know and which changes the moment they add a feature. The compiler decides and says so: web:app: no server-side effects — deployable as static files, or mobile:ios: 2 effects run on-device, 1 backend endpoint generated.

(Refinement, later: a project that MUST stay backend-free — a CDN static site, a fully-offline native app — can opt into a guard that fails the build if any effect would introduce a backend, e.g. [web] backend = "forbidden". An assertion on the derived result, not a separate target.)

8b. Cross-compilation — nearly free, because Sky → CGO-free Go

Targets map almost 1:1 onto Go's GOOS/GOARCH matrix, and pure Sky emits CGO_ENABLED=0 Go, so a static binary for another OS/arch cross-compiles from any host:

targetcross-compiles from any host?caveat
web (server), terminal:*, desktop:* binary✅ static Go binary—
web:app, mobile / tablet wasm frontend✅ GOARCH=wasm—
--embed (bundled PostgreSQL)⚠️ needs the target's PG bundle fetchedthe Sky binary still cross-compiles; the bundle is per-OS
mobile:ios signed .ipa❌ needs macOS + XcodeApple platform reality, not a Sky limit
mobile:android .apk✅ mostly (Android SDK)—

Honest caveats: a CGO-based Go FFI dep (sky add) breaks the clean cross-compile (pure-Go deps are fine), and iOS signing inherently needs a Mac. Otherwise sky build --target desktop:windows from a Mac, or --target mobile:android from Linux, Just Works — the Go backend is doing the heavy lifting.

8a. The unifying rule — family[:variant]

variant is the one irreducible choice a family can't infer for you:

familyvariant meansvaluesbare family builds
webexecution modeappserver-driven (the website)
mobileOSios · androidboth stores
tabletOSipad · android · windowsall
desktopOSmac · windows · linuxhost OS
terminalrenderertui · clitui

The variant is not the same category across families, but it is always "the thing you must state because it's a genuine product/deploy choice the compiler cannot make for you." At the point of use there is never ambiguity — web: completes to only app, mobile: to only ios/android, terminal: to only tui/cli — and web:ios / terminal:mac are rejected at parse time. Extensible (a new OS is one enum entry; a new family is a new runtime + one entry) and invalid combinations are impossible by construction, because a variant exists only under its family.

9. What each flag does to the same source

The value of the unification is that one source compiles every way, and the runtime differences are mechanical:

7. Outliers and tensions (the honest part)

  1. Cli is the genuine outlier — resolved by an Element→text adapter. The wiring survey confirmed Cli/Tui.program consume a String view with no Element path, while Tui consumes Element directly and Live/Spa/Webview consume Ui.layout [] element (Html). To keep one user-facing view type (Element msg) across every target, Std.App renders the Element per backend: identity → Tui, Ui.layout [] → the HTML family, and a small Element→text walk → terminal:cli. The user always writes view : model -> Element msg; Std.Cli.program (native String view) stays as the low-level escape hatch.
  2. Effect-location parity is the main risk. An app authored + tested under --html (effects server-side) and then built --wasm gets its effects split to RPC. Behaviour must be identical across the two. spa-split already guarantees this and has a gate corpus, but it is the surface where a "compiles every way" promise is most likely to leak. Any unification must treat cross-mode parity as a first-class, gated invariant.
  3. Invalid combos. --target terminal --html (server-driven terminal) is nonsense. The builder must reject invalid target × mode combos at build time with a one-line "did you mean" — not silently pick something.
  4. One config type with optional fields risks becoming a grab-bag. The builder pattern (App.app core |> App.withRoutes … |> App.withWindow …) keeps the core minimal and makes each optional field self-documenting + target-checked, rather than a wide record with half the fields ignored per target.
  5. One user-facing view type, per-backend adapter inside. The user writes exactly one view : model -> Element msg. Internally the backends do NOT all consume the same slot (survey): Tui takes the raw Element, Live/Spa/Webview take Ui.layout [] element (Html), Cli takes text — so Std.App owns the adapter per backend (§ Implementation). Std.Html stays the escape hatch for raw markup, not a second app model.

Full unification roadmap (confirmed 2026-08-25 with @anzel)

The endgame: the user writes ONE Std.App app and NEVER imports or chooses Std.Live / Std.Spa / Std.Tui / Std.Cli / Std.Webview. Entry form is an explicit main = App.run app; --target (optional) picks the backend.

Confirmed target → backend map ("delivery = family, native = platform"):

--targetbackendbuild
web · tabletSky.Live (server / responsive)plain binary
desktopSky.Live + a native window (webview shell over the server)plain + shell
web:app · desktop:{mac,windows,linux} · tablet:{ipados,android} · mobile:{ios,android}Sky.Spa (wasm, auto-split) + native shellspa-split
terminal:tui · terminal:cliSky.Tui / Sky.Cliplain binary

So bare-family = a Live/web-like delivery; naming a platform = a true native build. Almost everything is Live or Spa; Webview becomes a shell, not a distinct app model.

Steps (all ADDITIVE — none edits spa_partition, so existing Std.Spa/Live apps and their split path are untouched):

  1. main = App.run app + optional --target — SHIPPED. App.run is a real function; the build rewrites App.run → App.run<Backend> for the resolved target (DCE still prunes). Replaces the magic no-main dispatched form.
  2. Taxonomy remap — SHIPPED. std_app_runner maps the table above: bare web/tablet → runLive, bare desktop → runLiveWindow (the new Live-in-a-native-window mode — Task.parallel [runLive, sleep → Webview.url localhost], so the app is served AND shown in a native window), named platforms (desktop:os / tablet:os / mobile:os / web:app) → runSpa. runLiveWindow is build-verified (it needs a display to run; DCE links rt.Live_app + rt.Webview_url).
  3. Spa subsumption — SHIPPED. For the Spa targets, the build synthesises a Spa.app entry from the App.app value and feeds the EXISTING, UNCHANGED auto-split — closing the client-target gap without touching the proven splitter (the risk the user fenced off for live apps).
  4. App.withRequest — SHIPPED. Intent (met): make init's seed portable, the Live HTTP request an explicit capability. Rather than touch the live-traffic web runtime's init path (the fenced-off risk), it is done entirely in the App layer: runLive synthesises the Live init so it calls a.init () (portable — the app never reads the request through its seed) and, when App.withRequest is set, applies the (Request -> model -> (model, Cmd msg)) hook to the model BEFORE the first render. The Request is built from the seed record the runtime already hands init (live.go handleInitial), so raw Std.Live is untouched. withRequest fixes the app's seed to () (init : a -> … / init : () -> …), which is the portability contract. One runtime fix rode along: coerceReflectArg now rebuilds a Dict String String map element-wise, so the request's headers/params/cookies survive the map→record coercion (they silently arrived empty before — the same latent bug affected raw-Live "read a cookie at init"). See docs/skyapp/overview.md.

Capability model — mandatory config, type-enforced (phantom flag)

The DX goal: a minimal shared core, uniform mix-and-match withX builders, and mandatory per-target config enforced by the type — while optional config stays optional. Today there is exactly one mandatory capability: Live requires a notFound fallback page (Std.Live.config demands it, with no constructible default; every other backend field is optional or has a default). So:

Empirically validated in Sky: the flag threads + flips through a builder chain with no type annotations (HM generalises it), and a missing flag is a clean type mismatch: HasFallback vs NoFallback at the call site.

Two consequences the design must handle (both resolved):

  1. sky check is target-scoped, and checks what sky build builds. A dispatched entry's sky check verifies exactly the runner a sky build of the same command line would build: the --target, else the sky.toml [app] target, else web (via runLive, enforcing the fallback). A terminal-only app pins [app] target = "terminal:cli" (or "terminal:tui"), so it is not forced to add notFound. (An earlier bare check used the least-demanding runTui runner; it passed apps a bare build then rejected, breaking check ≡ build.)
  2. The dispatched --target web build gives a clean error. If a dispatched app lacks withNotFound, the generated main = App.runLive … fails to type-check; the build captures that and reprints "target 'web' requires a fallback page — add |> App.withNotFound <page>" rather than surfacing HasFallback vs NoFallback from generated code.

Extension pattern (why this is maintainable): a new optional capability = a record field + a flag-preserving withX + use in the runner(s); a new mandatory capability = one more phantom flag + a flag-flipping withX + require it in the runner(s) that need it. Only mandatory capabilities touch the type, one flag each; there is exactly one today.

Namespace — the builder is Std.App, not Sky.App

The Sky.* / Std.* split is load-bearing and the new module must land on the right side of it:

8. Implementation architecture (grounded in the wiring survey)

The survey pinned two constraints the mechanism must respect:

The mechanism: a per-target derived entry (generalises spa-split)

  1. Std.App (pure Sky) exposes the unified builder plus one runner per backend, each applying that backend's view adapter and calling its kernel:

    App.app { init, update, view : model -> Element msg, subscriptions }
        |> App.withRoutes [...]        -- capability (web)
        |> App.withWindow (...)        -- capability (desktop)
        |> App.withInput onEvent       -- capability (terminal)
    -- runners (internal target → backend):
    App.runLive  : App model msg -> Task Error ()   -- view |> Ui.layout []  → rt.Live_app
    App.runSpa   : App model msg -> Task Error ()   -- view |> Ui.layout []  → rt.Spa_app
    App.runWebview : App model msg -> Task Error () -- view |> Ui.layout []  → rt.Webview_app
    App.runTui   : App model msg -> Task Error ()   -- view (Element direct) → rt.Tui_app
    App.runCli   : App model msg -> Task Error ()   -- view |> Ui.toText     → rt.Cli_program
    
  2. The build resolves --target → a runner, then (for a Std.App entry, detected by an import Std.App scan like is_spa_app_entry) generates a tiny derived entry main = App.run<Backend> userApp and builds THAT — so only the selected backend is referenced, dodging the Spa/Webview link conflict:

    --targetrunnerbuild path (all already exist)
    webrunLiveplain go build (server)
    web:app · mobile:* · tablet:*runSpaspa_split_and_build (client)
    desktop[:os]runWebviewcgo go build (or runSpa native shell)
    terminal · terminal:tuirunTuiplain go build
    terminal:clirunCliplain go build

    This is exactly where the web = server / web:app = client flip lives: two different runners off the one target axis. terminal is now a first-class build target (net-new — today Tui/Cli build with no --target).

Migration — strictly non-breaking, additive only

Implementation status (branch feat/unified-app-builder)

Delivery slices (each its own commit + Judge boundary)

9. Recommendation

Do it — incrementally, Std.App-first, reusing the kernels + spa-split. The hard pieces exist: the Element view renders everywhere (with a per-backend adapter), the five app kernels are callable from Sky, and spa-split already solves client-mode effect location. The net-new work is bounded: one Std.App module, the Element→text adapter, and the per-target derived-entry build dispatch.

Suggested first slice, provable on its own: unify the config — one App.app taking the shared core + optional builders, with Std.Live/Std.Spa reimplemented as thin aliases over it and no new build flags yet. That collapses the mental model ("write an App, not a Live-app-or-a-Spa-app") without touching the runtime, and each subsequent slice (the --target/--mode resolution, the terminal fold) lands behind it. The if it compiles, it works promise extends naturally: if it compiles, it runs on every target you build it for.

Open questions for @anzel