Skip to content

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 with gtb --ci generate command --name studio --mcp-enabled=false --agentless.
  • Server (pkg/studio/server.go): a stdlib http.ServeMux using 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 GTB pkg/http.NewServer(ctx, cfg, handler) with MaxBytesMiddleware — 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.0 an 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/embedembed.FS; fs.Sub(…, "web/embed") served by http.FileServer. The embed dir always holds at least the committed placeholder.html, so the embed compiles even before any Svelte build. SPA fallback serves the built index.html when present, else placeholder.html (the "install a release" notice); with a built bundle, an unknown path rewrites to / so client-side routes resolve to index.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 with mount(App, {target}). Same as krites; proven viable there.
  • Vite: base: './' (relative asset paths so the embedded bundle is mount-agnostic), build.outDir: 'embed', emptyOutDir: false so the build never wipes the committed placeholder.html (the generate script clears prior built index.html/assets/ itself). Dev proxy: /api + /healthz → the running keryx studio (document the dev port).
  • State: local component $state + prop callbacks; a thin lib/api.js fetch wrapper (throws on !res.ok from 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 streams text/event-stream; because EventSource can't POST, the client uses fetch + res.body.getReader() + TextDecoder, splitting \n\n frames, with an AbortController to cancel (which stops the server handler). Server side: assert http.Flusher, an sse(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.Parsereel.Validatereel.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 storyboard rejects schema-invalid bodies with 422 + field-level errors (from reel.Validate) and on success writes the same storyboard.json the 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 (prose token events, then a patch event, then done). 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 match base, else re-propose) and applies the board, which is re-validated on the normal PUT 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.html is committed (so the embed always compiles). go install / a Node-less go build serve the placeholder, which tells the user to install a release for the full UI.
  • The generate script (scripts/build-web.sh) is graceful: if npm is absent it logs a skip and exits 0 (placeholder stays) — so backend-only contributors and this Node-less dev box still run just ci green. If npm is present it npm ci (first run) then clears prior built assets and runs vite build into web/embed/, so the real bundle overrides the placeholder at serve time.
  • Release: goreleaser's existing before: hooks runs go 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-dev runs Vite HMR against a locally-running keryx 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. MaxBytesMiddleware caps request bodies.
  • No secrets over the API (R-CFG-2): the config endpoint reads/writes .keryx.yaml non-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 post path; unattended posting remains CI-only; the studio command 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 real keryx studio in the scenario's project dir and drives /api/v1 alongside the CLI, asserting the same on-disk outcomes the CLI scenarios assert — "POST /reels → keryx reel list shows 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

  1. (review this spec; resolve §11) →
  2. Phase 1 — full v1 author & adjust. Built as an ordered sequence landing as one v1 increment (stacked commits acceptable):
  3. studio command + server skeleton (GTB pkg/http + ServeMux) + embed + SPA shell + /healthz; the reelcmd compute/render refactor (§5).
  4. Library: projects + reels CRUD endpoints + the library view (R-UI-24/28).
  5. Editor: GET workspace + PUT storyboard (422) + the mode-adaptive card editor, reorder, validation, unsaved-guard (R-UI-1/2/6/14/23).
  6. Source/assets: uploads + bundle/associated-content (R-UI-3/4/26).
  7. Chat: SSE propose→accept patch seam (R-UI-5/15, R-API-2/5/6).
  8. Settings panel (R-UI-30), theme switch (R-UI-22), responsive polish.
  9. 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

  1. GTB pkg/http vs raw stdlib server. Proposed: stdlib ServeMux routing wrapped by GTB pkg/http.NewServer (config-driven lifecycle + middleware), reconciling the 0002 §4.3 wording with krites' minimal pattern. Agree, or prefer krites' raw net/http.Server for an exact copy?
  2. Resolved — go:generate builds the bundle; nothing compiled is committed. The Svelte build is a //go:generate step (graceful-skip without Node); only a committed placeholder.html ships in git, overridden at build/release by the real bundle. go install gets the placeholder (UI users install a release). The output dir is web/embed/ (not dist/), and /dist/ is anchored in .gitignore for goreleaser only. Trade-off accepted: the release path needs Node; the repo stays compact. (Supersedes the krites "commit dist" approach — §6.)
  3. 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?
  4. pkg/studio/web location (co-located, krites-style) vs a top-level frontend/. Proposed: co-located under the package that embeds it.
  5. Resolved — a tiny user-scoped registry, deliberately outside the config system. A studio.yaml in keryx's user config dir (~/.keryx/studio.yaml here, 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.yaml contract or the Settings panel. Holds local dirs + remote-git projects (R-GIT-2); paths only, never secrets. See §2.1.
  6. 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 0002 R-API-5. (2026-06-22.) ```