hoplight ui [port] [studioDir] boots the primary local studio: a loopback Bun server (default
127.0.0.1:8321) serving the shell and a JSON API that is a thin skin over the same engine the CLI
uses. Optional remote listeners are separately enabled and gated. One engine, two shells - no format
logic exists in the UI layer.
Kit is Hoplight's conversational terminal preview. Run it from a source checkout with bun run kit;
current GitHub release binaries do not include a Kit executable. Its composer accepts multiline text and up to five
large paste cards. Up and Down recall submitted drafts only at the relevant buffer edge, Ctrl+F opens
transcript search without discarding the current draft, and Escape stops an active turn. A rejected
submission remains in the composer. Plain messages submitted during an active turn enter a bounded
FIFO follow-up queue; a compact receipt shows the next message and folded remainder count, then Kit
runs each message in order as the previous turn settles. The queue survives stopping the active turn
but is intentionally session-local and is not restored after relaunch. Typing / opens a
registry-driven command palette containing
every installed slash command and its summary; typing filters it, Up and Down move selection, Enter
runs the selection, Tab completes it for arguments, Escape closes it, and rows are clickable. A
trailing @ query opens matching studio pieces; choosing one inserts a stable @kind:id marker,
and resolved markers in replies render as compact name-and-kind cards. When the composer is empty,
its placeholder suggests a deterministic next move from the live studio shape, such as auditing
lorebook triggers or comparing two named characters; it disappears as normal placeholder text as
soon as typing starts and is never submitted implicitly. If a provider stops after
streaming part of a reply, Kit keeps
that partial reply visible before showing the stop or error. The input and connection state form one
fused prompter rail: an open heavy top rule and rose prompt cap lead into the input plate, while a
thin seam joins the quieter provider, model, and ready or working register below. Transcript
scrolling remains available by mouse wheel and navigation keys without painting a second,
application-owned scrollbar beside the terminal's own window chrome. Home and End jump to the oldest
or newest transcript content, and Page Up/Page Down move by a viewport. When new rows arrive while
the reader is scrolled up, a capped new below pill appears above the composer; clicking it or
pressing End returns to the latest row and clears the count.
/help opens a full-screen, registry-driven reference stage instead of writing a long help message
into the transcript. Commands and live keyboard bindings share grouped sections, the pane scrolls
with navigation keys, and Escape or q returns to the untouched session view.
While Kit is open, a bounded read-only stage watcher polls the canonical studio listing and compares
it with the prior snapshot. Outside imports, edits, and removals coalesce into one searchable
NOTICED transcript cue with a suggested next question; Kit never acts on the change by itself.
Temporary unreadable states are skipped until the next poll, the live piece/deck counts and composer
suggestion refresh from the new snapshot, and the watcher stops with the Kit process. Watch notices
are live-session context and are not written into saved conversation history.
Settled replies longer than 1,200 characters fold to a one-line cue and reopen by click or Ctrl+O; live streaming text never folds mid-answer. Fenced unified diffs receive counted add/remove treatment, horizontal rules share Kit's scene seam, and error rows cap hostile or accidental floods. Hovering a user, assistant, or error band exposes a copy corner. Copy uses the terminal's OSC52 support, refuses oversized payloads, and reports unsupported or rejected requests in a short status toast instead of claiming clipboard success.
Successful turns are saved atomically as one JSON file per session under the Hoplight configuration
directory's sessions/ folder. /session opens the saved-session playbill, /resume [name] restores
history and visible transcript lines, /rewind can truncate or fork at an earlier turn, and
/export [md|json] writes a transcript with a session-specific filename. Search, resume, and rewind
lists keep the selected row inside a terminal-height-based visible window.
/model opens provider settings. Credential, endpoint, or provider-option edits invalidate any
previous model result before another lookup. Lookup and save failures remain visible and can be
retried; activating or removing a provider refreshes the shell's provider and model state.
/doctor discovers and concurrently runs four independently timed, read-only checks: vault sealing,
a real provider proof-of-life, canonical studio enumeration, and the exact Hoplight/Bun build. One
timeout or failure becomes its own warning/failure row without hiding the other completed results.
/decks (also /inventory) prints the current canonical deck counts on demand. /privacy reports
the session's provider sends and token counts on demand. Wide terminals show the animated NOW
PLAYING theatre marquee without a persistent telemetry strip;
inventory stays out of persistent chrome. Narrow terminals use a purpose-drawn miniature rainbow
V KIT lockup but retain the complete greeting and stage cues, wrapping the copy instead of
replacing it with a terse fallback. Extremely short windows can scroll that opening while the
composer and connection commands remain reachable. On roomy terminals, the large V KIT lockup
and its welcome script share one centered stage block instead of clinging to the left edge of an
ultrawide canvas. The shell inherits OpenTUI's live canvas instead of copying a
dimension snapshot. Kit also matches the terminal emulator's default background to the stage while
it is open, then restores it on exit, so fractional-cell and profile-padding gutters visually belong
to the same surface.
Kit's live model tool belt includes studio_list, studio_read, and studio_search; bounded
Hoplight documentation query; bounded capability discovery; draft discard; and revision-checked
apply. Documentation
query lazily ranks catalog-declared Markdown sections with a local full-text scorer and reads only
catalog-declared pages or heading sections.
Capability discovery can search, browse a collapsed target to area to action hierarchy, or describe
one exact operation. Search replaces the deferred set with no more than five typed operations;
describe reveals exactly one. The set resets on the next user turn. Capability calls preview without
save. Kit keeps composing preview operations while the model still needs tools. When the model
finishes a composed draft, the shell replaces any model-authored save question with a semantic
REVIEW CHANGE panel. The panel shows up to five field-level before/after rows, the target, hidden
change and warning counts, and clickable Apply & save or Discard controls. Enter or y applies;
Escape, n, or d discards. Apply pauses in this interactive Gate, saves once
only if the stored canonical revision still matches, then re-reads the piece before reporting an
applied, stale, or failed receipt. Read-only tool batches may run concurrently, but their
observations retain provider order; drafts and applies always serialize. Loop stops name the
model-round, tool-call, elapsed-time, no-progress, or cancellation budget and offer a recovery
action. /tools opens the keyboard-and-mouse Panel Deck browser for piece, area, action, platform
applicability, and preview behavior. Compact terminals show one active pane at a time. See
Kit content tools for the exact implementation boundary.
The Backstage header reflects scheduler-owned lifecycle state. Its sealed trace keeps the final verified, stale, discarded, failed, cancelled, or stopped receipt beside the ordered tool moves. Reasoning fragments from consecutive tool rounds accumulate into one rehearsal receipt instead of adding one nearly identical collapsed row per model round. Provider-authored tool preambles remain in model history but do not interrupt the user transcript; Backstage owns in-progress narration.
- Apps are drop-in folders:
src/ui/apps/<name>/index.{ts,tsx}default-exports aHoplightApp(src/ui/app-contract.ts): a manifest (tile title, flat-ink SVG mark, accent, order, optionalcomingSoon/catalogOnly/appCatalog) plusComponent(ctx). The server discovers them withBun.Glob(_-prefixed folders skipped) and bundles each for the browser on demand. A normal app gains a Dock tile; acatalogOnlyapp stays packaged and opens from the manifest-declared Apps catalog. No central list names either kind, identical doctrine tosrc/formats/. - Apps never touch the engine or the filesystem: they receive an
AppContextwhoseapiis the only door (inspect/export/studio/formats). The shell owns theme, the Workbench tabs, and the status bar. - Tokens are one CSS source:
src/ui/theme/tokens.css, transcribed fromdesign/DECISIONS.md(both themes, the stamp, the seam). Apps consume tokens; hardcoding colors or layout pixels in an app is a review rejection (fluid law). - Bright accents never carry small text: every accent has a deep companion for text-bearing
fills, always worn with
var(--stage-white)ink (white on the deep tone clears ~7:1; on bright rose even black ink measured 4.47:1, under AA). Pairs:--rose/--rose-deep,--accent/--accent-deep(repainted together by the shell when a custom house accent lands),--deck-*/--deck-*-deep, and per-piece--a/--a-deep(minted together byaccentVars()insrc/ui/_shared/decks.ts; the deepening math isdeepAccent()insrc/ui/_shared/color-math.ts, unit-tested to agree with the tokens). Bright originals keep borders, spines, and marks; a fill with a label on it takes the deep twin.
The locked vs-setup-hybrid flow, one plain question per screen, built on the same drop-in doctrine:
- Steps are drop-in folders:
src/ui/setup/steps/<name>/index.tsdefault-exports aSetupStep(src/ui/setup/step-contract.ts): a manifest (question, say-line, settings key, single/multi, stage note), its options (may be data-driven: the publish step derives platforms from/api/formats, excludingnativeformats), optional custom option widgets (theme thumbnails, color swatches), an optional stage ZONE (theme owns the backwall preview, first-deck the floor, publish the apron), optional direct stage effects (accent repaints--accent), andphrase/recapfragments for the final summary. The wizard (src/ui/setup/wizard.ts) derives progress dots, "N of M", defaults, skip-all, the stage, the summary sentence, and the recap chips from the sorted step list; nothing central names a step. Pure logic (selection, defaults, sentence assembly) lives insrc/ui/setup/wizard-core.ts, unit-tested. - Settings:
src/studio/settings-shape.ts(open record, fail-closed per-key parser, known keys inSETTING_KEYS: theme / firstDeck / publishTargets / houseAccent) +src/studio/settings.ts(<studioDir>/settings.json). The shell boots settings-first: nosetupComplete-> wizard; after OPEN VAUDE the shell applies theme + house accent and lands on the app whose manifest setfirstRunLanding(the Library's two doors); later boots open the lowest-order app. The top-strip theme button reveals the next theme outward from the control through the View Transitions API, falls back to an immediate switch when unsupported or reduced motion is requested, and persists to settings (localStorage is only a pre-paint cache). Individual controls use serializedPATCHupdates so overlapping changes compose instead of replacing stale snapshots.
One context menu for the whole app (src/ui/_shared/context-menu.ts), extended by REGISTRATION:
- a surface marks an element as a TARGET:
ctx.menus.attach(el, () => ({ type, label, data }))(deepest attached element wins; a document-levelshelltarget is the always-there fallback) - a feature contributes items:
ctx.menus.register(type, provider)(any number of providers per type; each provider's items form a section; empty/null = deny by absence). Returns an unregister for app cleanup. - shell-owned providers:
entity(Send / Show / Open beside / Close the split / Remove on the Workbench - the one-shot single path, works in every room),app(dock tiles),shell(Go home / Import files / Switch theme). Adding a menu later (Open in editor, Export, Delete) = one register() call; nothing central is edited.
The Dock's Add app slot is a real control. It finds the one manifest with appCatalog: true and
opens that surface without hardcoding an app id. The catalog derives its cards from ctx.apps(), so
it shows the official applications in the running build and cannot drift from the packaged manifest
roster. CSS Workshop is the first catalogOnly tool: it remains fully bundled and mountable while
staying off the everyday Dock. Help / Docs is another catalog-only app and renders the committed
docs/ corpus inside the Studio. Its left navigation and search derive from
docs/generated/docs-index.json, including short frontmatter summaries, optional semantic page
summaries, retrieval topics, and nested H2/H3 anchors when sidecars are merged. The reader presents
the index as User Docs and Developer Docs with human-facing workflow, platform,
application/API, architecture, data-model, format, security, extension, and technical-decision
sections. Decision records keep their canonical ADR filenames on disk while the reader uses plain
titles without ADR codes. The center pane renders Markdown through the shared sanitizer and mounts
only generated figures or committed docs/media/ images; the right rail derives from the current
page's heading anchors (nested anchors flatten depth-first while preserving H3 styling). The
desktop build bakes the same Markdown and assets that are visible on GitHub, so there is one source
rather than an in-app copy. Each page's view on GitHub action uses the existing leaving gate and
/api/open URL allowlist.
hoplight ui (or bun run dev) watches src/ui/ and pushes a reload over SSE (/dev/reload) to
every open page; bundles are built fresh per request, so edits appear on save. The packaged exe
serves baked bundles and 404s the stream (the client goes quiet). Zero dependencies.
Summaries carry sourceFormat + sourceVariant (the first non-hoplight escrow entry). The Library
maps ids to chip labels from the LIVE registry: platform name when the adapter is
platform-specific ("RoleCall · V3"), "Default" when the adapter declares generic (the plain
Tavern/CC reader); no source = no chip (made from scratch).
src/studio/store.ts: local-first storage where the canonical vaud-json format IS the database -
<studioDir>/<kind>/<id>.json, one file per entity, portable and versionable by construction.
Saving never silently overwrites: an occupied id gets a numbered sibling (the import journey's
"Keep both" default enforced at the storage floor).
inventory() returns readable summaries and separate records for safe-id JSON files that fail
parsing, canonical schema, kind, or id checks. Damage records use bounded reasons; Hoplight does not
modify or quarantine those files.
src/ui/receipt.ts renders the plain-words import receipt server-side (both shells say identical
sentences): platform names only, "we read / we kept" voice, lines only when true (embedded book,
scripts-as-data notice, privileged warning), and the warm unknown-file message. It is a rendering of
what the engine already knows - no new format logic.
The Library's primary read is GET /api/studio/inventory, which returns healthy entities and
bounded damaged-file records from one filesystem scan. GET /api/studio/list remains the
healthy-only read for existing clients.
GET / shell · GET /tokens.css · GET /boot.js · GET /api/apps manifests ·
GET /apps/<id>.js bundled app · GET /api/setup/steps step ids · GET /setup/steps/<id>.js
bundled step · GET|POST|PATCH /api/settings (POST replaces the document; PATCH validates and merges a partial update) ·
GET /api/docs/index|figures generated docs metadata · GET /api/docs/get?id= catalog-declared
Markdown only · GET /api/docs/asset?path= committed docs media and generated figure assets only ·
GET /api/formats (includes native) · POST /api/inspect (bytes + x-filename) -> receipt +
canonical entity · POST /api/export {entity, targetId} (cross-kind fails closed) ·
GET /api/coverage (per-platform canonical-path claims - the editor lens's and the Press's ground
truth) · GET /api/studio/list|get|inventory (get&revision=1 returns an editor baseline and its
revision) · GET /api/studio/portrait?kind&id (the entity's art: the
escrowed PNG carrier, else a data-URI body.media.portrait; 404 when none) ·
POST /api/studio/save (editor saves compare the loaded revision and return 409 when stale) ·
POST /api/studio/save-bundle (the complete canonical batch is validated
before writing; keep-both lorebook renames rewrite knowledgeRefs) · host-only /api/remote/* routes for tunnel/LAN status, setup,
join secrets, devices, approvals, and host controls · host-only /api/updates/* routes for release
information and version switching. Remote clients do not receive either control family.
Empty studio = the locked first-run doors; populated = the browse room: deck chips with LIVE
per-kind counts, a proscenium stage presenting the active deck through a DROP-IN VIEW, and the
continuous art-size dial. Opens on the setup wizard's firstDeck. Import (drop anywhere,
plain-words receipts) lives here. Covers use /api/studio/portrait art when carried.
- Damaged files stay visible: the Library loads
/api/studio/inventory, which returns healthy entities and damage records from one filesystem pass. When any safe-id JSON file fails closed, an expandable notice names the file and bounded reason, confirms that Hoplight did not change it, shows the local studio path when available, and gives manual restore-or-remove guidance. The notice also appears when no readable pieces remain. - Deck views are drop-in modules: one file in
src/ui/apps/library/views/default-exporting aDeckView(view-contract.ts: label, icon, css, render(ctx));views/registry.tsis the one stated seam (a browser bundle cannot glob), one import line per view. Shipped:grid(default, fluid auto-fill 2:3 cards),showcase(one card at a time: hero art, the card's own tagline/description viapeek, prev/next + thumb rail, stage in place),list(dense rows). The toolbar derives from the registry; the library names no view. Every view renders two piece states from the context:open(a quiet "on the workbench" annotation) andselected(staged, accent ring + corner check). - Multi-select is staging (restored after the IDE rework dropped it): tapping a
piece toggles it into the room's staging set (persists across deck switches - the distributed tray);
a Send action bar appears with the live count and commits the whole batch through
workbench.sendMany, which fires the follow dialog ONCE with the real newly-opened count. The right-clickentitymenu keeps the one-shot single Send. Open pieces are annotation-only: tapping one just notes "already on the Workbench" (no state-dependent navigation - one rule for tap). - Art size is a continuous dial (range slider,
SIZE_RANGE4-36rem, fail-closedclampSize): dragging repaints live via the cascading--card-wvar; release persists. View + size persist per-user throughAppContext.prefs(library.view,library.size).
Pieces are sent from the Library and open as tabs. The Workbench mounts a writable editor for every canonical kind: character, lorebook, persona, preset, regex set, and sprite pack. The shell owns tabs, split view, recents, and focus; each editor owns its unsaved local draft and explicit save flow. Editor loads include a revision of the complete canonical entity. A save replaces that exact revision atomically; a newer disk write leaves the draft dirty and reports the conflict instead of overwriting it.
- Open pieces are shell-owned (
AppContext.workbench: pieces/active/send/sendMany/remove/isOpen/ focus/onChange) so tabs persist across app switches; the "on the workbench" marks in every Library view read the same state.send(single) andsendMany(batch) share one follow path so the dialog always reports the true count opened. - Sending honors the FOLLOW setting (
workbench.follow): "ask" pops the dialog ("N items were sent to the Workbench. Follow?" Yes/No + "Never ask me this again"), "always" jumps there, "never" stays with a status note. Changeable in Settings. workbench.openopens a piece AND lands on its editor with no follow prompt - the path every create button uses ("New persona" etc.): the click itself already says where the user is going, the same reasoning that lets "Open beside" skip the prompt.sendstays the ask-me path for Library send flows.- The manifest flag
editsPiecesmarks the room tabs focus into. The home app on boot is the user'shomeAppsetting. - The recents rail (a low deck along the bottom - "wanna bring this one up?") offers recently
imported/opened pieces as one-click sends. Recency is the honest later-of two signals: a piece's
importedAtstamp (set on every save) and its last-opened time (a per-piece epoch-ms map persisted in settingsworkbench.recents, bumped byworkbench.send/focus, capped at 60). Currently-open pieces are excluded. Ranking is pure and unit-tested (workbench/recents-core.ts); the rail readsworkbench.recents()off the shell store. The rail collapses from its label (hide/show, persisted asworkbench.recentsOpen) so the editor pane can take the room. - Character editor. The character editor provides:
a fixed Identity card (name/tagline/full name/title/age/pronouns) plus reorderable prose cards
(description, personality, scenario, first message, example messages) whose order persists to
presentation.fieldOrderusing RC's ids verbatim (cross-app order interop; unrendered ids keep their saved positions). Explicit save only: Save button + Ctrl+S, dirty flag, beforeunload guard; saving round-trips the WHOLE body so untouched fields (escrow, behavior, media) survive structurally unchanged, pinned by editor-core tests. Fields the editor does not write yet stay visible in a read-only tail. OneCharacterEditorstays mounted (hidden via CSS) per open character, so its React state IS the unsaved draft across tab switches. Every editor publishes dirty state through the shareduseEditorGuardshook, which also owns Ctrl+S and the window-close warning. Closing a dirty tab from the tab strip, keyboard, editor back button, or context menu opens one shell-owned Discard changes / Keep editing dialog; only explicit discard unmounts the draft. Pure logic inworkbench/editor-core.ts(tested), the React component inworkbench/Editor.tsx. Lorebook, persona, preset, regex, and pack pieces dispatch to their own writable editors fromworkbench/index.tsx. Canonical path writes and variant lifecycle/override writes now delegate to the same entity-layer operations used by Kit's typed character capabilities, so base and variant semantics cannot drift. - SPLIT VIEW - anything can sit beside anything. The shell store carries a second visible key
(
splitKey, never equal toactiveKey); "Open beside" on theentitymenu pins any piece into a second pane next to the active one (opening it first if needed - the explicit gesture skips the follow prompt). Focusing the pinned piece SWAPS the panes (both stay visible); closing the primary promotes the pinned piece; the pinned tab wears an inset accent bar. The pane-key rules are pure and unit-tested (shell/store-core.ts:focusKeys/removeKeys/besideKeys). Apps readworkbench.beside()/openBeside()/closeSplit()off the ctx. The capability lives in the shell - no editor owns it, every current and future kind inherits it. - THE FLUID-LAW CONTAINER SEAM. The workbench stage and every piece pane declare
container-type: inline-size, and the editor family (Character, Pack, Lorebook, Workshop) collapses via@containerqueries against the PANE, never@mediaagainst the viewport - a half-width split, a future drawer, and a phone all compose the same way (design/DECISIONS.md, the fluid law). A stage too narrow for two readable panes stacks the split vertically. - Lorebook editor. The
character editor's sibling: ONE ENTRY OWNS THE SCREEN.
workbench/LorebookEditor.tsxmerges one-concept skins (chassis +lore/entry-page+lore/entry-toc+lore/entry-rail+ the dial/ key skins; a class name lives in exactly ONE module). Header: the book's SPINE CHIP (monogram, name, entries · ~tokens/budget, a "book rules" link opening an InkDialog with the book form), the Writing-for select (presentation only; the book never forks), Save. LEFT: the quiet TOC (entry-toc.tsx: search across titles+keys, grouped with counts - "Always on" + the rest; category folders slot in later - active row wears the accent bar, disabled entries strike through, + New entry). CENTER (entry-page.tsx): the entry masthead (ENTRY N OF M pager, ~tok, On/Off, name in display type, a COMPUTED "Fires on: …" line - never a stored field) and the dossier cards: KEYS (primary + "only together with" side by side, AND-any logic select always visible, Simple/Advanced tabs in the card head; advanced picks a chip and edits its riders viatrigger-editor.tsx/trigger-edit.ts), TIMING & CHANCE directly under Keys (a fold whose summary line honestly states what it hides: sticky/cool/delay, recursion segments, group), PLACEMENT (one row of position pills,position-picker.tsx), THE PASSAGE (big serif textarea, {{user}}/{{char}} inserts, char/~token count), a folded CREATOR NOTE card, and PLATFORM CARDS - the character editor's native-fields doctrine applied to lore: ONE FILE PER PLATFORM underlore/platforms/(sillytavern/rolecall/novelai/risu; registry.ts is the one stated seam; platforms with no long tail have no file - deny by absence), each rendering a platform-NAMED folded card surfaced only by its Write-for lens (Hoplight shows them all; RoleCall has no lens - the Hoplight card covers its wire). The lens select lists each platform separately - SillyTavern, Chub, and Lumiverse are three lenses on the codec-grounded ownership matrix (core/lore/platform-fields.ts), never one smushed tab. RIGHT (entry-rail.tsx): the fine print - Order & survival label/value rows (order, priority, always keep, chance, speaks-as, scan depth, whole-words/case tri-states, move/copy/delete), the sample-match try-a-line, and quiet health tips for the focused entry. Session is pure and unit-tested (lore/session.ts: one focusedId; TOC click and pager both go throughselectEntry). The RC codec (formats/rolecall/lorebook.ts) remains the field-coverage checklist; token estimates are the core's honest gauge (estimateEntryTokens/estimateBookTokens,4 chars/token, always rendered with ""). - The app dock collapses to marks-only via the strip at its foot (persisted as
shell.dockSlim); it is the same visual language as the locked narrow-screen mode, just user-driven. Tiles carry hover titles so the slim dock stays discoverable.
The staging grammar applied to export: the Library browses, pieces get STAGED for the Press
(right-click "Stage for the Press", or the room's own left rail of unstaged-piece stamps), and the
room works only its staged queue - no studio browser inside. The queue is shell-store state
(pressQueue + ctx.press), so it survives app switches.
- Bundles (
press/press-bundles.ts, pure + tested): a staged character travels as a bundle - hisbody.knowledgeRefslorebooks ride along automatically (resolved against the whole studio, even when never staged), droppable per run ("drop from this run" / "ride again"); staged books already riding a bundle are not doubled as solos. Everything else rides solo. - Readiness on every card (
press/readiness-core.ts, pure + tested): characters are read against the run target's coverage claims (/api/coveragecarries - the same ground truth as the editor lens): "8 of 23 filled - empty: nickname, personality, +12 more" with an open-in-the-editor fix link. Lorebooks get the can-it-ever-fire check (lorebookKeyGap: entries with no triggers and not constant). No claims = an honest nothing, never fake green. - Export results keep the same distinction: undeclared target coverage reports loss as unknown
(
counts.dropped: null) instead of presenting an empty confirmed-drop list as zero loss. - One target per run (
press/press-core.ts:groupPlatformsfolds extension-map hosts - Marinara/Chub print characters as a CCv3 card, their native character file), per-card filename + flavor (.json/.txt/.md only where the wire format is text), skip rows declared before the run, per-row honest results (printed with carries / skipped with the reason / failed with the error), one zip out. - Deferred, stated: delivery ledger + saved jobs, drag reorder, rehearse-bytes drawer, offer-rail readiness dots.
Settings is built from section modules: one file in src/ui/apps/settings/sections/ exporting a
SettingsSection (id, label, order, Component: (props: { ctx }) => JSX.Element);
sections/registry.ts is the one stated seam. Tabs derive from the registry (src/ui/apps/settings/ index.tsx); every control is call-and-response against live settings (theme/accent repaint
instantly). Shipped sections: Appearance (theme and house accent), Studio (home app, Library first
deck, and publish targets), Workbench (follow behavior), Remote access (tunnel and LAN setup, devices,
and host controls), Updates (release checks and version switching), and About.
Remote-access host controls use /api/remote/*; release information and version switching use
/api/updates/*. Both route families are host-only and remain distinct from ordinary remote-client
content access.
Extracted-once UI, layered so a single implementation serves every consumer:
components/swatch-row/-SwatchRow(+ theHOUSE_PALETTEconstant), the house color-swatch row. Preset tiles are the fast path; withallowCustoma finalcustomtile opensPaintPicker(solid mode) for any color. Controlled:valuein,onChange(hex)out. Consumers: setup accent step, Settings Appearance, later the editor's per-entity accent.components/color-picker/-ColorPicker, the on-brand HSV surface (saturation/value square, hue strip, hex field; no OS dialog). HSV, not hex, is its internal state (hex is lossy at s=0/v=0)._shared/color-math.ts- the pure color math powering the picker:normalizeHex/hexToHsv/hsvToHex(fail-closed on garbage) anddragFraction(the pointer-drag fraction math shared by the SV square, the hue strip, and the gradient stop rail). Unit-tested._shared/paint.ts- the pure Paint model: asolid | gradient(linear|radial)discriminated union withpaintToCssand a fail-closednormalizePaint(a gradient needs >=2 valid stops). Unit-tested.components/paint-picker/-PaintPickercomposesColorPickerto edit a solid color or each stop of a gradient.allowgates the modes, so the same widget serves a solid-only accent and a full linear/radial gradient fill. Solid mode is live via the accent's custom tile; gradient mode (stop rail, add/drag/remove stops, linear-angle slider, true-curve preview) is built and model-tested, awaiting its first fill consumer (per-entity/pack background in the editor).
The primary listener binds loopback. Optional remote listeners are separate, explicitly enabled boundaries with join secrets, device approval, and host-only controls. Uploads parse through the same fail-closed adapters as the CLI (zip-bomb caps included). Scripts inside entities remain data everywhere. App-manifest SVGs are sanitized before insertion in both the Dock and catalog (scripts/foreignObject/handlers stripped) because the shell invites third-party drop-ins.