Appearance
Admin web (the SPA)
Chapter 18. Chapters 7 and 17 covered the control-plane API. This chapter is the app that drives it — the admin SPA that merchants and staff actually use to onboard, edit themes, and manage a store.
The merchant/admin single-page app — apps/admin-web (@ratio/admin-web). Stack: React 19, Vite 6, react-router 7, Clerk, Monaco, Vitest. Notable absences tell you the philosophy: no react-query / zustand / SWR, no Ant Design, no @ratio/control-plane-client — data flow is a hand-rolled typed fetch client plus React context, and the UI is a bespoke CSS design system. The one workspace dep in the bundle is @ratio/design-tokens, aliased to its TS source so the editor's tokens stay byte-identical with the storefront renderer.
Entry & routing
Provider order (in main.tsx) is load-bearing: ThemeProvider → BrowserRouter → ClerkProvider — theme is outermost so it drives both the app chrome and Clerk's widget appearance; the Clerk publishable key throws at module load if unset. App.tsx splits <SignedOut> (a bespoke sign-in card) from <SignedIn> (<ErrorBoundary> → AuthedRoutes). AuthedRoutes fires two loads on mount — api.listStores() and api.me() — and blocks rendering until both resolve, so the role-based landing is correct; everything downstream gets { api, stores, me, reload } via context. The route tree:
| Path | Element | Note |
|---|---|---|
/stores/new | OnboardingWizard | chrome-less; before /:storeId so "new" isn't an id |
/admin/* | RequireAdmin → platform | users · stores · base-theme · base-theme/edit |
/stores | StoresLauncher | multi-store launchpad |
/stores/:id/themes/:themeId/editor | FullScreenEditorPage | full-screen, outside the shell (lazy) |
/stores/:id/themes/:themeId/customize | FullScreenCustomizePage | full-screen, outside the shell (lazy) |
/stores/:id/* | MerchantLayout (shell) | themes · pages · domains · commerce · settings · access · audit · danger |
The two editor routes sit outside MerchantLayout (they own the whole screen). Route ordering matters — new and the editor segments are declared before /:storeId so they aren't captured as a store id.
State — three hand-rolled contexts
No global store library. common/store-context.tsx provides the app-wide StoreData { api, stores, me, reload, openCreate } and the per-store outlet context MerchantCtx { api, store } (via useMerchant()); resolveStore matches a URL param by host, hosts[], or id, so old domain URLs still resolve. common/theme.tsx holds light/dark/system (persisted; 'system' removes data-theme so CSS prefers-color-scheme wins). common/ui.tsx carries the toast context. The pattern throughout: a container owns state via useState/useRef + the injected api; there is no cache layer, so each panel load()s on mount and exposes a reload.
The API client — common/api.ts
createApi(baseUrl, getToken, fetchImpl) returns a frozen object of ~50 typed methods (export type Api = ReturnType<…>; getToken/fetchImpl injected so it's testable headless). The request core attaches authorization: Bearer <Clerk JWT>, defaults JSON (a FormData body skips content-type for uploads), and uses an AbortController with a 15s timeout (assistant 90s, uploads 30s). Non-2xx → ApiError(status, …) (unwrapping {error}/{message} envelopes); timeouts/network → a friendly ApiError(0, …) so loaders never hang; pickArray throws if an expected list field is renamed (a malformed response surfaces as an error, not an infinite spinner). Bundle/page saves round-trip a revision token and get a 409 if another editor moved the draft.
The feature map — features/*
| Feature | What it is |
|---|---|
theme | the Monaco code editor + the visual customizer (the biggest feature — below) |
onboarding | the store-creation wizard (below) |
pages | the page-builder (list + PageEditor from api.pbCatalog()) |
commerce / settings | connect the commerce merchant id; per-capability provider pins; rename |
domains | connect/verify custom domains, DNS + SSL status |
audit | the Activity view — server-side filter/sort/page via the shared DataTable |
assistant | "Ask Ratio" (AskRatio) — calls api.assistant, renders reply + actions[] |
access | mint a short-lived store-scoped agent token for a bring-your-own AI (ADR-007) |
admin | the platform console — all users/stores, the base-theme propagation + base editor |
stores / shell | the multi-store launcher; MerchantLayout/PlatformLayout, ⌘K palette, nav |
The theme editor & customizer
Both surfaces edit the same bundle draft (ThemeFiles = Record<path, source>) with the same optimistic revisionRef, and both drop the synthesized manifest.json so a full-tree save can't clobber a platform-managed file. Container/presentational is strict — a big container owns all state and composes dumb editor-* / customizer-* children.
- Code editor — a VS Code-style workbench (activity bar, explorer, Monaco, live preview). Monaco is self-hosted and worker-less (no CDN, no language server; Liquid is highlighted as HTML), and uncontrolled so our edits don't fight the cursor — external writes (the Blocks/Assets panels rewriting the same JSON) reconcile through one dedicated
pushEditOperationseffect. A 409 on save keeps the dirty buffer ("changed elsewhere"); a 400 parses{issues:[{path,error}]}into per-file messages. A 500ms-debounced live preview re-renders on edit. - Visual customizer — a sections rail + a live-preview iframe + in-canvas selection. Its heart is the preview bridge:
The iframe is opaque-origin (allow-scripts without allow-same-origin) so theme scripts can't touch the admin session; messages are authed by event.source + a source:'rt-customizer' tag (origin is null). The bridge relies on server-emitted data-rt-section / data-rt-block markers that exist only in editor preview mode. A stale-index guard silently drops any in-canvas action when the preview no longer matches the current files (an index into a stale render could point at a section that no longer exists).
Version history offers owner-only per-version rollback + reset-to-base; the themes grid (themes-list) is the launch point into the full-screen editor/customizer.
Onboarding
A 4-step chrome-less wizard (Connect → Store → Design → Launch), its {step, data} persisted in sessionStorage (6h TTL, step-clamped) so a refresh doesn't lose progress or re-submit the address.
A live store is created at Step 2, not Step 4
Despite "draft" in the code comments, Step 2's Continue calls createStore, which creates and publishes a live store on the default bundle theme (stamping storeId/themeId). Step 3 (Design) then adopts the chosen base ⊕ brand tokens, and Step 4 (Launch) republishes. This is why the sessionStorage persistence matters — a mid-flow refresh must not re-create the store and hit "domain already connected."
The design system & conventions
No component library (Ant Design is deliberately avoided) — a bespoke CSS system under src/styles/ driven by @ratio/design-tokens, plus hand-rolled primitives in common/: a focus-trapping Dialog, a generic controlled DataTable<T> (serving both client-side Pages and server-side Activity), PageHeader, the ⌘K CommandPalette, an inline-SVG Icon set, and a toast system. Container/presentational is pervasive: a route wrapper pulls context → a *Panel/*List container owns state + api → dumb children in their own files; pure transforms (block-ops, template-blocks, wizard-state, host) are extracted with colocated tests. Built with Vite (tsc -b && vite build) and deployed to Cloudflare Pages with a _redirects SPA fallback so deep links resolve.
Gotchas
- Env fails differently —
VITE_CLERK_PUBLISHABLE_KEYthrows at load;VITE_ADMIN_API_URLsilently defaults to:8787. - Two blocking loads gate the app —
listStores+me; a persistently failing/mesilently hides admin/owner affordances rather than erroring. revisionRefis a ref, not state — a 409 means the draft moved; the code editor keeps your buffer, the customizer asks you to reload.- Monaco is worker-less — Liquid highlights as HTML; external file writes must go through the reconcile effect or you lose edits.
- Preview iframes are opaque-origin — postMessage auth is by
event.source, not origin; thedata-rt-*markers exist only in editor preview mode. me.isPlatformAdmingrants a syntheticadminrole on every store — the UI must not hide owner actions from platform admins (the backend already bypassesrequireRole('owner')).- No shared cache — every panel re-fetches on mount; a mutation must call the right
reloador the UI goes stale.