Skip to content

feat(editor): user-saved layouts — save any subtree, re-insert it exactly#33

Merged
DavidBabinec merged 4 commits into
mainfrom
feat/user-saved-layouts
Jun 11, 2026
Merged

feat(editor): user-saved layouts — save any subtree, re-insert it exactly#33
DavidBabinec merged 4 commits into
mainfrom
feat/user-saved-layouts

Conversation

@DavidBabinec

@DavidBabinec DavidBabinec commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

What

Layouts in the module inserter, three groups: the user's saved layouts, per-plugin groups (labelled with the plugin's display name), and the seeded built-in presets. Users create layouts by right-clicking any layer (DOM panel or canvas) → Save as layout…, which snapshots the element + its whole subtree (props, breakpoint overrides, every referenced style rule) for exact re-insertion. Plugins ship layouts in packs, authored as clean HTML.

How

One snapshot engine, two front doors. Capture/restore was extracted from the clipboard slice into @site/store/subtreeSnapshot, and both paste and layout insertion run through it — a saved layout inserts exactly like pasting the original selection: fresh node ids, scoped classes cloned with remapped scope.nodeId, framework classes re-matched by name, regular classes reused-or-reimported.

Persistence. A fourth locked system table layouts (kind layout) joins posts/pages/components. Saved layouts load onto SiteDocument.layouts and save through the same incremental roster protocol as pages/components (PUT /admin/api/cms/layouts, per-layout dirty tracking from Mutative patches). Strict write validation enforces unique ids/names against the merged post-save roster; the tolerant load path drops corrupt rows and sanitizes richtext props.

Migration — in-place upgrade, no DB re-creation. Seeded by a new 017_layouts_system_table migration (001 baseline untouched). PG swaps the data_tables kind CHECK and seeds; SQLite rebuilds data_tables with the widened CHECK. Because data_rows.table_id is ON DELETE RESTRICT (fires immediately even under defer_foreign_keys, and SQLite ≥3.25 renames always rewrite child FK clauses), the migration runner gains Migration.disableForeignKeys — it toggles PRAGMA foreign_keys around that one migration and verifies foreign_key_check before re-enabling enforcement.

Editor.

  • layoutsSlice: saveNodeAsLayout / insertLayout / renameLayout / deleteLayout — all undoable site mutations.
  • "Save as layout…" sits next to Componentize in the layer context menu (page mode; disabled with an inline reason on the page root). Naming via LayoutNameDialog with inline duplicate-name errors.
  • Inserter items get wireFromTree previews and inline disabled reasons: snapshots carrying a base.outlet follow the outlet module's placement rules; in VC mode, snapshots whose component refs would create a dependency cycle are disabled. Refs to since-deleted VCs are stripped at insertion.
  • The Layouts section is composed by composeLayoutsSection: Saved → per-plugin groups (display names from a new pluginRuntime name registry fed by the editor plugin loader) → Built-in. Labels render only when more than one group is present.
  • Right-click a saved layout → Rename… / Delete (undoable, toast-confirmed). Layouts can be favorited into the canvas notch.

Plugin packs — HTML-authored layouts. definePack({ layouts: [{ id, name, html, css? }] }): authors write the markup they already have. compilePackLayout runs the host's HTML-import pipeline at plugin build time (instatic-plugin build installs a happy-dom window + the base module registry first): <style> blocks harvested, scripts stripped, elements mapped to base.* modules, CSS parsed into style rules with deterministic ids (<layoutId>/<className>) so re-inserting after a pack update reuses the site's class instead of importing a duplicate. Multi-root markup is wrapped in a container. The wire format (pack/site.json) carries compiled SavedLayout objects, so the host install path needs no DOM. Ids must be <pluginId>/-namespaced (validated at install) — re-syncs replace the plugin's own rows and never collide with user layouts; the prefix is also what the inserter groups on. Install summary, toast counts, and audit metadata report layouts; same visualComponents.register gate as the rest of the pack.

Module-size ceiling splits (700-line gate): uiSlice's save-dirty accumulator → saveTrackingSlice; the manage menu → SavedLayoutManageMenu; layout validation → persistence/validateLayouts with shared helpers in validationShared.

Integration hardening (post-implementation audit): import's SYSTEM_TABLE_IDS and the plugin cms.content.tables.create reserved-slug guard both know about layouts; export/import carries layout rows (round-trip-tested); the system-flag architecture gate checks all four tables.

Notes

  • Built-in layout presets are untouched (LAYOUT_PRESETS, still HTML-only; CSS for built-ins is a later task as discussed).
  • Pack layout CSS should stick to plain class/element rules — @media blocks compile to conditions the snapshot format doesn't carry yet.
  • Docs updated: CLAUDE.md, docs/architecture.md, docs/editor.md ("Saved layouts" section incl. plugin grouping), docs/features/content-storage.md, docs/features/content-workspace.md, docs/features/plugin-system.md (pack layouts + ID rules), docs/reference/database-dialects.md (RESTRICT-parent rebuild pattern).

Testing

  • New: layoutsSlice.test.ts (save/insert round-trip, scoped-class remap, outlet guard, dangling-VC-ref healing, rename/delete), layoutsWriteValidation.test.ts, schemas.test.ts, layoutFromRow.test.ts, pack layout cases in pluginPack.test.ts (namespacing, malformed/incoherent rejection, replace-by-id), HTML compile cases in builders.test.ts (class linking, <style> harvesting, multi-root wrap, deterministic ids, empty-HTML rejection), composeLayoutsSection ordering/label tests.
  • Existing suites updated for the fourth collection (cmsAdapter wire shapes, dirty tracking, fetch mocks, system-table flags, import/export round-trip).
  • bun test (5284 pass), bun run build, bun run lint all green.

🤖 Generated with Claude Code

DavidBabinec and others added 4 commits June 11, 2026 21:02
…ctly

Layouts in the module inserter now have two groups: the user's saved
layouts above the seeded built-in presets. Right-clicking a layer in the
DOM panel or canvas offers "Save as layout…", which snapshots the clicked
element + its whole subtree — props, breakpoint overrides, and every
referenced style rule — for exact re-insertion later.

Engine: capture/restore is the clipboard's snapshot machinery, extracted
into a shared module (@site/store/subtreeSnapshot) so paste and layout
insertion cannot drift: fresh node ids, scoped classes cloned with
remapped scope.nodeId, framework classes re-matched by name, regular
classes reused-or-reimported.

Persistence: a fourth locked system table `layouts` (kind 'layout') in
both migration baselines, loaded onto SiteDocument.layouts and saved
through the same incremental roster protocol as pages/components
(PUT /admin/api/cms/layouts, per-layout dirty tracking). Existing local
dev databases need a re-migrate (drop .tmp/dev.db) — pre-release.

Editor: layoutsSlice (save/insert/rename/delete, all undoable), naming
via LayoutNameDialog with inline duplicate-name errors, inserter items
with wireframe previews and inline disabled reasons for snapshot-borne
hazards (content outlets follow the outlet module's placement rules; VC
dependency cycles are blocked in component mode). Saved-layout items get
a right-click Rename…/Delete menu; refs to since-deleted Visual
Components are stripped at insertion time.

Splits forced by the 700-line module ceiling: uiSlice's save-dirty
accumulator moved to saveTrackingSlice; the saved-layout manage menu into
SavedLayoutManageMenu; saved-layout validation into
persistence/validateLayouts (shared error/sanitize helpers in
validationShared).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the layouts system-table seed out of the 001 baseline into a new
017_layouts_system_table migration so live installs upgrade in place —
no database re-creation. PG swaps the data_tables kind CHECK and seeds
the table; SQLite rebuilds data_tables with the widened CHECK. Because
data_rows.table_id is ON DELETE RESTRICT (fires immediately even under
defer_foreign_keys, and renames always rewrite child FK clauses since
SQLite 3.25), the runner gains Migration.disableForeignKeys: it toggles
PRAGMA foreign_keys around that migration's transaction and verifies
foreign_key_check before re-enabling enforcement. Covered by a new
populated-DB upgrade test (migration017LayoutsUpgrade.test.ts).

Hardening found by a post-merge audit:
- import.ts SYSTEM_TABLE_IDS now includes 'layouts' (an import into a
  not-yet-migrated DB can no longer create layouts as an unprotected
  custom table)
- plugin cms.content.tables.create rejects the reserved 'layouts' slug
- data-tables-system-flag architecture gate extended to all four system
  tables; import/export roundtrip now exercises a layouts row

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rade test

Packs (pack/site.json) can now carry `layouts` alongside visualComponents/
pages/classes. definePack({ layouts: [...] }) auto-namespaces ids under the
plugin id (createdAt optional); parsePluginPack validates each entry with
the tolerant SavedLayout parser plus a strict tree-coherence check and the
same ownership rule as classes (ids must be plugin-namespaced, so re-syncs
replace the plugin's own rows and never collide with user layouts).
applyPluginPackToSite records replaced layout ids; the install handler
upserts them as data_rows (table_id 'layouts') and reports them in the
install summary, toast counts, and audit metadata.

Also removes the migration-017 upgrade test per review feedback (the
migration itself is unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s in the inserter

Pack layouts are now authored the way authors already write markup:
definePack({ layouts: [{ id, name, html, css? }] }). compilePackLayout
runs the host's HTML-import pipeline at plugin build time — <style>
blocks are harvested, scripts stripped, elements mapped to base.*
modules, CSS parsed into style rules with deterministic ids
(<layoutId>/<className>) so re-inserting after a pack update reuses the
site's class instead of importing a duplicate. Multi-root markup is
wrapped in a container; class names without a matching rule are dropped.
The wire format (pack/site.json) keeps carrying compiled SavedLayout
objects, so the host install path is unchanged and needs no DOM. The CLI
installs a happy-dom window + the base module registry before evaluating
the author's config (packCompileEnvironment, mirroring the server's
richtext sanitizer setup). Layout ids now require the <pluginId>/ form.

The inserter's Layouts section now groups: the user's Saved layouts,
one group per plugin labelled with the plugin's display name, then
Built-in presets. pluginRuntime gains a plugin-name registry fed by the
editor plugin loader for every installed plugin; composeLayoutsSection
in the inserter model owns the ordering + labels and is unit-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@DavidBabinec DavidBabinec force-pushed the feat/user-saved-layouts branch from ad8c9c7 to 385559f Compare June 11, 2026 19:04
@DavidBabinec DavidBabinec merged commit 3d52eda into main Jun 11, 2026
6 checks passed
@DavidBabinec DavidBabinec deleted the feat/user-saved-layouts branch June 11, 2026 19:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant