# Editor Deep dive on the admin app and the visual editor — how the SPA boots, how routing works, how the editor store mutates pages, how the canvas renders. The frontend is a single React 19 + Vite SPA mounted at `/admin`. Inside it, two concerns coexist: the **admin shell** (auth, navigation, workspaces, plugin host UI) and the **visual editor** (`src/admin/pages/site/`). They share auth, routing, theming, and the spotlight palette; they differ in everything else — the editor owns a heavy Zustand store and a custom rendering pipeline. --- ## TL;DR - **Entry:** `src/admin/main.tsx` mounts `` with React 19 root-level error callbacks. `flushSync` forces the initial render synchronous to cut LCP. - **Router:** `src/admin/lib/routing/` — in-house router replacing `react-router-dom`. 10 routes, all wrapped in a per-route `` and ``, plus a final `path="/admin/*"` catch-all redirecting unknown admin URLs to `/admin/dashboard` (login form when unauthenticated) instead of rendering an empty tree. Public-site 404s are NOT claimed — the publish pipeline's NotFound handling owns those. - **Cold path:** entry chunk is tiny. `AuthenticatedAdmin` is `React.lazy` and only loads post-login. Each workspace page is wrapped in `prewarmedLazy(...)`: the active page fires its import at module evaluation; the remaining pages pre-warm via `requestIdleCallback` after first paint so subsequent nav is synchronous (no Suspense flicker). - **Workspaces:** `dashboard`, `site` (the editor), `content`, `data`, `media`, `plugins`, `users`, `ai`, `account`, `pluginPage`. Capability-gated by `canAccessWorkspace`. - **Editor store** lives at `src/admin/pages/site/store/`. Zustand + Mutative (`zustand-mutative`) + `subscribeWithSelector`. 12 slices, one source of truth for the page tree. Undo/redo uses patch-based history (O(change) per step, not O(site)). - **Active tree routing:** `mutateActiveTree(fn)` in `siteSlice` is the **only** place that branches on page-mode vs. VC-mode. The 11 named mutation actions are one-liners that delegate to it. - **Canvas:** `src/admin/pages/site/canvas/` renders the page tree into per-breakpoint `IframeFrameSurface` iframes. Two views: **design** (multiple breakpoints side-by-side with pan/zoom) and **live** (single real-size editable frame with normal scrolling). Design mode paints iframe shells with detailed skeletons first, mounts the active breakpoint's node tree after the first paint, then fills inactive breakpoint frames on idle time. Three canvas ring tokens: `--canvas-selection-ring` (neon green, selected node), `--canvas-hover-ring` (neon pink, hovered node), `--canvas-selector-ring` (neon orange, selector-panel match sweep). - **Spotlight:** Cmd+K palette at `src/admin/spotlight/`. Always available across workspaces. Owns its own command registry, providers, and scopes. --- ## Process — what loads when ```text GET /admin/site │ ▼ dist/index.html (one HTML file for the whole SPA) │ ▼ loads ~96 KB gz of entry chunk │ src/admin/main.tsx │ ├─→ ← in-house router (src/admin/lib/routing/) │ ├─→ ← src/admin/router.tsx │ │ │ └─→ (eager-imported) │ │ │ │ AdminEntry calls useAdminBoot() — probes the session. │ │ Phase = 'login' → renders . │ │ Phase = 'editor' → React.lazy-loads . │ │ │ └─→ (post-login chunk, ~heavy) │ │ │ │ Module evaluation: fires preload() for the active │ │ page only (the one matching window.location.pathname). │ │ │ └─→ │ └─→ │ └─→ │ └─→ > │ └─→ │ └─→ ← real Site shell │ └─→ (post-paint lazy) │ ▼ SitePage mounts the real Site toolbar/chrome first. In production, AdminCanvasLayout starts the editor body import after the shell has painted; the body chunk contains DnD, the canvas, panels, first-party module registration, loop sources, and code-editor overlays. ``` Why the split: - **`main.tsx`** is the only module pre-login can compile. Keep it minimal. - **`AdminEntry`** is eager-imported but small (~10 KB gz). Owns the boot probe and gate. - **`AuthenticatedAdmin`** is `React.lazy` so the login screen doesn't pay for SpotlightRoot, the editor store, or any workspace page chunk. - **Workspace pages** are wrapped in `prewarmedLazy(...)` — the active page pre-warms at module evaluation (alone, so no 8 sibling imports stealing CPU); after first paint a `requestIdleCallback` pre-warms the remaining pages. `/admin/site` delays sibling preloads slightly so `AdminCanvasEditorBody` claims the first post-paint slot. The result: subsequent workspace navigation renders synchronously with no Suspense fallback. - **Plugin runtime** (`globalThis.__instatic`) is installed lazily by `ensurePluginRuntime()` in `pluginRuntimeBootstrap.ts`. It's triggered on first admin-layout mount via `useInstalledEditorPlugins`, so plugin code never runs before login and the runtime download stays off the dashboard critical path. --- ## Routing `src/admin/lib/routing/` contains the in-house router (`Router`, `Routes`, `Route`, `Navigate`, `Link`, `useLocation`, `useNavigate`, `useParams`). Replaces `react-router-dom` for the 10-route admin app. Use the in-house router for every internal admin navigation, including links rendered by the site editor. `react-router-dom` and raw `` hard navigations are banned in admin UI by `admin-router-usage.test.ts`. `src/core/` and `src/modules/` stay router-free because they are shared engine / published-page code, not admin UI. The route table (`src/admin/router.tsx`): | Path | Component shorthand | |-----------------------------------------|-----------------------------------| | `/` → redirect to `/admin/dashboard` | `` | | `/admin` → redirect to `/admin/dashboard` | `` | | `/admin/dashboard` | `` | | `/admin/site` | `` (the editor) | | `/admin/content` | `` | | `/admin/data` | `` | | `/admin/media` | `` | | `/admin/plugins` | `` | | `/admin/users` | `` | | `/admin/ai` | `` (AI credentials, models, defaults) | | `/admin/account` | `` | | `/admin/plugins/:pluginId/:pageId` | `` | Every route is wrapped with `withRouteBoundary(...)` → `` and `}>`. The error boundary resets when the pathname changes so a broken route never strands the user. --- ## URL state and workspace deep links `src/admin/lib/urlState/` provides two hooks that make workspace selections directly bookmarkable and shareable via the query string, without touching the router: ```ts import { useInitialQueryParams, useUrlQuerySync } from '@admin/lib/urlState' ``` ### Why a separate module Workspace selections still need bookmarkable query strings without replaying route navigation. `urlState` solves this by operating on `window.history.replaceState` directly — no `instatic:locationchange` event, no route re-match, just a query-string update that keeps the pathname stable. ### `useInitialQueryParams()` Captures the `URLSearchParams` present at first mount using a `useState` lazy initializer (runs exactly once). Subsequent `useUrlQuerySync` writes never change what the one-shot deep-link read observes. ```ts const initialParams = useInitialQueryParams() const pageSlug = initialParams.get('page') // read once on load ``` ### `useUrlQuerySync(params, options?)` Mirrors a key→value map into the URL via `replaceState` on every render where the values change. - A non-empty string value sets the param (`?key=value`). - `null` or empty removes the param. - Keys NOT in `params` are left untouched — workspaces own only their own params. - `replaceState` (never `pushState`) so navigating between rows/pages doesn't flood the browser back stack. - The `enabled` option (default `true`) lets callers gate the sync on a load-complete flag so an in-progress deep link isn't overwritten before the selection settles. ```ts useUrlQuerySync( { table: selectedTable?.slug ?? null, row: selectedRowId }, { enabled: !loadingTables }, ) ``` ### URL contract per workspace | Workspace | URL form | Notes | |-----------|----------|-------| | **Site editor** | `/admin/site` | Home page (slug `index`); bare URL is canonical — no `?page=` written | | **Site editor** | `/admin/site?page=` | Opens the page with that slug | | **Site editor** | `/admin/site?table=pages&row=` | Cross-workspace deep link from Data workspace; normalized to `?page=` after consume | | **Site editor** | `/admin/site?table=components&row=` | Opens the Visual Component with that id; normalized after consume | | **Content** | `/admin/content?table=&row=` | Opens the collection and entry | | **Data** | `/admin/data?table=&row=` | Opens the table and row | ### Site editor URL sync — `useSiteEditorUrlSync` `src/admin/pages/site/hooks/useSiteEditorUrlSync.ts` implements a bidirectional sync for the site editor: 1. **READ (once, after load):** consumes `?page=` or `?table=…&row=…` from the initial URL and applies the selection to the editor store. Guarded by a ref so it fires at most once per mount. 2. **WRITE (ongoing):** mirrors the active page's slug back into the URL so the address bar stays current. The home page (`slug === 'index'`) is always represented as the bare `/admin/site` — the `?page=` param is omitted. `usePersistence` reloads an already-hydrated editor store before URL consumption when the initial URL points at a page/component row that is missing from memory. Data-workspace mutations to system `page` and `component` tables also call `requestCmsSiteReload()` (`src/admin/state/adminEvents.ts`), which is retained if the Site editor is not mounted yet and consumed by `usePersistence` on the next mount. --- ## Auth and access After login, every route renders ``. Before rendering the workspace, it calls `canAccessWorkspace(currentUser, section)`. If the user's capabilities don't include the workspace, it ``s to `firstAccessibleWorkspace(currentUser)` (e.g. a contributor with only `media.manage` lands on `/admin/media`). `src/admin/access.ts` owns the capability-to-workspace mapping. `src/admin/workspace.ts` owns the `AdminWorkspace` union and the workspace paths. Sensitive actions (delete user, revoke another device, sign out all devices) require step-up auth — wrapped in `` so the step-up dialog is available from anywhere in the shell. --- ## Admin shell layout ### The three layouts Every admin page picks one of three root layouts from `src/admin/layouts/`. Import directly from the per-layout path so rolldown can split them into separate chunks (there is deliberately no barrel). | Layout | Used by | Bundle contract | |---|---|---| | `AdminCanvasLayout` | Site editor (`SitePage`) | Site shell — toolbar/chrome, persistence, editor store, and a post-paint lazy boundary for the heavy body. | | `AdminWorkspaceCanvasLayout` | Content, Data, Media | Canvas chrome (toolbar, sidebar, full-height canvas) WITHOUT site-only modules (no editor store, PropertiesPanel, DnD, or CodeMirror). | | `AdminPageLayout` | Plugins, Users, Account, plugin admin pages | Lightweight — toolbar + centered scrollable page body. **Must not import the editor store.** Site name and favicon come from `useSiteSummary` + the `adminUi` Zustand store. | `AdminCanvasLayout` keeps the real editor shell mounted while `usePersistence()` loads the draft site document. In production it renders the toolbar/chrome first and lazy-loads `AdminCanvasEditorBody` after paint. The body owns the permanent rail, sidebars, canvas, DnD context, `ConfirmDeleteProvider`, `CodeEditorPanel`, first-party module registration, and loop-source registration. Rare modal surfaces such as `ImportHtmlModal` stay behind their own open-state lazy boundary inside the body. Loading states use the same local skeleton vocabulary: the editor-body lazy fallback and the canvas no-site fallback both render `CanvasFrameSkeletonFrame`, and sidebars use compact skeleton rows or blocks. Once the document is in the store, every breakpoint frame mounts immediately — the tree is already in memory, so there is nothing to stagger. The `adminUi` store (`src/admin/state/adminUi.ts`) is the small cross-shell state store: settings-modal open flag, site-import modal open flag, site name/favicon for the toolbar brand position, and `activeLivePath` — the public path the "Open live page" toolbar button opens. The toolbar renders a compact skeleton while the site identity is loading, then renders the configured site favicon when present; otherwise it shows the site name with the same compact bold typography as the admin navigation. The site name is exposed through the shared tooltip after identity loads. It lives outside `@site/` so `AdminPageLayout` can subscribe without pulling in the 165 KB editor graph. The editor's `settingsSlice` mirrors its state into `adminUi` via a registered bridge so both are always in sync. Canvas chrome state for Content, Data, and Media lives in `src/admin/state/workspaceLayout.ts`, with persistence in `src/admin/state/workspaceLayoutStorage.ts` and `src/admin/state/useWorkspaceLayoutPersistence.ts`. That store owns non-site sidebar widths, right-panel collapsed state, and the Data sidebar toggle. Site editor layout remains site-only: `src/admin/pages/site/hooks/useEditorLayoutPersistence.ts` subscribes to the editor store and delegates the storage mapping to `src/admin/pages/site/layout/siteEditorLayoutPersistence.ts`. `activeLivePath` is written by the active workspace and cleared on unmount. The Site editor delegates to `useActiveLivePath` (`src/admin/pages/site/hooks/useActiveLivePath.ts`) inside `AdminCanvasEditorBody` — it resolves templates to a routable path rather than their own (non-routable) slug: an everywhere template maps to the previewed page's path; a postTypes template maps to the previewed published row's permalink. Both resolutions follow the same selection as the `TemplateModeControl` preview dropdown so the button always opens what the canvas is showing. The Content workspace writes `activeLivePath` inline inside its own layout; non-editor layouts never write it, so it stays `null` there naturally. `AdminWorkspaceCanvasLayout` and `AdminPageLayout` both call `useSiteSummary()` — a lightweight hook that fires a single `cmsAdapter.loadSite()` per session and writes the name + favicon into `adminUi`. The Site editor's `usePersistence` writes the same fields when it hydrates the full site, so after navigating to `/admin/site` the toolbar updates without a second fetch. When a Content or Data workspace has a right-side panel available but the user closes it, `AdminWorkspaceCanvasLayout` renders a compact top-right canvas notch to reopen that panel without changing the selected row or entry. The notch reads and writes `useWorkspaceLayout`; it does not touch the Site editor store. ```text src/admin/ ├── main.tsx ← React root mount ├── AdminEntry.tsx ← boot probe + auth gate ├── AuthenticatedAdmin.tsx ← post-login chunk (prewarmedLazy scheduler) ├── AppLoadingScreen.tsx ← shared loading screen ├── router.tsx ← admin route table ├── access.ts ← workspace gating ├── workspace.ts ← AdminWorkspace union ├── session.tsx, sessionContext.ts ← AdminSession context ├── pluginRuntimeBootstrap.ts ← installs globalThis.__instatic (lazy) │ ├── layouts/ │ ├── AdminCanvasLayout/ ← Site shell + lazy editor body │ ├── AdminWorkspaceCanvasLayout/ ← canvas shell for Content/Data/Media │ └── AdminPageLayout/ ← lightweight page shell (no editor store) │ ├── state/ │ └── adminUi.ts ← cross-shell Zustand store (settings, site import, site name/favicon) │ ├── lib/ │ ├── routing/ ← in-house router │ ├── urlState/ ← workspace-agnostic URL query-string sync │ ├── prewarmedLazy.ts ← React.lazy alternative with explicit preload + sync fast-path │ ├── useAsyncResource.ts ← canonical single-resource async load hook │ └── useAdminNavigate.ts │ ├── preauth/ ← login / setup flows ├── shared/ ← StepUp, dialogs, AdminSectionNavigation, AdminContextMenuGuard, ... ├── modals/ ← workspace-level modals ├── plugin-host-hooks/ ← React hooks plugins call via the SDK ├── plugin-host-ui/ ← Host UI primitives plugins call via the SDK ├── spotlight/ ← Cmd+K palette │ └── pages/ ← workspace implementations ├── dashboard/ ← stats, activity, publish lineup ├── site/ ← THE VISUAL EDITOR (see below) ├── content/ ← post / page list and editor ├── data/ ← data_tables management (see docs/features/data-workspace.md) ├── media/ ← media manager ├── plugins/ ← plugin install / configure ├── users/ ← user management ├── ai/ ← AI credentials, defaults, usage audit ├── account/ ← own-account settings └── ... ``` ### Cross-page primitives - **`SpotlightRoot`** — Cmd+K command palette. Owns its own command registry (`spotlight/commands/`), provider runner (`providers/`), scopes, keybindings, recents, telemetry. Available from every workspace. - **`AdminSectionNavigation`** — top-of-screen workspace switcher. - **`AccountMenuButton`** — top-right avatar / account menu. - **`Panel`, `PanelHeader`, `SidebarResizeHandle`** — generic floating-panel chrome reused across the editor, content, and data workspaces. - **`StepUp`** — re-auth dialog gating sensitive actions. - **`AdminContextMenuGuard`** (`src/admin/shared/AdminContextMenuGuard/`) — mounted at root level in `main.tsx` alongside the router. Intercepts every native `contextmenu` event on the document. If the event was already `preventDefault`-ed by an app context menu (or fired inside a `[role="menu"]` element), the guard is silent. Otherwise it prevents the native browser menu and shows a small animated danger flash at the cursor to signal "no context menu here." App context menus (e.g. `DataRowContextMenu`, `DataTableContextMenu`) call `preventDefault()` at their source, so the guard only fires for truly unhandled right-clicks. - **`useAsyncResource`** (`src/admin/lib/useAsyncResource.ts`) — canonical hook for single-resource async loads. Runs `loader` on mount and whenever `deps` change, tracks `{ data, loading, error }`, discards superseded responses, and exposes a stable `refresh()`. The loader receives an `AbortSignal` for in-flight cancellation. Reach for this first when a screen loads one resource. For the full decision guide — when to use it and what patterns intentionally don't use it (optimistic collections, multi-fetch orchestrators, module-level cached loads, non-fetch effects) — see [`docs/reference/use-async-resource.md`](../reference/use-async-resource.md). --- ## The visual editor (`src/admin/pages/site/`) The editor is a self-contained app inside the admin shell. It owns: - A canvas that renders the page tree into per-breakpoint iframes. - A heavy Zustand store with 12 slices. - Left and right sidebars with collapsible panels. - A toolbar with publish / save / zoom / the module inserter. - Property controls bound to selected nodes. ### Folder structure ```text src/admin/pages/site/ ├── SitePage.tsx ← Site route; renders AdminCanvasLayout ├── EditorPermissionsProvider.tsx, editorPermissionsContext.ts │ ├── store/ ← Zustand + Mutative store (see below) │ ├── store.ts ← root store assembly │ ├── types.ts ← EditorStore type union │ ├── slices/ ← one file per slice │ ├── insertLocation.ts ← drop-target geometry │ └── clipboard/ ← copy/cut/paste serializers │ ├── canvas/ ← canvas rendering (see below) ├── sidebars/ ← LeftSidebar, RightSidebar, PanelRail ├── panels/ ← per-panel implementations (DomPanel, PropertiesPanel, ...) ├── property-controls/ ← right-panel form controls ├── module-picker/ ← module inserter modal + compact context-menu picker ├── code-editor/ ← CodeMirror-backed code panel ├── toolbar/ ← top toolbar ├── preview/ ← preview iframe runtime ├── explorer-actions/ ← DOM / Site explorer context menus ├── agent/ ← AI agent panel ├── hooks/ ← cross-cutting editor hooks ├── layout/ ← shell layout └── ui/ ← editor-local UI primitives (Tree*, etc.) ``` The heavy body for that route lives beside the layout at `src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx`. It is not in `src/admin/pages/site/` because the body is part of the Site shell split: the route chunk stays small, while the editor runtime graph remains one lazy boundary deeper. ### Site Explorer `SiteExplorerPanel` (`src/admin/pages/site/panels/SiteExplorerPanel/`) is the editor's concept browser for pages, templates, Visual Components, stylesheets, and scripts. Every section renders through `SiteExplorerTreeSection`, which uses the shared `Tree*` primitives from `src/admin/pages/site/ui/Tree/` for depth indent, chevrons, selection chrome, and DnD row affordances. Organization is persisted in `site.explorer` on the site shell. Folders are decorative and flat: they group editor rows only, and never change page slugs, public URLs, component identity, or file paths. The homepage is the page whose slug is `index`; it is always pinned as the first Pages row and does not receive organization drag handlers. **Store actions** on `siteSlice` for explorer management: | Action | Effect | |---|---| | `createExplorerFolder(sectionId, name)` | Creates a folder in the given section, returns the new folder id | | `renameExplorerFolder(sectionId, folderId, name)` | Renames a folder | | `deleteExplorerFolder(sectionId, folderId)` | Deletes a folder; items that were inside it move to the section root | | `moveExplorerFolder(sectionId, folderId, nextIndex)` | Reorders a folder within the root level | | `moveExplorerItem(sectionId, itemId, parentFolderId, nextIndex)` | Moves an item to a folder or the root; the homepage cannot be moved | | `setPageAsHomepage(pageId)` | Promotes a page to `slug='index'`, demotes the previous homepage to a generated slug, pins the new homepage at the section root | | `convertPageToTemplate(pageId, payload)` | Sets `page.template` config; moves the row from Pages to Templates section in the explorer | | `convertTemplateToPage(pageId)` | Clears `page.template` and strips `dynamicBindings` from all nodes; moves the row back to Pages | **DnD architecture:** Organization drag-and-drop (`useSiteExplorerDnd`) uses `useDndMonitor` to hook into the outer `DndContext` that lives in `AdminCanvasEditorBody`. The explorer DnD hook only reacts to `siteExplorerItem` / `siteExplorerFolder` drags, which keeps Site Explorer focused on opening and organizing site artifacts rather than inserting components onto the canvas. **Section model:** `buildSiteExplorerTreeSection` in `siteExplorerModel.ts` converts the flat placement arrays from `site.explorer` into a typed tree model (`SiteExplorerTreeSectionModel`) that `SiteExplorerTreeSection` renders — pinned items come first, then root entries (folders and items) sorted by `order`, with each folder's items sorted within it. **Reconciliation:** `reconcileSiteExplorerInPlace(site)` is called on load, on item-lifecycle mutations (page/template conversions, file creates/deletes, VC creates/deletes), and before any move operation. It drops stale placements, appends newly-created items, filters out non-ejected generated files, and re-pins the homepage. ### Editor store `src/admin/pages/site/store/` is the central state for the editor. Zustand with the `mutative` middleware from `zustand-mutative` (mutations are written as direct draft-mutation; Mutative produces structural sharing) and `subscribeWithSelector` (granular subscriptions without React context re-renders). `enableAutoFreeze: true` mirrors Immer's dev guard against accidental external mutation. **Undo/redo** uses patch-based history: every undoable mutation captures Mutative `[next, forward, inverse]` patch pairs scoped to the `SiteDocument`. Undo applies `entry.inverse`, redo applies `entry.forward` — O(change) in both time and memory, not O(site). A 50-deep history holds kilobytes of patches instead of hundreds of megabytes of full-site clones. See [`docs/reference/editor-history.md`](../reference/editor-history.md). The store is composed of **12 slices**, each created by a factory in `store/slices/`: | Slice | Owns | |------------------------|----------------------------------------------------------------------------| | `siteSlice` | `SiteDocument` (pages, nodes, breakpoints, settings, classes, files). The page tree itself. | | `selectionSlice` | `selectedNodeId`, `hoveredNodeId` | | `canvasSlice` | Zoom, pan, `activeBreakpointId`, `activeConditionId`, `canvasMode` ('select'|'pan'|'insert'), `canvasView` ('design'|'live'), `runScripts` | | `uiSlice` | Site editor panel visibility, unsaved-changes flag, insert picker, `componentizeEditorRequest` | | `classSlice` | Style-rule CRUD, node ↔ class assignment, ambient selector creation | | `filesSlice` | `SiteFile` CRUD | | `visualComponentsSlice`| Visual Component CRUD | | `settingsSlice` | Settings modal open/close + active section | | `agentSlice` | AI Agent Panel state + streaming | | `sitePanelSlice` | Dependency manifest + site runtime settings | | `clipboardSlice` | Copy / cut / paste of layer subtrees, persisted editor-wide | | `inlineEditSlice` | `activeInlineEdit` — the canvas inline text-edit session (double-click to edit) | The combined `EditorStore` type lives at `store/types.ts` so each slice can import it without going through `store.ts` (this eliminates the historical store ↔ slice cycles). **Constraint #182:** The page tree is the single source of truth. No panel may maintain a local copy of node data — they read from the store via selectors. ### `mutateActiveTree` — the only mode-aware function The store routes mutations to the **active tree** (page in page-mode, VC in VC-mode) through one function in `slices/site/`: ```ts function mutateActiveTree(fn: (tree: NodeTree) => void): void { if (mode === 'page') fn(activePage) // Page IS NodeTree else fn(vc.tree as NodeTree) // structurally identical cast } ``` The 11 named tree-mutation actions on the store (`insertNode`, `deleteNode`, `updateNodeProps`, `setBreakpointOverride`, `clearBreakpointOverride`, `renameNode`, `toggleNodeLocked`, `toggleNodeHidden`, `moveNode`, `duplicateNode`, `wrapNode`) are **one-liners that call `mutateActiveTree`**. They MUST NOT contain their own `kind === 'visualComponent'` branch — gated by `no-vc-mode-branches-in-mutations.test.ts`. Why this matters: page trees and VC trees both have shape `NodeTree`. The tree-agnostic mutations in `src/core/page-tree/mutations.ts` work on any `NodeTree`. The store doesn't need to know which kind of tree it's mutating — that's the sole job of `mutateActiveTree`. See [docs/reference/page-tree.md](reference/page-tree.md) for the `NodeTree` type and the mutation cookbook. ### Selectors and subscriptions Components subscribe to the store via `useEditorStore(selector)`. `subscribeWithSelector` keeps re-renders narrow: ```tsx import { useEditorStore } from '@site/store/store' function NodeName({ nodeId }: { nodeId: string }) { const name = useEditorStore((s) => s.site.activePage.nodes[nodeId]?.name) return {name} } ``` Selectors are pure reads. Mutations go through actions (`useEditorStore.getState().insertNode(...)`). --- ## The canvas `src/admin/pages/site/canvas/` is the rendering pipeline. Two key ideas: ### 1. Design mode and live mode `CanvasRoot` switches between two rendering surfaces based on `canvasView`: - **Design mode** (`canvasView === 'design'`): `CanvasRoot` → `CanvasTransformLayer` → `BreakpointFrame` → `IframeFrameSurface` → `NodeRenderer`. Each breakpoint gets its own iframe rendered side-by-side inside the pan/zoom transform layer. The author sees all breakpoints at once and can zoom in/out. The canvas opens at 50% (`INITIAL_ZOOM`) so several frames fit in view; reset (Cmd/Ctrl+0, the toolbar % button) goes to 100% (`RESET_ZOOM`). - **Live mode** (`canvasView === 'live'`): `CanvasRoot` → `CanvasLiveSurface` → `IframeFrameSurface` → `NodeRenderer`. A single real-size frame at 100% width (optionally clamped to a selected breakpoint's width) scrolls normally. The toolbar zoom controls pin to 100% and disable with the reason in their tooltip ("Live mode always shows 100% zoom.") — the stored design-canvas zoom is preserved for the return to design mode. Resizable with side handles. Because the live frame is flush with the top of the canvas surface, both chrome controls — `CanvasNotch` (top-center) and `CanvasModeToggle` (top-left) — render in **peek** mode: they park above the top edge and roll down on hover/`:focus-within`, so they do not overlay the page's own header. In design mode they are always pinned. Both modes use the same `IframeFrameSurface` and the same `NodeRenderer` — they are fully editable (click-to-select, properties panel, structural edits all work). The only difference is the layout wrapper. They also share the loading treatment: while the page is hydrating, design mode renders a `CanvasFrameSkeletonFrame` per breakpoint and live mode renders the same `CanvasFrameSkeleton` inside its single frame's width model. Design mode mounts every breakpoint frame as soon as the page document is in the store. The node tree lives in memory, so there is no async load to stage and no per-frame stagger — each `BreakpointFrame` mounts its iframe shell and `NodeRenderer` tree directly. Skeleton frames (`CanvasFrameSkeletonFrame`) cover the only genuine wait: the document not being loaded yet (`page === null`). Each `IframeFrameSurface` boots with an empty `srcDoc` skeleton and portals the React node tree into the iframe's `` via `createPortal`. Why iframes: - **Style isolation.** Page CSS (`body { background: black }`, `>`, `+`, `:nth-child()`) works exactly as on the published page — no wrapping divs, no selector rewriting. - **Plugin module isolation.** Plugin canvas modules (`ModuleSandboxFrame.tsx`) run inside nested iframes with `sandbox="allow-scripts"` for security; the `IframeFrameSurface` outer frame is same-origin. - **Per-breakpoint viewport.** Each frame is sized to the breakpoint width, so `vw`/`vh` units, media queries, and scroll behaviour all match the published page. ### 2. Selection, hover, and inspect ladder overlays Selection rings and hover rings are absolutely-positioned overlay divs portaled into the canvas root—outside the iframe and the transform layer. Their 1px border is a `box-shadow` using `--canvas-selection-ring` (neon green) for selection and `--canvas-hover-ring` (neon pink) for hover. Because the rings live in the canvas root's coordinate space rather than inside the scaled transform layer, that 1px border stays exactly 1px at every zoom level. The two ring colors are the only chromatic UI on the canvas; they're bright enough to be visible against any user content. `BreakpointSelectionOverlay` owns these rings and all other canvas-local action chrome that must escape iframe overflow: the selected-layer toolbar and the Alt/Option inspect ladder. The selected-layer toolbar carries four actions, left to right: drag-to-reorder, **insert module** (`CanvasInsertModuleButton` — opens the full `ModuleInserterDialog`, the same modal command surface as the main toolbar's "+ Add" button, rather than an anchored dropdown that would mis-position against the zoom/transform-scaled canvas and its breakpoint iframes), duplicate, and delete. Both inserter entry points share the `useInsertInserterItem` hook, so the picked node routes through `resolveInsertLocation` against the current selection — nesting as a last child of a container target or landing as a sibling-after of a leaf target, identical to every other insert flow. Holding Alt/Option while hovering a canvas element opens a momentary tree-shaped target picker in the parent canvas root, anchored above or below the hovered element and clamped to the visible canvas. The picker is built from the active `NodeTree`, not raw DOM parents: ancestors appear above the hovered node, the hovered node is the current row, and the first visible child appears below it. ArrowUp/ArrowDown move the highlighted target, Enter commits selection, clicking a row commits immediately, and releasing Alt/Option or pressing Escape dismisses the ladder. Committing through the ladder changes the selected node without taking focus from the current side panel, so the Properties panel stays open while users retarget parent or child layers. Ring and toolbar positions are computed on each animation frame via a RAF loop (simpler than wiring ResizeObserver/MutationObserver/IntersectionObserver to every mutation source — scroll, layout shift, zoom, content animation). The loop only starts when `hasOverlayWork` is true — at least one selection ring, hover ring, selector-affinity highlight, or toolbar is visible. When there is no overlay work the effect returns early so idle breakpoint frames incur no RAF cost. **When adding a new visible overlay type to `BreakpointSelectionOverlay`, update `hasOverlayWork`** so the loop arms correctly. Each tick is split into a read phase and a write phase to keep the loop cheap at 60fps. The read phase resolves tracked elements through a `CanvasNodeElementCache` (`canvasNodeLookup.ts` — cached until the element disconnects or the iframe swaps documents, so no per-frame `querySelector` document scans), snapshots the shared iframe/canvas-root geometry once per tick via `createCanvasOverlayMeasureSession` (`canvasOverlayGeometry.ts`), and measures every rect — the toolbar anchors to the union of the ring rects already measured, never a second query/measure pass. The write phase then applies styles, skipping any write whose rect is already applied. Steady-state frames are therefore a few cached-layout reads with zero writes, and because no write lands between reads, changing rects never force per-ring reflows. **Keep new overlay work inside this read-then-write structure.** ### Inline text editing (double-click) Double-clicking a node whose module declares `inlineTextEdit` (`base.text`, `base.button`, childless `base.link`) edits the text **in place**: the node's own element inside the breakpoint iframe becomes the editor. `NodeRenderer` builds an `InlineEditBinding` and the module spreads `inlineEditableElementProps(binding)` onto its real root element, making it `contentEditable="plaintext-only"` (seeded once via `dangerouslySetInnerHTML` from the escaped initial value, with `\n` → `
`). There is no overlay and no typography mirroring — the author edits the actual published element, so the editing surface is byte-identical to what publishes. Every keystroke reads the text back with `readInlineEditableText(el)` (`el.innerText`) and commits live through `updateNodeProps`, so all breakpoint frames preview the edit; the burst coalesces into one undo entry. For single-line modules Enter commits + closes; for multiline `base.text`, Enter inserts a hard break (stored as `\n`, rendered as `
` everywhere) and Cmd/Ctrl+Enter commits. Blur commits + closes; Escape reverts via a single `undo()`. Canvas shortcuts (Delete/Cmd+D) are suppressed mid-edit by the `activeInlineEdit` guard in `useCanvasKeyboardShortcuts`. Session state is `activeInlineEdit` in `inlineEditSlice`. Full design: [`docs/features/canvas-iframe-per-frame.md`](features/canvas-iframe-per-frame.md) → "Inline text editing (in-place `contentEditable`)". ### CSS injection into the iframe Each iframe `` receives five `