0011 — studio (local web UI)¶
Status: IN PROGRESS (architecture + roadmap reviewed 2026-06-22; all §11
questions resolved. Phase 1 step 1 — the server skeleton — starting.)
Date: 2026-06-22
Depends on: 0002 §4 (the web-UI contract — the R-UI-* / R-API-* requirements
this turns into an architecture); 0001 §10 (studio design intent); the existing
internal/workspace, internal/reel, internal/reelcmd, internal/socialcmd
packages (the studio is a front-end over the same workspace ops).
Pattern source: krites (../haileys-app, gitlab.com/phpboyscout/krites) — a
shipped GTB-based tool with the identical Svelte-SPA-in-a-Go-binary stack. This
spec mirrors its proven wiring.
1. Goal & scope¶
keryx studio starts a local, single-user web app that is a richer
front-end over a reel workspace — anything it does maps 1:1 to a CLI operation on
the same files (0002 §4). It does not introduce a second source of truth: the
storyboard the studio saves is the same storyboard.json the CLI reads.
Phase 1 target (confirmed): the full v1 "author & adjust" surface (0002 §4.1 v1) — project switcher, reels library CRUD, the mode-adaptive card editor, uploads, associated-content/bundle, the propose→accept chat, inline validation, and the unsaved-changes guard. v2 "preview & produce" (in-browser render, take audition, posting from the UI) is Phase 2.
In scope: the studio command, the HTTP server + /api/v1 surface, the embedded
Svelte SPA, the build/CI wiring, and the parity tests. Out of scope: changing any
generation/posting behaviour; exposing anything the CLI can't do (R-UI-13).
2. Architecture (mirrors krites)¶
pkg/cmd/studio/ # cobra command (gtb-generated, --mcp-enabled=false)
pkg/studio/ # the studio package (framework-quality → pkg/)
server.go # ServeMux routing + GTB pkg/http server lifecycle
embed.go # //go:embed all:web/embed + SPA fallback
gen.go # //go:generate → builds the SPA into web/embed (skips w/o Node)
handlers_*.go # thin /api/v1 handlers over internal/{workspace,reel,…}
web/ # Svelte 5 + Vite SPA (source)
src/ index.html vite.config.js package.json
embed/ # vite output — GITIGNORED, except:
placeholder.html # committed; "install a release for the full UI"
- Command (
keryx studio): a long-running server, gated off the MCP surface (setup.ExcludeFromMCP) — it is not a tool to expose, and it can trigger posting via the API (R-MCP-2). Scaffolded withgtb --ci generate command --name studio --mcp-enabled=false --agentless. - Server (
pkg/studio/server.go): a stdlibhttp.ServeMuxusing Go 1.22+ method+pattern routing ("GET /api/v1/reels",r.PathValue("slug")) — no third-party router (Go is 1.26). The mux is wrapped and run by GTBpkg/http.NewServer(ctx, cfg, handler)withMaxBytesMiddleware— this satisfies 0002 §4.3 ("served by GTB pkg/http") while keeping krites' minimal routing, and gives us GTB's config-driven bind + graceful shutdown + health handlers for free. - Bind localhost by default (
R-API-3):127.0.0.1,--port(0 = OS picks free),--host 0.0.0.0an explicit opt-in for LAN. No networked exposure without opt-in + auth (0001 §10.2; auth itself is Phase 2 / deferred). - Embedding (
pkg/studio/embed.go)://go:embed all:web/embed→embed.FS;fs.Sub(…, "web/embed")served byhttp.FileServer. The embed dir always holds at least the committedplaceholder.html, so the embed compiles even before any Svelte build. SPA fallback serves the builtindex.htmlwhen present, elseplaceholder.html(the "install a release" notice); with a built bundle, an unknown path rewrites to/so client-side routes resolve toindex.html. API routes register first;mux.Handle("/", spa)is the catch-all.
2.1 The project registry (the studio's only state)¶
The multi-project switcher (R-UI-28) needs a small list of known projects — the
only state the studio keeps, and it is per-user + machine-local, not
project config. It lives in a studio.yaml in keryx's user config dir (routed
through the existing resolver, which respects $XDG_CONFIG_HOME /
os.UserConfigDir() and lands at ~/.keryx/studio.yaml here, beside the
config.yaml token fallback). It is read/written directly by the studio —
plain typed YAML, afero-backed, absent = empty registry (not an error),
mirroring internal/workspace — and deliberately not through GTB/viper, so it
never touches the .keryx.yaml config contract or the Settings panel (R-UI-30).
Entries cover both local dirs and remote-git projects (R-GIT-2) so the shape
needn't change later; paths/remotes only, never secrets.
3. Frontend (Svelte 5 SPA + Vite)¶
- Plain Svelte 5 (runes:
$state/$effect) + Vite, not SvelteKit — an SPA mounted withmount(App, {target}). Same as krites; proven viable there. - Vite:
base: './'(relative asset paths so the embedded bundle is mount-agnostic),build.outDir: 'embed',emptyOutDir: falseso the build never wipes the committedplaceholder.html(the generate script clears prior builtindex.html/assets/itself). Dev proxy:/api+/healthz→ the runningkeryx studio(document the dev port). - State: local component
$state+ prop callbacks; a thinlib/api.jsfetch wrapper (throws on!res.okfrom the{error}body). No router lib — the three levels (library → editor → publish, 0002 §4.2) are conditional views. Reassess a store/router only if state-sharing pain appears. - Streaming chat = SSE-over-POST (
R-UI-5): the chat endpoint streamstext/event-stream; becauseEventSourcecan't POST, the client usesfetch+res.body.getReader()+TextDecoder, splitting\n\nframes, with anAbortControllerto cancel (which stops the server handler). Server side: asserthttp.Flusher, ansse(w, flusher, event, data)helper. (krites' cull pattern.) - Mobile-first (
R-UI-20): responsive; bottom-tab nav (Cards/Editor/Chat/ Publish) on narrow screens; ≥44px touch targets; drag-reorder via a small lib (e.g. SortableJS) plus a keyboard-accessible move for a11y (R-UI-14). - Visual language (0002 §4.2): neutral light base, teal/amber as restrained accents only — the chrome stays quiet, the palette earns its place in the reel.
4. HTTP API (/api/v1, thin over internal packages)¶
Endpoints map 1:1 to CLI ops (0002 §4.3); both are tested against the same
workspace assertions (§8). Versioned (R-API-4). The handlers are thin: parse
→ call an internal/* function → encode JSON / 422.
| Method · path | Backing call | Req |
|---|---|---|
GET /api/v1/projects · POST …/switch |
project registry (studio-only) | R-UI-28 |
GET·PUT /api/v1/config |
.keryx.yaml (no secrets) |
R-UI-30, R-CFG-2..4 |
GET /api/v1/reels |
workspace.List |
R-UI-24 |
POST /api/v1/reels |
reelcmd.New (core) |
R-WS-16 |
POST …/reels/{slug}:duplicate · :rename · DELETE |
reelcmd.Duplicate/Rename/Remove |
CRUD |
GET /api/v1/workspace/{slug} |
workspace.Load + reel.Parse + asset/social status |
R-UI-1 |
PUT …/workspace/{slug}/storyboard |
reel.Parse→reel.Validate→reel.Marshal |
R-API-1 (422) |
POST …/workspace/{slug}/assets |
upload into workspace | R-UI-3 |
PUT …/workspace/{slug}/bundle · …/source |
reelcmd.Link / source store |
R-UI-26, R-UI-4 |
POST …/workspace/{slug}/chat (SSE) |
chat→proposed patch (ops) | R-API-2/5/6 |
GET·PUT …/workspace/{slug}/social |
socialcmd |
R-UI-25 (Phase 2 UI) |
GET /healthz |
GTB health | — |
R-API-1:PUT storyboardrejects schema-invalid bodies with 422 + field-level errors (fromreel.Validate) and on success writes the samestoryboard.jsonthe CLI reads.R-API-2/5/6: the chat endpoint never auto-writes. v1 returns a whole-board proposal —{ base: <revision>, summary, storyboard: [...] }— streamed over SSE (prosetokenevents, then apatchevent, thendone). The client shows a card-by-card diff and accepts/rejects as a unit (R-UI-15); on accept it rebases (the working board must still matchbase, else re-propose) and applies the board, which is re-validated on the normalPUT storyboard. Why whole-board, not ops: in either model the client materialises a full board to preview and PUT; ops add an interpretation layer that is the most bug-prone and LLM-unreliable part of the feature (index arithmetic, reorder semantics, mis-applied stale ops) for the sole benefit of per-op cherry-pick, which R-UI-15 defers. Whole-board is atomic, robust, and re-validated identically. Per-op patches are an additive future enhancement. (Decision 2026-06-22; §11.6.)
5. Reuse (altitude) — the compute core is internal/workspace¶
Parity is at the workspace-operation level, not the CLI-rendering level, and
this already holds — no refactor was needed (a wrong assumption in the first
draft). internal/workspace owns the data ops (New/List/Load/Rename/
Duplicate/Remove/Link/Status, all afero-backed and unit-testable in
memory), and reelcmd.* is already a thin CLI presenter that calls a
workspace op then cliout.Emit. So the studio API is simply a parallel JSON
presenter over the same internal/workspace core — both surfaces share one
tested core, honouring R-UI-13. internal/reel (Parse/Validate/Marshal)
backs the storyboard endpoints the same way. The one shared resolution — the reel
root from config (reelcmd.Root) — is computed once when the server is wired
(RunStudio) and passed into the handlers via studio.Deps.
6. Build & CI wiring (go:generate, no committed bundle)¶
The Svelte build is a //go:generate directive (pkg/studio/gen.go), so it
runs as part of the go generate ./... that just generate/just build/just ci
already invoke — no extra plumbing, and no compiled code in git.
- The built bundle is gitignored; only a hand-written
placeholder.htmlis committed (so the embed always compiles).go install/ a Node-lessgo buildserve the placeholder, which tells the user to install a release for the full UI. - The generate script (
scripts/build-web.sh) is graceful: ifnpmis absent it logs a skip and exits 0 (placeholder stays) — so backend-only contributors and this Node-less dev box still runjust cigreen. Ifnpmis present itnpm ci(first run) then clears prior built assets and runsvite buildintoweb/embed/, so the real bundle overrides the placeholder at serve time. - Release: goreleaser's existing
before: hooksrunsgo generate ./...on a Node-equipped runner, so the released binary embeds the real UI. CI lint/test stages need no Node — they compile against the placeholder. just web-devruns Vite HMR against a locally-runningkeryx studio(inner loop)..gitignore:pkg/studio/web/embed/*(except!.../placeholder.html),pkg/studio/web/node_modules/, and an anchored/dist/for goreleaser's own output — kept separate from the studio bundle.
7. Security¶
- Localhost bind by default (
R-API-3); LAN exposure is explicit + (Phase 2) authenticated.MaxBytesMiddlewarecaps request bodies. - No secrets over the API (
R-CFG-2): the config endpoint reads/writes.keryx.yamlnon-secret settings only; credentials stay in env/keychain and are never returned or written by the studio. - Posting stays gated: on-demand posting from the UI is human-initiated and
calls the same
keryx postpath; unattended posting remains CI-only; thestudiocommand is excluded from MCP (R-MCP-2).
8. Testing¶
- API parity (godog) — ✅ built (
features/studio.feature+test/e2e/steps/world_studio_test.go): the harness starts a realkeryx studioin the scenario's project dir and drives/api/v1alongside the CLI, asserting the same on-disk outcomes the CLI scenarios assert — "POST /reels →keryx reel listshows it"; "PUT an invalid storyboard → 422 + the file is unchanged"; "DELETE → gone from the listing". Env-gated (INT_TEST=1). The deterministic core (validation) is also unit-tested directly. - Frontend: like krites, no FE test suite initially — the logic lives in the Go API (parity-tested) and the SPA is a thin client. Add vitest/Playwright later if the client grows non-trivial logic (the patch-rebase client is the candidate).
- Faked providers/HTTP as today; afero in-memory FS for handler tests.
9. Definition of Done (per increment)¶
just ci green; docs page(s) under docs/components/studio + docs/how-to
written as we build; the godog parity scenarios for the increment's endpoints;
/simplify (+ /code-review for non-trivial) before the PR.
10. Phased plan¶
- (review this spec; resolve §11) →
- Phase 1 — full v1 author & adjust. Built as an ordered sequence landing as one v1 increment (stacked commits acceptable):
studiocommand + server skeleton (GTBpkg/http+ ServeMux) + embed + SPA shell +/healthz; the reelcmd compute/render refactor (§5).- Library: projects + reels CRUD endpoints + the library view (
R-UI-24/28). - Editor:
GET workspace+PUT storyboard(422) + the mode-adaptive card editor, reorder, validation, unsaved-guard (R-UI-1/2/6/14/23). - Source/assets: uploads + bundle/associated-content (
R-UI-3/4/26). - Chat: SSE propose→accept patch seam (
R-UI-5/15,R-API-2/5/6). - Settings panel (
R-UI-30), theme switch (R-UI-22), responsive polish. - Phase 2 — preview & produce (0002 §4.1 v2): in-browser silent preview,
take audition/selection, illustration re-roll, the Publish panel
(
R-UI-25/27), and networked-exposure auth.
11. Questions for review¶
- GTB
pkg/httpvs raw stdlib server. Proposed: stdlibServeMuxrouting wrapped by GTBpkg/http.NewServer(config-driven lifecycle + middleware), reconciling the 0002 §4.3 wording with krites' minimal pattern. Agree, or prefer krites' rawnet/http.Serverfor an exact copy? - ✅ Resolved —
go:generatebuilds the bundle; nothing compiled is committed. The Svelte build is a//go:generatestep (graceful-skip without Node); only a committedplaceholder.htmlships in git, overridden at build/release by the real bundle.go installgets the placeholder (UI users install a release). The output dir isweb/embed/(notdist/), and/dist/is anchored in.gitignorefor goreleaser only. Trade-off accepted: the release path needs Node; the repo stays compact. (Supersedes the krites "commit dist" approach — §6.) - Phase 1 size. Full v1 is large for one increment (confirmed as the target). OK to land it as a stack of reviewable commits under one feature branch (skeleton → library → editor → assets → chat → settings), each green, rather than one giant PR?
pkg/studio/weblocation (co-located, krites-style) vs a top-levelfrontend/. Proposed: co-located under the package that embeds it.- ✅ Resolved — a tiny user-scoped registry, deliberately outside the config
system. A
studio.yamlin keryx's user config dir (~/.keryx/studio.yamlhere, via the XDG-aware resolver), read/written directly by the studio (plain typed YAML, afero, absent = empty), not through GTB/viper — so it never touches the.keryx.yamlcontract or the Settings panel. Holds local dirs + remote-git projects (R-GIT-2); paths only, never secrets. See §2.1. - ✅ Resolved — the chat patch is a whole-board proposal for v1 (not per-op),
accepted/rejected as a unit, rebased on a revision, re-validated on
PUT. Ops are a future enhancement. Rationale in §4 (the "Why whole-board" note) and spec 0002R-API-5. (2026-06-22.) ```