diff --git a/CHANGELOG.md b/CHANGELOG.md
index a15232999..71ad68059 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,41 @@
-# Digital Shopfront CMS Package Changelog
+# ArtisanPack UI Visual Editor — Changelog
-## [Unreleased]
+All notable changes to this project are documented in this file.
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
+this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.0.0] — V1 release
+
+V1 ships the post editor, the site editor, the block fork to the
+`artisanpack/*` namespace, and first-class pairing with
+`artisanpack-ui/cms-framework`. See the [README](README.md) and the
+[`docs/`](docs/) directory for the full V1 surface.
+
+### Added
+
+- **Site editor (Phase H).** Mounted at `/visual-editor/site`. Templates,
+ template parts, theme.json-backed global styles, navigation menus, and
+ patterns — all editable through a custom shell built on
+ `@wordpress/block-editor`. Backed by cms-framework's
+ `Template`/`TemplatePart`/`GlobalStyles`/`Menu`/`Pattern` models when
+ cms-framework is installed; fail-closed `SiteEditorAccessGate`
+ defaults to deny until the host binds a permissive gate (or installs
+ cms-framework, which auto-binds `CmsFrameworkInstallGate`).
+- **Documentation set.** Fifteen new / refreshed docs covering install,
+ content model, Blade component reference, post-editor surface, custom
+ blocks, renderers, site-editor surface, templates, global styles,
+ navigation, patterns, Livewire and Inertia embedding recipes,
+ theming, troubleshooting, and migration. Entry point:
+ [`docs/getting-started.md`](docs/getting-started.md).
+- **V1 expansion plan retained as historical record:**
+ [`docs/plans/11-v1-expansion.md`](docs/plans/11-v1-expansion.md).
+
+### Changed
+
+- README rewritten to reflect final V1 scope: post editor + site editor
+ + patterns + Livewire/Inertia embedding.
+
+## [1.0.0-alpha.1] — Gutenberg adoption marker
### Added
diff --git a/MEMORY.md b/MEMORY.md
new file mode 100644
index 000000000..614a669db
--- /dev/null
+++ b/MEMORY.md
@@ -0,0 +1,167 @@
+# Visual Editor — V1 architecture notes
+
+Persisted reference for future work on this package. Read on every
+session start; update when architecture decisions change.
+
+---
+
+## Package shape
+
+- Composer package: `artisanpack-ui/visual-editor` (namespace `ArtisanPackUI\VisualEditor\`, src under `src/`)
+- npm package: `@artisanpack-ui/visual-editor` (built from `resources/js/visual-editor/`)
+- Three sub-packages under `packages/`:
+ - `artisanpack-ui/visual-editor-renderer-blade` (Packagist; published in V1)
+ - `@artisanpack-ui/visual-editor-renderer-react` (npm; mirror unpublished in V1.0.0)
+ - `@artisanpack-ui/visual-editor-renderer-vue` (npm; mirror unpublished in V1.0.0)
+
+## V1 surfaces
+
+Two surfaces, mounted independently:
+
+1. **Post editor** — `` Blade
+ component. Boots a React tree on `[data-ap-visual-editor]`. Edits
+ any model that uses `HasBlockContent`.
+2. **Site editor** — `GET /visual-editor/site/{path?}`. Templates,
+ template parts, patterns, global styles, navigation. Gated by
+ `SiteEditorAccessGate` (`DenyByDefaultGate` by default;
+ cms-framework auto-binds `CmsFrameworkInstallGate`).
+
+## Content model
+
+- `ArtisanPackUI\VisualEditor\Concerns\HasBlockContent` on any Eloquent
+ model. Default column: `content` (JSON); override via
+ `$blockContentColumn`. Optional scope via `$blockContentScope`.
+- Resource map: slug → model class. Read from
+ `config('artisanpack.visual-editor.resources')` + `ap.visual-editor.resources`
+ filter. Static config wins on key collision. Validation deferred to
+ first request (not boot), so contributor packages don't trip boot
+ when absent.
+- Resolver: `ArtisanPackUI\VisualEditor\Resources\ResourceResolver`. Applies
+ `forVisualEditor` scope; throws `InvalidArgumentException` on unknown
+ resource / missing trait.
+
+## REST surface
+
+All under `/visual-editor/api`, middleware
+`config('artisanpack.visual-editor.api.middleware')` (default
+`['api', 'auth']`).
+
+Endpoint families:
+
+- `{resource}/{id}/content` — block content CRUD
+- `blocks/preview`, `blocks` — block registry + dynamic preview
+- `query/resolve` — `core/query` resolution
+- `templates`, `template-parts`, `patterns`, `global-styles`, `menus`,
+ `menu-items`, `menu-locations` — site-editor entities (wp_template
+ shape; matches Gutenberg's REST contract so core-data shim resolves
+ unchanged)
+- `attachments/{id}`, `search`, `site/{id}` — media, link picker,
+ site-meta
+
+cms-framework adds: `posts`, `pages` (REST adapters into
+cms-framework's models).
+
+## @wordpress/* pins
+
+All exact-version pinned, lockstep, audited per release. V1.0.0 ships
+against the `15.16.0` / `32.5.0` / `7.43.0` baseline (see
+`docs/release-notes-inputs-1.0.0.md` §1 + §5). Renovate is paused for the
+group during V1.x expansion phases and resumes post-tag.
+
+Schema version: theme.json **v3** (pinned in
+`config('artisanpack.visual-editor.global_styles.schema_version')` and
+enforced by `UpdateGlobalStylesRequest`).
+
+Bump procedure: `docs/troubleshooting.md` §3 + `docs/global-styles.md` §8.
+
+## Core-data shim
+
+`resources/js/visual-editor/vendor/core-data-shim.ts` — aliased in
+`vite.config.ts` as `@wordpress/core-data`. Provides:
+
+- Entities: `wp_template`, `wp_template_part`, `wp_navigation`, `wp_block`
+ (patterns), `root:globalStyles`, attachments, site-meta.
+- Selectors: `getEntityRecord`, `getEntityRecords`, `getEditedEntityRecord`,
+ `canUser`, `getCurrentUser`, `getMedia`, plus resolver tracking
+ (`hasFinishedResolution`, `isResolving`).
+
+Adding a new entity or selector goes here. Surface only what Gutenberg
+demands; broader surface = more re-verification per upstream bump.
+
+## Block fork (Phase I — closed)
+
+- 42 forked core blocks under `artisanpack/*`. Source of truth:
+ `resources/js/visual-editor/blocks/`.
+- `core/*` no longer registered. `from:core/*` transforms on every fork
+ catch pasted upstream markup.
+- `@wordpress/block-library` demoted to devDependencies; only consumed
+ by `npm run upstream-diff`.
+- Per-block `upstream-state.json` tracks the upstream commit each fork
+ derives from. CI runs the diff on every Renovate PR.
+
+## Renderers
+
+Three packages, all consume the same block-tree JSON. Parity verified
+by `npm run verify:parity`. Static blocks: per-block partial/component
+in each renderer. Dynamic blocks: `DynamicBlock` subclass renders
+server-side; React + Vue fall through to `` that fetches
+from `/visual-editor/api/blocks/preview`.
+
+## Embedding stacks
+
+- Livewire: `wire:ignore` + Alpine listeners on `ve:editor:*` events.
+ See `docs/livewire.md`.
+- Inertia React: imperative `mountVisualEditor(el, config)`.
+- Inertia Vue: `` SFC from `@artisanpack-ui/visual-editor/vue`.
+
+Same three browser events fire regardless of stack:
+`ve:editor:change` (debounce edge), `ve:editor:autosave` (debounced
+save returns 200), `ve:editor:save` (explicit save returns 200).
+
+## Theming
+
+- Editor chrome restyled to DaisyUI via `@artisanpack-ui/react`.
+- Known issues: `@artisanpack-ui/react` Popover + Dropdown freeze the
+ canvas — use inline manual patterns. Tabs needs
+ `tabListClassName="tabs-box"` for DaisyUI 5.
+- React package: use subpath imports (`/form`, `/layout`, etc.); root
+ import breaks tests via missing `react-apexcharts` peer.
+
+## Site-editor + cms-framework version pair
+
+V1.x ↔ V1.x. cms-framework provides:
+
+- Models: `Post`, `Page`, `Template`, `TemplatePart`, `Pattern`,
+ `GlobalStyles`, `Menu`, `MenuItem`.
+- Service contracts: `QueryResolver`, `SiteEditorAccessGate` impl
+ (`CmsFrameworkInstallGate`), site-meta via `apGetSetting('site.*')`.
+- Migrations: `block_content` column on `posts`/`pages`, full
+ site-editor tables.
+
+Loose coupling — cms-framework's editor wiring guarded by
+`class_exists(\ArtisanPackUI\VisualEditor\VisualEditor::class)`.
+
+## Smoke flows
+
+Both run against the version pair before every release tag:
+
+- `docs/g6-smoke-flow.md` — Phase G (posts, pages, site-meta, query).
+- `docs/h8-smoke-flow.md` — Phase H (templates, parts, patterns,
+ global styles, navigation).
+
+## Tests
+
+- PHP: `./vendor/bin/pest` (Pest 3.x, Orchestra Testbench).
+- JS: `npm test` (Vitest, jsdom).
+- Parity: `npm run verify:parity`.
+- Upstream diff: `npm run upstream-diff`.
+
+## What's deferred to V1.1+
+
+- `@wordpress/*` upgrade pass (paused during V1.0.0; resumes post-tag).
+- Style variations (theme-level presets).
+- Revision history of global-styles edits.
+- Standalone-without-cms-framework polish (currently usable but the
+ site editor requires cms-framework).
+- WordPress import companion package
+ (`artisanpack-ui/visual-editor-wp-import`).
diff --git a/README.md b/README.md
index 8192ac80f..6688be4f0 100644
--- a/README.md
+++ b/README.md
@@ -1,130 +1,268 @@
# ArtisanPack UI Visual Editor
-Description
+A Gutenberg-powered visual editor for Laravel applications. Brings the
+WordPress block editor to any Eloquent model, plus a full site editor
+for templates, template parts, global styles, navigation menus, and
+patterns.
+
+V1.0 ships:
+
+- **Post editor** — block-based content editing on any model that opts
+ in via the `HasBlockContent` trait.
+- **Site editor** — templates, template parts, theme.json-backed global
+ styles, navigation menus, and patterns; mounted at `/visual-editor/site`.
+- **42 forked core blocks** under the `artisanpack/*` namespace.
+ `core/*` markup pasted from upstream auto-converts on insert.
+- **Three renderer packages** — Blade (server-side), React, and Vue —
+ for rendering saved block content on the public site.
+- **First-class pairing** with [`artisanpack-ui/cms-framework`](https://github.com/ArtisanPack-UI/cms-framework)
+ for Posts, Pages, site-meta, navigation, and global-styles persistence.
+ Both packages remain usable standalone.
+
+Full documentation: see [`docs/`](docs/). Start with [Getting started](docs/getting-started.md).
+
+---
## Installation
-You can install the visual-editor package by running the following composer command.
+```bash
+composer require artisanpack-ui/visual-editor
+php artisan migrate
+```
+
+For the site editor (recommended for most apps):
-`composer require artisanpack-ui/visual-editor`
+```bash
+composer require artisanpack-ui/cms-framework
+php artisan migrate
+```
+
+See [Getting started](docs/getting-started.md) for the full setup.
+
+---
## Version compatibility
The visual-editor and `artisanpack-ui/cms-framework` packages ship as a
-version pair — both packages need to be present, and both need to be
-on a compatible major version, for the Phase H site-editor integration
-to work. Install one without the other and the site-editor's install
-gate (#432) surfaces a "cms-framework required" page instead of mounting.
+version pair — both packages need to be present, and both need to be on
+a compatible major version, for the site-editor integration to work.
+Install one without the other and the site-editor's install gate (#432)
+surfaces a "cms-framework required" page instead of mounting.
| visual-editor | cms-framework | Notes |
| ------------- | ------------- | ---------------------------------------------- |
-| v1.x | v1.x | Phase H site-editor integration (this release) |
+| v1.x | v1.x | Site-editor integration (this release) |
| v0.x | v0.x | Pre-v1 — no site-editor integration |
Bumping the major on either package without bumping the partner is
unsupported. Both smoke flows run against this version pair before every
-release tag:
-[`docs/g6-smoke-flow.md`](docs/g6-smoke-flow.md) covers Phase G
-(cms-framework content integration — posts, pages, site-meta, query
-loop); [`docs/h8-smoke-flow.md`](docs/h8-smoke-flow.md) covers Phase H
-(site-editor — templates, parts, patterns, global styles, navigation).
+release tag: [`docs/g6-smoke-flow.md`](docs/g6-smoke-flow.md) covers
+Phase G (cms-framework content integration — posts, pages, site-meta,
+query loop); [`docs/h8-smoke-flow.md`](docs/h8-smoke-flow.md) covers
+Phase H (site-editor — templates, parts, patterns, global styles,
+navigation).
+
+---
-## Peer Dependencies
+## Peer dependencies
-The editor UI is built on [`@artisanpack-ui/react`](https://www.npmjs.com/package/@artisanpack-ui/react), which is styled with DaisyUI and Tailwind CSS. Host applications embedding the editor must have the following installed and loaded:
+The editor UI is built on [`@artisanpack-ui/react`](https://www.npmjs.com/package/@artisanpack-ui/react),
+which is styled with DaisyUI and Tailwind CSS. Host applications
+embedding the editor must have the following installed and loaded:
- `tailwindcss` `^4.0.0`
- `daisyui` `^5.0.0`
+---
+
## Usage
-You can use any of the visual editor functions like this:
+Add the `HasBlockContent` trait to any model and register it in
+`config/artisanpack/visual-editor.php`:
-```
-Example Here
+```php
+use ArtisanPackUI\VisualEditor\Concerns\HasBlockContent;
+
+class Post extends Model
+{
+ use HasBlockContent;
+}
```
-## Gutenberg adoption — transient shims (V1)
+```php
+// config/artisanpack/visual-editor.php
+return [
+ 'resources' => [
+ 'posts' => App\Models\Post::class,
+ ],
+];
+```
-The V1 editor adopts the upstream `@wordpress/*` packages. Some of those packages expect a WordPress backend that this package does not provide yet, so we ship **temporary shims** under `resources/js/visual-editor/vendor/`:
+Mount the editor in a Blade view:
-- **`core-data-shim.ts`** — aliased in `vite.config.ts` as `@wordpress/core-data`. Registers an empty `core` Redux store via `@wordpress/data` so Gutenberg's selectors (`getEntityRecord`, `getEntityRecords`, `getCurrentUser`, `getUsers`, `getMedia`, etc.) see "no data, done loading" instead of crashing. Blocks that need WordPress-specific data (navigation, query, post-*) render empty; those blocks land in the `disabled_blocks` list in M5.
-- **`media-upload-stub.tsx`** — registers `editor.MediaUpload` via `@wordpress/hooks` so the media-library picker on `core/image` is routed through a stub that displays an "M4 placeholder" notice instead of silently no-op'ing.
+```blade
+
+```
-Both shims will be replaced by `artisanpack-ui/cms-framework` (the real Laravel-backed `core-data` store and media bridge). Every selector or filter we implement here is one we have to re-verify against Gutenberg upgrades — expand the surface only when an observed crash demands it.
+The site editor mounts automatically at `/visual-editor/site` when
+cms-framework is installed and the configured `SiteEditorAccessGate`
+permits the request.
+
+See [`docs/blade-component.md`](docs/blade-component.md) for the full
+component reference and [`docs/site-editor.md`](docs/site-editor.md) for
+the site-editor surface.
+
+---
+
+## Documentation
+
+| Topic | Doc |
+|-------|-----|
+| Install and first post | [`docs/getting-started.md`](docs/getting-started.md) |
+| `HasBlockContent` + resource map | [`docs/content-model.md`](docs/content-model.md) |
+| `` reference | [`docs/blade-component.md`](docs/blade-component.md) |
+| Post editor surface tour | [`docs/post-editor.md`](docs/post-editor.md) |
+| Authoring custom blocks | [`docs/custom-blocks.md`](docs/custom-blocks.md) |
+| Blade / React / Vue renderers | [`docs/renderers.md`](docs/renderers.md) |
+| Site editor surface tour | [`docs/site-editor.md`](docs/site-editor.md) |
+| Template hierarchy + parts | [`docs/templates.md`](docs/templates.md) |
+| theme.json-backed global styles | [`docs/global-styles.md`](docs/global-styles.md) |
+| Menus, locations, fallbacks | [`docs/navigation.md`](docs/navigation.md) |
+| Synced vs unsynced patterns | [`docs/patterns.md`](docs/patterns.md) |
+| Embedding in Livewire | [`docs/livewire.md`](docs/livewire.md) |
+| Embedding in Inertia (React/Vue) | [`docs/inertia.md`](docs/inertia.md) |
+| Theming editor chrome | [`docs/theming.md`](docs/theming.md) |
+| Common problems | [`docs/troubleshooting.md`](docs/troubleshooting.md) |
+| Migration & WP import | [`docs/migration.md`](docs/migration.md) |
+
+---
+
+## Gutenberg adoption — transient shims
+
+The V1 editor adopts the upstream `@wordpress/*` packages. Some of those
+packages expect a WordPress backend that this package doesn't provide,
+so we ship temporary shims under `resources/js/visual-editor/vendor/`:
+
+- **`core-data-shim.ts`** — aliased in `vite.config.ts` as
+ `@wordpress/core-data`. Provides the entity registrations and
+ selectors Gutenberg expects (`getEntityRecord`, `getEntityRecords`,
+ resolvers, permissions stubs). Templates, template parts, navigation,
+ patterns, global styles, attachments, and site-meta are wired through
+ to the package's REST surface.
+- **`media-upload-stub.tsx`** — registers `editor.MediaUpload` via
+ `@wordpress/hooks` so the media-library picker on `core/image` is
+ routed through a stub or the real
+ `registerArtisanpackMediaBridge(MediaModal, uploadMedia)` bridge.
+
+Both shims will be replaced over the V1.x release line as the cms-framework
+side surfaces real backings. Every selector or filter implemented here is
+one to re-verify against Gutenberg upgrades. See
+[`docs/core-data-shim.md`](docs/core-data-shim.md) and
+[`docs/troubleshooting.md`](docs/troubleshooting.md#2-core-data-shim-entities-and-missing-data).
+
+---
## Block defaults
-V1 ships with a frozen allow-list of forked blocks under the `artisanpack/*` namespace. The defaults in `config/visual-editor.php` expose every block that landed during the Phase I block fork (plan 13) — `@wordpress/block-library`'s `registerCoreBlocks()` is no longer called, and the editor registers only the in-package forks discovered under `resources/js/visual-editor/blocks/`. The `core/*` → `artisanpack/*` mapping table in [`docs/block-library-audit.md`](docs/block-library-audit.md) is the source of truth for which forks exist.
-
-The forked allow-list covers the content, media, layout, widget, entity, and loop/feed clusters. The entity blocks (`artisanpack/post-*`, `artisanpack/site-*`, `artisanpack/template-part`, `artisanpack/navigation`) and the loop / feed cluster (`artisanpack/query`, `artisanpack/post-template`, `artisanpack/archives`, `artisanpack/categories`, `artisanpack/tag-cloud`) need an entity in scope to render meaningful content — pair the editor with [`artisanpack-ui/cms-framework`](https://github.com/ArtisanPack-UI/cms-framework) (see [Using with cms-framework](#using-with-cms-framework)) and they resolve against Posts / Pages / templates / site settings end-to-end. Standalone, they fall back to empty shells rather than crashing.
-
-`disabled_blocks` is empty by default: with the I7 cutover (#415) the editor no longer registers any `core/*` block, so there is nothing to deny-list. New `@wordpress/block-library` releases similarly bring no new registrations into this package — additions land only when a fork is added to the in-package blocks directory and to the allow-list. `from:core/*` transforms still ship on each fork so existing `core/*` markup pasted from upstream converts on insert.
-
-Override the defaults by publishing the config to `config/artisanpack/visual-editor.php` and editing the `enabled_blocks` / `disabled_blocks` arrays. The deny-list always wins over the allow-list.
+V1 ships a frozen allow-list of forked blocks under the `artisanpack/*`
+namespace. The defaults in `config/artisanpack/visual-editor.php` expose every block
+that landed during the Phase I block fork — `@wordpress/block-library`'s
+`registerCoreBlocks()` is no longer called, and the editor registers
+only the in-package forks discovered under
+`resources/js/visual-editor/blocks/`. The `core/*` → `artisanpack/*`
+mapping table in [`docs/block-library-audit.md`](docs/block-library-audit.md)
+is the source of truth for which forks exist.
+
+The forked allow-list covers the content, media, layout, widget, entity,
+loop/feed, comments, query/pagination, and authentication clusters.
+Entity blocks (`artisanpack/post-*`, `artisanpack/site-*`,
+`artisanpack/template-part`, `artisanpack/navigation`) and the loop /
+feed cluster (`artisanpack/query`, `artisanpack/post-template`,
+`artisanpack/archives`, `artisanpack/categories`,
+`artisanpack/tag-cloud`) need an entity in scope to render meaningful
+content — pair the editor with
+[`artisanpack-ui/cms-framework`](https://github.com/ArtisanPack-UI/cms-framework)
+and they resolve against Posts / Pages / templates / site settings
+end-to-end. Standalone, they fall back to empty shells rather than
+crashing.
+
+`disabled_blocks` is empty by default: with the I7 cutover (#415) the
+editor no longer registers any `core/*` block, so there's nothing to
+deny-list. New `@wordpress/block-library` releases similarly bring no
+new registrations into this package — additions land only when a fork
+is added to the in-package blocks directory and to the allow-list.
+`from:core/*` transforms ship on each fork so existing `core/*` markup
+pasted from upstream converts on insert.
+
+Override the defaults by publishing the config to
+`config/artisanpack/visual-editor.php` and editing the `enabled_blocks`
+/ `disabled_blocks` arrays. The deny-list always wins over the allow-list.
+
+---
## Using with cms-framework
-The visual editor is fully usable standalone, but pairs with [`artisanpack-ui/cms-framework`](https://github.com/ArtisanPack-UI/cms-framework) to unlock editable `Post` and `Page` content, a real backing for `core/site-*` blocks, working `core/post-*` / `core/query` / taxonomy widget blocks, and seeded `visual_editor.*` permissions. The full integration contract lives in [`docs/plans/12-cms-framework-integration.md`](docs/plans/12-cms-framework-integration.md).
+The visual editor is fully usable standalone, but pairs with
+[`artisanpack-ui/cms-framework`](https://github.com/ArtisanPack-UI/cms-framework)
+to unlock:
-### Install both packages
+- Editable `Post` and `Page` content out of the box.
+- A real backing for `core/site-*` blocks (site title, tagline, logo, icon).
+- Working `core/post-*`, `core/query`, taxonomy widget, and navigation
+ blocks.
+- Templates, template parts, patterns, global styles, and menus
+ persisted via cms-framework's models.
+- Seeded `visual_editor.*` permissions.
```bash
composer require artisanpack-ui/visual-editor artisanpack-ui/cms-framework
-```
-
-Both packages are loosely coupled — cms-framework's editor wiring is guarded by `class_exists(\ArtisanPackUI\VisualEditor\VisualEditor::class)`, so each package remains usable on its own.
-
-### Run migrations
-
-```bash
php artisan migrate
```
-cms-framework's V1.x migration set adds a `block_content json nullable` column to its `posts` and `pages` tables (the legacy `content` longText column is preserved for search / excerpt / backwards-compatibility — see plan 12 §4.2 for the dual-state guidance).
+Both packages are loosely coupled — cms-framework's editor wiring is
+guarded by `class_exists(\ArtisanPackUI\VisualEditor\VisualEditor::class)`,
+so each remains usable on its own. The full integration contract lives in
+[`docs/plans/12-cms-framework-integration.md`](docs/plans/12-cms-framework-integration.md).
-### Resource map
-
-When both packages are installed, cms-framework registers its `Post` and `Page` into the `ap.visual-editor.resources` filter automatically. The merged map ends up shaped like:
-
-```php
-// Effective config('artisanpack.visual-editor.resources') after the
-// ap.visual-editor.resources filter has run with both packages installed.
-[
- 'posts' => \ArtisanPackUI\CMSFramework\Modules\Blog\Models\Post::class,
- 'pages' => \ArtisanPackUI\CMSFramework\Modules\Pages\Models\Page::class,
- // …plus any slug → model entries the host app added to
- // config/artisanpack/visual-editor.php (static config wins on collision).
-]
-```
-
-Host-app overrides in `config/artisanpack/visual-editor.php` always take precedence on key collision, so swapping `posts` to a custom `App\Models\Post` is just a config edit.
-
-### Version pairing
-
-visual-editor V1.0.x ships against cms-framework V1.x. The compatibility matrix is in [Version compatibility](#version-compatibility) above; the [`docs/g6-smoke-flow.md`](docs/g6-smoke-flow.md) and [`docs/h8-smoke-flow.md`](docs/h8-smoke-flow.md) flows run against the version pair before every release tag.
+---
## Extensibility
-The package exposes a small filter surface so other packages can contribute editor wiring at runtime without forcing host apps to publish-and-edit the package config.
-
### `ap.visual-editor.resources`
-Register slug → Eloquent model class mappings used by `/visual-editor/api/{resource}/{id}/content`. Filter contributions are merged with `config('artisanpack.visual-editor.resources')`; the static config wins on key collision so host-app overrides always take precedence. Models must use `ArtisanPackUI\VisualEditor\Concerns\HasBlockContent` — invalid entries surface as `InvalidArgumentException` on first request rather than at boot, so a contributor's standalone install never trips host boot.
+Register slug → Eloquent model class mappings used by
+`/visual-editor/api/{resource}/{id}/content`. Filter contributions are
+merged with `config('artisanpack.visual-editor.resources')`; the static
+config wins on key collision so host-app overrides always take
+precedence. Models must use
+`ArtisanPackUI\VisualEditor\Concerns\HasBlockContent` — invalid entries
+surface as `InvalidArgumentException` on first request rather than at
+boot, so a contributor's standalone install never trips host boot.
```php
-addFilter( 'ap.visual-editor.resources', function ( array $resources ): array {
- return array_merge( [
+addFilter('ap.visual-editor.resources', function (array $resources): array {
+ return array_merge([
'posts' => App\Models\Post::class,
- ], $resources );
-} );
+ ], $resources);
+});
```
-Full contract (input/output shape, collision behavior, validation guarantees, contributor timing): [`docs/plans/12-cms-framework-integration.md`](docs/plans/12-cms-framework-integration.md) §4.1.
+Full contract:
+[`docs/plans/12-cms-framework-integration.md`](docs/plans/12-cms-framework-integration.md) §4.1.
+
+### `ap.icons.register-icon-sets`
+
+Editor chrome icons resolve through `artisanpack-ui/icons`. Register
+additional icon sets in a service provider — see the
+[`artisanpack-ui/icons`](https://github.com/ArtisanPack-UI/icons) docs.
+
+---
## i18n
-Editor strings use `@wordpress/i18n` with the `artisanpack-visual-editor` text domain. The domain is initialized via `bootI18n()` in `resources/js/visual-editor/vendor/i18n.ts`.
+Editor strings use `@wordpress/i18n` with the `artisanpack-visual-editor`
+text domain. The domain is initialized via `bootI18n()` in
+`resources/js/visual-editor/vendor/i18n.ts`.
Regenerate the placeholder `.pot` catalog with:
@@ -132,9 +270,15 @@ Regenerate the placeholder `.pot` catalog with:
npm run i18n:extract
```
-The extractor scans `resources/js/visual-editor/**/*.{ts,tsx}` for `__/_x/_n/_nx` calls bound to the text domain and writes `languages/artisanpack-visual-editor.pot`. It is deliberately minimal — a richer extractor (template strings, plural forms, PHP scanning) replaces it post-V1.
+The extractor scans `resources/js/visual-editor/**/*.{ts,tsx}` for
+`__/_x/_n/_nx` calls bound to the text domain and writes
+`languages/artisanpack-visual-editor.pot`.
+
+---
## Contributing
-As an open source project, this package is open to contributions from anyone. Please [read through the contributing
-guidelines](CONTRIBUTING.md) to learn more about how you can contribute to this project.
+As an open source project, this package is open to contributions from
+anyone. Please [read through the contributing
+guidelines](CONTRIBUTING.md) to learn more about how you can contribute
+to this project.
diff --git a/docs/blade-component.md b/docs/blade-component.md
new file mode 100644
index 000000000..543c1c44b
--- /dev/null
+++ b/docs/blade-component.md
@@ -0,0 +1,124 @@
+# Blade component reference
+
+`` is the single Blade entry point. It renders a
+`
` mount point with data-attributes that the
+React editor reads on boot.
+
+```blade
+
+```
+
+---
+
+## Attributes
+
+| Attribute | Type | Required | Default | Purpose |
+|-----------|------|----------|---------|---------|
+| `:model` | Eloquent `Model` | **yes** | — | The model being edited. Must use `HasBlockContent`. |
+| `resource` | `string` | no | reverse-looked-up from the resource map | The slug under which the model is registered. Provide explicitly if your config maps multiple slugs to the same class (or to override). |
+| `:apiBase` | `string` | no | `/visual-editor/api` | API root the editor talks to. Change if you've mounted the package routes under a custom prefix. |
+| `:initialTitle` | `?string` | no | `null` | Pre-fills the document-title field in the editor topbar. |
+| `:initialSlug` | `?string` | no | `null` | Pre-fills the slug field in the document panel. |
+| `:initialStatus` | `?string` | no | `null` | Pre-fills the status pill (`draft`, `published`, custom values). |
+| `:initialExcerpt` | `?string` | no | `null` | Pre-fills the excerpt field. Requires `supports.excerpt = true`. |
+| `:initialFeaturedImage` | `?array` | no | `null` | Pre-fills the featured-image picker. Shape: `['id' => int, 'url' => string, 'alt' => ?string]`. |
+| `:initialAuthorId` | `int\|string\|null` | no | `null` | Pre-selects the author. |
+| `:initialCommentsOpen` | `?bool` | no | `null` | Pre-fills the comments toggle. |
+| `:authorOptions` | `?array` | no | `null` | Dropdown options for the author picker. Shape: `[['value' => 1, 'label' => 'Jane'], …]`. |
+| `:supports` | `?array` | no | `null` | Which document-panel fields to render. Keys: `excerpt`, `featuredImage`, `comments` (all `bool`). Omit to show everything the trait supports. |
+| `:previewUrl` | `?string` | no | `null` | Front-end preview URL. Opens in a new tab from the topbar Preview button. |
+
+The component passes its `$attributes` bag through to the root div, so
+`class`, `id`, `style`, and `wire:ignore` are all forwarded.
+
+---
+
+## Resource resolution
+
+If you omit `resource`, the component reverse-looks-up the model class in
+`config('artisanpack.visual-editor.resources')` and uses the first
+matching slug. If the class isn't in the map, it throws
+`RuntimeException` at render time so misconfiguration surfaces
+loudly.
+
+Always set `resource="…"` explicitly when:
+
+- The same model class is registered under multiple slugs.
+- You want render to fail fast at template time rather than at API time.
+- A future config change might add a sibling mapping.
+
+---
+
+## API surface used by the editor
+
+| Method | Path | When |
+|--------|------|------|
+| `GET` | `{apiBase}/{resource}/{id}/content` | On mount, fetches the persisted block tree. |
+| `PUT` | `{apiBase}/{resource}/{id}/content` | On autosave (debounced ~1s after the last change) and on explicit save (⌘S). |
+| `POST` | `{apiBase}/blocks/preview` | When a dynamic block requests an editor preview render. |
+| `POST` | `{apiBase}/query/resolve` | When a `core/query` block needs paginated results. |
+| `GET` | `{apiBase}/blocks` | Once on mount, to fetch the enabled-block manifest. |
+
+All routes run through the middleware stack in
+`config('artisanpack.visual-editor.api.middleware')` (default
+`['api', 'auth']`).
+
+---
+
+## Browser events
+
+The editor dispatches three `CustomEvent`s on `window`:
+
+| Event | When | `detail` shape |
+|-------|------|----------------|
+| `ve:editor:change` | Debounce window closes, right before autosave fires. | `{ resource, id, blocks }` |
+| `ve:editor:autosave` | Debounce-triggered save returns 200. | `{ resource, id, blocks, updatedAt }` |
+| `ve:editor:save` | Explicit save (⌘S or topbar) returns 200. | `{ resource, id, blocks, updatedAt }` |
+
+`resource` and `id` match the component's `data-resource` / `data-id`
+attributes, so a single listener can disambiguate multiple editors on the
+same page. See [Livewire](livewire.md) and [Inertia](inertia.md) for
+embedding recipes.
+
+---
+
+## Mounting multiple editors
+
+Each mount is a separate React root with its own store. There's no
+cross-talk between them — autosaves don't race, undo histories don't
+share. Use distinct DOM ids if you need to scroll to one programmatically.
+
+```blade
+@foreach ($posts as $post)
+
+@endforeach
+```
+
+---
+
+## Theming and styling
+
+The root div gets `class="ap-visual-editor"` plus any classes you pass in
+via `$attributes`. Editor chrome is themed through DaisyUI tokens — see
+[Theming](theming.md) for how to override colors and typography.
+
+---
+
+## See also
+
+- [Content model](content-model.md) — `HasBlockContent`, resource map, policies
+- [Getting started](getting-started.md) — end-to-end install
+- [Livewire](livewire.md) / [Inertia](inertia.md) — embedding recipes
diff --git a/docs/content-model.md b/docs/content-model.md
new file mode 100644
index 000000000..66d5b218c
--- /dev/null
+++ b/docs/content-model.md
@@ -0,0 +1,197 @@
+# Content model
+
+The visual editor stores content as a Gutenberg-shaped block tree (a JSON
+array of `{ name, attributes, innerBlocks, … }` nodes) on any Eloquent
+model that opts in via the `HasBlockContent` trait. Models are exposed to
+the editor through the **resource map**, a slug → model class registry
+read by the API layer.
+
+This page covers the trait, the resource map, and the policy / authorization
+surface.
+
+---
+
+## 1. The `HasBlockContent` trait
+
+`ArtisanPackUI\VisualEditor\Concerns\HasBlockContent`
+
+Add the trait to any model whose content should be editable:
+
+```php
+use ArtisanPackUI\VisualEditor\Concerns\HasBlockContent;
+use Illuminate\Database\Eloquent\Model;
+
+class Post extends Model
+{
+ use HasBlockContent;
+}
+```
+
+### Conventions and overrides
+
+| Behaviour | Default | Override |
+|-----------|---------|----------|
+| Column storing the block tree | `content` (JSON) | `protected string $blockContentColumn = 'body'` |
+| Query scope applied by the resource resolver | `forVisualEditor` (passthrough) | `protected string $blockContentScope = 'published'` (must exist on the model as a scope method) |
+| Cast for the block column | `array` (auto-applied if not present) | Set `$casts['content'] = 'json'` explicitly to opt out of the auto-cast |
+| Searchable text | Plain-text extract of the block tree | Override `blockContentSearchableText(): string` |
+
+The trait's `initializeHasBlockContent()` hook auto-applies the `array`
+cast at boot. If your model already casts the column, the trait leaves
+your cast untouched.
+
+### Public API
+
+```php
+$post->getBlockContent(); // array>
+$post->setBlockContent($blocks); // void
+$post->blockContentSearchableText(); // string
+$post->toBlockContentSearchableArray(); // ['block_content' => '…'] for Scout
+Post::query()->forVisualEditor()->get(); // optional content scope
+```
+
+The `forVisualEditor` scope is what the resource resolver applies before
+fetching by id. By default it's a passthrough — override
+`$blockContentScope` to filter by status, ownership, tenant, etc.
+
+### Migration
+
+Add the column when introducing the trait to an existing model:
+
+```php
+Schema::table('posts', function (Blueprint $table) {
+ $table->json('content')->nullable();
+});
+```
+
+The legacy `ve_contents` table the package ships is only used by the
+fallback `VisualEditorPost` model and the `/editor` test route. Host-app
+models never write to it.
+
+---
+
+## 2. The resource map
+
+The resource map is a slug → model class array consulted by every
+content-bearing API endpoint:
+
+```
+GET /visual-editor/api/{resource}/{id}/content
+PUT /visual-editor/api/{resource}/{id}/content
+```
+
+`{resource}` is the slug. The map is read from two places, merged at boot:
+
+1. **Static config** — `config('artisanpack.visual-editor.resources')`
+ in `config/artisanpack/visual-editor.php`.
+2. **Filter** — `ap.visual-editor.resources`, contributed by packages
+ like cms-framework.
+
+```php
+// config/artisanpack/visual-editor.php
+return [
+ 'resources' => [
+ 'posts' => App\Models\Post::class,
+ 'pages' => App\Models\Page::class,
+ ],
+];
+```
+
+```php
+// From a package's service provider
+addFilter('ap.visual-editor.resources', function (array $resources): array {
+ return array_merge([
+ 'posts' => MyPackage\Models\Post::class,
+ ], $resources);
+});
+```
+
+**Collision wins:** static config beats filter contributions. The host
+app's published config is authoritative — packages can suggest a default,
+the app can override.
+
+**Validation timing:** the map is not validated at boot. The first
+request that resolves a missing resource raises `NotFoundHttpException`
+(returned to the client as 404); a model that's registered but doesn't
+use `HasBlockContent` raises `InvalidArgumentException`. This is
+deliberate — contributor packages that aren't installed in a given
+environment never trip boot.
+
+### Resolution flow
+
+1. Request comes in: `PUT /visual-editor/api/posts/42/content`.
+2. `ResourceResolver` looks up `posts` → `App\Models\Post`.
+3. It calls `Post::query()->forVisualEditor()->findOrFail(42)`.
+4. The controller calls `$post->setBlockContent($blocks)` and `$post->save()`.
+
+The model must use `HasBlockContent` — the resolver checks the trait
+explicitly and throws otherwise.
+
+---
+
+## 3. Policies and authorization
+
+Resource models use **their own Laravel policies**. The package does not
+inject a "visual editor policy" on top of them. If your `PostPolicy`
+already gates `update`, the editor's save endpoint inherits it.
+
+The controllers call `Gate::authorize('update', $model)` (or
+`Gate::authorize('view', $model)` on the show endpoint) before
+reading/writing block content.
+
+### Site-editor access gate
+
+The site editor (templates, parts, patterns, global styles, navigation) is
+gated by a single boot-time contract:
+`ArtisanPackUI\VisualEditor\SiteEditor\Contracts\SiteEditorAccessGate`.
+
+Default implementation: `DenyByDefaultGate` — fail-closed. Bind your own
+to open it:
+
+```php
+// AppServiceProvider::register()
+$this->app->bind(
+ \ArtisanPackUI\VisualEditor\SiteEditor\Contracts\SiteEditorAccessGate::class,
+ \App\Auth\AllowAdminsGate::class,
+);
+```
+
+When cms-framework is installed it auto-binds `CmsFrameworkInstallGate`,
+which checks that cms-framework is installed and migrations have run
+before allowing access.
+
+### API middleware
+
+Every `/visual-editor/api/*` route runs through
+`config('artisanpack.visual-editor.api.middleware')` (default:
+`['api', 'auth']`). Replace the default if you need Sanctum, a different
+guard, or unauthenticated read access.
+
+---
+
+## 4. Multiple resources, one editor
+
+A single editor mount edits one resource at a time, scoped by the
+`data-resource` and `data-id` attributes that the Blade component emits.
+You can mount more than one editor on the same page — every editor event
+includes `{ resource, id }` in its `detail`, so listeners can
+disambiguate. See the [Livewire recipe](livewire.md) for an example of
+multiple editors coexisting.
+
+---
+
+## 5. The `ve_contents` fallback table
+
+The package's migration creates `ve_contents` (`id`, `author_id`, `title`,
+`blocks`, `timestamps`). It backs the `VisualEditorPost` model used by the
+default `/editor` route and a handful of tests. Production apps that
+register their own resource models can ignore the table — it stays empty
+and never grows.
+
+---
+
+## See also
+
+- [Blade component reference](blade-component.md) — attribute-by-attribute
+- [Custom blocks](custom-blocks.md) — extending what authors can insert
+- [Renderers](renderers.md) — getting saved content back onto the page
diff --git a/docs/custom-blocks.md b/docs/custom-blocks.md
index 8c70619f7..63a41fece 100644
--- a/docs/custom-blocks.md
+++ b/docs/custom-blocks.md
@@ -225,6 +225,83 @@ Use a dynamic block when:
render (latest posts, cart totals, stock levels).
- The block needs to run Laravel authorization before exposing data.
+### Dynamic block hooks
+
+`DynamicBlock` exposes four overridable methods. Only `name()` and
+`render()` are required; the others have safe defaults.
+
+```php
+abstract class DynamicBlock
+{
+ abstract public function name(): string;
+ abstract public function render(array $attrs); // : View|Stringable|string
+
+ public function validateAttrs(array $attrs): array
+ {
+ return $attrs;
+ }
+
+ public function searchableText(array $attrs): string
+ {
+ return '';
+ }
+
+ public function authorize(?Authenticatable $user, array $attrs): bool
+ {
+ return true;
+ }
+}
+```
+
+- **`validateAttrs(array $attrs): array`** — runs before `render()` and
+ before persistence. Normalize, coerce, and reject bad input. Throw
+ `InvalidArgumentException` to abort. Defaults to passthrough.
+
+ ```php
+ public function validateAttrs(array $attrs): array
+ {
+ $limit = filter_var($attrs['limit'] ?? 5, FILTER_VALIDATE_INT);
+ if ($limit === false || $limit < 1 || $limit > 50) {
+ throw new \InvalidArgumentException('limit must be 1–50');
+ }
+
+ return ['limit' => $limit];
+ }
+ ```
+
+- **`searchableText(array $attrs): string`** — plain-text extract used by
+ `HasBlockContent::blockContentSearchableText()` and the Scout searchable
+ array. Return whatever the block contributes to full-text search.
+ Defaults to empty string.
+
+ ```php
+ public function searchableText(array $attrs): string
+ {
+ return (string) ($attrs['heading'] ?? '');
+ }
+ ```
+
+- **`authorize(?Authenticatable $user, array $attrs): bool`** — gates
+ preview rendering during authoring. Return `false` to render an
+ authorization-denied placeholder instead of the block. Defaults to
+ `true`. Public-site renders go through the renderer package directly
+ and run the host app's own authorization.
+
+ ```php
+ public function authorize(?Authenticatable $user, array $attrs): bool
+ {
+ return $user?->can('view-internal-block') ?? false;
+ }
+ ```
+
+### Block preview endpoint
+
+While authoring, dynamic blocks render via
+`POST /visual-editor/api/blocks/preview`. The editor sends
+`{ blockName, attributes }`; the controller resolves the block, runs
+`validateAttrs` → `authorize` → `render`, and returns HTML. Cache misses
+during typing are amortized by the editor's per-block debounce.
+
---
## 5. Rendering on the public frontend
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 000000000..5245a0e9a
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,192 @@
+# Getting started
+
+Install the visual editor, register a model as editable content, mount the
+Blade component, and ship a first post — under an hour from `composer require`
+to working editor.
+
+If you also need site-wide chrome (templates, template parts, theme
+typography, menus, patterns) install `artisanpack-ui/cms-framework` alongside
+the editor. The two packages are a version pair (V1.x ↔ V1.x). See
+[Version compatibility](../README.md#version-compatibility).
+
+---
+
+## 1. Install
+
+```bash
+composer require artisanpack-ui/visual-editor
+php artisan migrate
+```
+
+The package ships a `ve_contents` table for the legacy `VisualEditorPost`
+fallback model. Host-app resource models you register later don't touch this
+table — they store block content on their own.
+
+### Peer dependencies
+
+The editor UI is built on [`@artisanpack-ui/react`](https://www.npmjs.com/package/@artisanpack-ui/react),
+which is styled with DaisyUI + Tailwind CSS v4. Host apps must have:
+
+- `tailwindcss` `^4.0.0`
+- `daisyui` `^5.0.0`
+
+Both are loaded once at the app shell level. The editor inherits them from
+the host page.
+
+### Pair with cms-framework (recommended)
+
+```bash
+composer require artisanpack-ui/cms-framework
+php artisan migrate
+```
+
+Adds a `block_content` column to cms-framework's `posts` and `pages` tables,
+seeds `visual_editor.*` permissions, and auto-registers
+`Post`/`Page` into the resource map. The site editor's install gate goes
+green and the `core/post-*`, `core/site-*`, `core/query`, taxonomy, and
+navigation blocks all resolve.
+
+You can skip this step — the editor works standalone for a single resource
+model — but you'll have to wire entity blocks yourself.
+
+---
+
+## 2. Publish the config (optional)
+
+```bash
+php artisan vendor:publish --tag=artisanpack-visual-editor-config
+```
+
+Drops `config/artisanpack/visual-editor.php`. Edit it to register
+resources, swap the API middleware, tune the block allow/deny lists, or
+change global-styles theme/schema pinning. See [Blade component reference](blade-component.md)
+and [Custom blocks](custom-blocks.md) for what each key does.
+
+---
+
+## 3. Register a resource model
+
+A "resource" is any Eloquent model whose content is editable via the visual
+editor. Add the `HasBlockContent` trait and declare it in the config:
+
+```php
+// app/Models/Post.php
+namespace App\Models;
+
+use ArtisanPackUI\VisualEditor\Concerns\HasBlockContent;
+use Illuminate\Database\Eloquent\Model;
+
+class Post extends Model
+{
+ use HasBlockContent;
+
+ protected $fillable = ['title', 'slug', 'content'];
+}
+```
+
+```php
+// config/artisanpack/visual-editor.php
+return [
+ 'resources' => [
+ 'posts' => App\Models\Post::class,
+ ],
+ // …
+];
+```
+
+The trait adds a `content` JSON column convention, a Scout-friendly
+searchable accessor, and the `forVisualEditor` query scope used by the
+resource resolver. Full reference: [Content model](content-model.md).
+
+If your model already has a different column for block content:
+
+```php
+class Post extends Model
+{
+ use HasBlockContent;
+
+ protected $blockContentColumn = 'body';
+}
+```
+
+Run the migration that adds the column:
+
+```bash
+php artisan make:migration add_content_to_posts_table
+```
+
+```php
+Schema::table('posts', function (Blueprint $table) {
+ $table->json('content')->nullable();
+});
+```
+
+---
+
+## 4. Mount the editor
+
+In whatever Blade view backs your edit screen:
+
+```blade
+{{-- resources/views/admin/posts/edit.blade.php --}}
+
+```
+
+The Blade component emits a single `
` mount
+point. The React app boots against it on page load, fetches block content
+from `/visual-editor/api/posts/{id}/content`, and autosaves on every change.
+
+Full attribute list and event contract: [Blade component reference](blade-component.md).
+
+---
+
+## 5. Build assets
+
+The editor JS bundle is registered automatically by the package's Vite
+plugin. From your app:
+
+```bash
+npm install
+npm run build # or: npm run dev
+```
+
+The editor mounts on every page that contains a `[data-ap-visual-editor]`
+element.
+
+---
+
+## 6. Try it
+
+1. Run `php artisan serve` (or `composer run dev` in this dev app).
+2. Visit the edit screen.
+3. Insert a Paragraph block, type, watch the autosave indicator fire.
+4. Reload — content persists.
+
+If the editor doesn't appear, check the browser console for missing peer
+deps (Tailwind / DaisyUI) and confirm the resource is registered. See
+[Troubleshooting](troubleshooting.md).
+
+---
+
+## 7. Add the site editor (optional)
+
+If cms-framework is installed, the site editor shell is already mounted at
+`/visual-editor/site`. The default access gate fails closed — implement
+your own `SiteEditorAccessGate` or rely on cms-framework's
+`CmsFrameworkInstallGate`. See [Site editor](site-editor.md).
+
+---
+
+## Where to go next
+
+- **Authoring custom blocks:** [Custom blocks](custom-blocks.md)
+- **Rendering saved content on the public site:** [Renderers](renderers.md)
+- **Editing templates and theme styles:** [Site editor](site-editor.md), [Templates](templates.md), [Global styles](global-styles.md)
+- **Embedding inside Livewire:** [Livewire](livewire.md)
+- **Embedding inside Inertia:** [Inertia](inertia.md)
+- **Things that go wrong:** [Troubleshooting](troubleshooting.md)
diff --git a/docs/global-styles.md b/docs/global-styles.md
index 23e5c70df..695eeef63 100644
--- a/docs/global-styles.md
+++ b/docs/global-styles.md
@@ -1,30 +1,38 @@
-# Global styles — theme.json schema pin
+# Global styles
-The visual editor's `globalStyles` singleton is the theme.json-shaped
-record the site editor reads and writes. C3 (#359) introduces the
-backend: a `VisualEditorGlobalStyles` model, migration, REST endpoints,
-validated write path, and policy. This document records the **schema
-version we pin the package to**, the constraints that follow from that
-decision, and how a future version bump is handled.
+The visual editor's global styles are the theme.json-shaped record the
+site editor reads and writes to drive site-wide typography, color,
+layout, and per-block style overrides. One record per theme, accessed as
+a singleton, validated against a pinned schema version, emitted to CSS at
+render time.
-## Pinned version
+This page covers the schema pin, record shape, REST surface, validation,
+the site-editor styles panels, CSS emission, and how a future schema bump
+is handled.
+
+---
+
+## 1. Pinned schema version
The package pins to **theme.json schema version 3**. The value lives in
config at `artisanpack.visual-editor.global_styles.schema_version` (not
-as a hard-coded constant in code) so host apps can override if they
-know what they are doing; the default is what the package is tested
-against.
+as a hard-coded constant) so host apps can override if they know what
+they're doing — the default is what the package is tested against.
The pin tracks the `@wordpress/*` packages vendored into the editor
-bundle (see `package.json` — `@wordpress/block-editor`,
-`@wordpress/block-library`, `@wordpress/components`, etc.). These
-packages target a specific WordPress core release series; the
-corresponding theme.json schema version is the one the package treats
-as "compatible". Updating the pinned `@wordpress/*` versions without a
-conscious theme.json review is what §4.2 of the V1 plan calls out as
-silent drift — the pin is here to force the review.
+bundle (`@wordpress/block-editor`, `@wordpress/blocks`,
+`@wordpress/components`). Those packages target a specific WordPress
+core release; the corresponding theme.json schema version is what the
+package treats as compatible. Updating the pinned `@wordpress/*` versions
+without a conscious theme.json review is the silent drift the V1 plan
+explicitly calls out — the pin is here to force the review.
+
+See [Troubleshooting §3](troubleshooting.md#3-wordpress-package-upgrades)
+for the schema-bump procedure.
+
+---
-## Record shape
+## 2. Record shape
The record stores a theme.json-shaped blob:
@@ -32,69 +40,137 @@ The record stores a theme.json-shaped blob:
{
"id": 7,
"version": 3,
+ "theme": "artisanpack-base",
"settings": {
- "color": { "palette": [ /* { slug, name, color } */ ] },
+ "color": { "palette": [ { "slug": "primary", "name": "Primary", "color": "#3b82f6" } ] },
"typography": {
- "fontFamilies": [ /* { slug, name, fontFamily } */ ],
- "fontSizes": [ /* { slug, name, size } */ ]
+ "fontFamilies": [ { "slug": "sans", "name": "Sans", "fontFamily": "Inter, system-ui, sans-serif" } ],
+ "fontSizes": [ { "slug": "base", "name": "Base", "size": "1rem" } ]
},
- "layout": { "contentSize": "720px", "wideSize": "1120px" }
+ "layout": { "contentSize": "720px", "wideSize": "1120px" },
+ "spacing": { "spacingScale": { "operator": "*", "increment": 1.5, "steps": 7 } }
},
"styles": {
"color": { "background": "...", "text": "..." },
"typography": { "fontFamily": "...", "fontSize": "...", "lineHeight": "..." },
"elements": { "link": { /* ... */ }, "heading": { /* ... */ } },
- "blocks": { "core/button": { /* ... */ } }
+ "blocks": { "artisanpack/button": { /* ... */ } }
}
}
```
-The full reference payload lives at
-`resources/theme-json/default-base.php` and is returned by
-`GET /visual-editor/api/global-styles/base`.
+The packaged defaults live at `resources/theme-json/default-base.php` and
+are returned by `GET /visual-editor/api/global-styles/base`. Host apps
+override defaults by setting
+`artisanpack.visual-editor.global_styles.base_path` to a custom PHP file
+that returns the same shape.
-## Endpoints
+---
-See `docs/core-data-shim.md` §Global styles for the REST contract the
-core-data shim addresses. C3 ships four endpoints:
+## 3. REST API
-- `GET /visual-editor/api/global-styles/lookup` — resolves the active
- theme's singleton id (creating the row on first access).
-- `GET /visual-editor/api/global-styles/{id}` — user record.
-- `PUT /visual-editor/api/global-styles/{id}` — write user record;
- validated against the pinned schema version.
-- `GET /visual-editor/api/global-styles/base` — theme defaults; the
- site-editor diffs the user record against this to show what has been
- customized.
+| Method | Path | Purpose |
+|--------|------|---------|
+| `GET` | `/visual-editor/api/global-styles/lookup` | Resolve the active theme's singleton id (creates the record on first access). |
+| `GET` | `/visual-editor/api/global-styles/base` | Theme defaults — the site editor diffs the user record against this to show what's been customized. |
+| `GET` | `/visual-editor/api/global-styles/{id}` | Fetch the user record. |
+| `PUT` | `/visual-editor/api/global-styles/{id}` | Update the user record. Validated against the pinned schema version. |
+| `GET` | `/visual-editor/api/global-styles/css` | Emit the user record as CSS for front-end injection. |
-## Validation
+All endpoints behind the API middleware stack.
+
+---
+
+## 4. Validation
`PUT` requests flow through
-`ArtisanPackUI\VisualEditor\Http\Requests\UpdateGlobalStylesRequest` which
-enforces:
+`ArtisanPackUI\VisualEditor\Http\Requests\UpdateGlobalStylesRequest`,
+which enforces:
- `version` must equal the pinned `schema_version` — a schema bump is
- explicit work, not a client-driven drift.
+ explicit work, not client-driven drift.
- `settings` and `styles` are required arrays.
-- `settings.color.palette` entries must each carry `slug`, `name`,
- `color`; slugs must be unique across the palette (duplicate slugs
- collapse to a single CSS variable, masking a real bug).
+- `settings.color.palette` entries each carry `slug`, `name`, `color`;
+ slugs must be unique across the palette (duplicate slugs collapse to a
+ single CSS variable, masking a real bug).
- `settings.typography.fontFamilies` and `settings.typography.fontSizes`
likewise require unique slugs.
+- Per-block styles under `styles.blocks.{block}` must reference a
+ registered block name.
-A 422 response with a `version`, `settings`, or
-`settings.color.palette` validation error is the signal that the
-payload does not match the pinned schema.
+A 422 response with `version`, `settings`, or `settings.color.palette`
+validation errors is the signal that the payload doesn't match the
+pinned schema.
-## Multi-theme scoping
+---
-The record carries a `theme` column so each installed theme gets its
-own singleton. The active theme is configured at
+## 5. Multi-theme scoping
+
+The record carries a `theme` column so each installed theme gets its own
+singleton. The active theme is configured at
`artisanpack.visual-editor.global_styles.theme` (default
-`artisanpack-base`). Switching themes gets a fresh singleton rather
+`artisanpack-base`). Switching themes loads a fresh singleton rather
than inheriting the previous theme's customizations.
-## Handling a future schema bump
+---
+
+## 6. Site-editor styles panels
+
+The site editor's Styles section exposes four nested panels:
+
+- **Typography** — font families, font sizes, base type styles
+ (`styles.typography`), element overrides (heading, link).
+- **Colors** — palette, background, text, link.
+- **Layout** — content width, wide width, root padding, spacing scale.
+- **Blocks** — per-block style overrides (`styles.blocks.{block}`),
+ organized alphabetically by block name.
+
+Each panel is a controlled form over a slice of the record; saves are
+debounced and write the full record via PUT.
+
+A "Reset to theme default" button at the panel level (and per-field
+chevrons) deletes the user value for that key, restoring whatever
+`/global-styles/base` returns.
+
+---
+
+## 7. CSS emission
+
+`GET /visual-editor/api/global-styles/css` returns a stylesheet derived
+from the user record. The Blade renderer's `` and React/Vue
+`` components inject it once at the root.
+
+Generated CSS shape:
+
+```css
+:root {
+ --wp--preset--color--primary: #3b82f6;
+ --wp--preset--font-size--base: 1rem;
+ --wp--preset--font-family--sans: Inter, system-ui, sans-serif;
+}
+
+body {
+ color: var(--wp--preset--color--text);
+ background-color: var(--wp--preset--color--background);
+ font-family: var(--wp--preset--font-family--sans);
+}
+
+.is-style-h1, h1.wp-block-heading {
+ font-size: var(--wp--preset--font-size--xxx-large);
+}
+
+.wp-block-button {
+ /* per-block styles emitted from styles.blocks['artisanpack/button'] */
+}
+```
+
+CSS variables follow the `--wp--preset--{category}--{slug}` convention
+so existing Gutenberg block markup using `var(--wp--preset--*)` resolves
+without modification.
+
+---
+
+## 8. Handling a future schema bump
When the `@wordpress/*` pins are upgraded and the upstream theme.json
schema changes:
@@ -107,21 +183,20 @@ schema changes:
3. Update `resources/theme-json/default-base.php` so the defaults match
the new schema.
4. Ship a migration that either rewrites existing user records into the
- new schema shape or documents an upgrade path for host apps that
- have customized their global styles.
+ new schema shape or documents an upgrade path for host apps that have
+ customized their global styles.
5. Update this doc with the new pinned version and the WP core release
it tracks.
-The point of the pin is that each of these steps is a conscious
-decision — not a silent change that happens the next time `composer
-update` or `npm update` runs.
+The point of the pin is that each of these steps is a conscious decision
+— not a silent change that happens the next time `composer update` or
+`npm update` runs.
+
+---
-## Out of scope for C3
+## See also
-- CSS emission / frontend rendering — **E1** (front-end global-styles
- CSS emission).
-- Site-editor global-styles UI (typography, colors, layout, blocks,
- variations) — **D3**.
-- Style variations (theme-level presets) — deferred; §8 of the plan doc
- tracks this as an open question for 1.1.
-- Revision history of global-styles edits.
+- [Site editor](site-editor.md) — the surface that edits global styles
+- [Templates](templates.md) — templates that the styles apply to
+- [Renderers](renderers.md) — CSS injection on the public site
+- [Troubleshooting](troubleshooting.md) — schema-bump procedure
diff --git a/docs/inertia.md b/docs/inertia.md
new file mode 100644
index 000000000..4cc7e064f
--- /dev/null
+++ b/docs/inertia.md
@@ -0,0 +1,256 @@
+# Inertia embedding recipes
+
+The visual editor is a React app under the hood, so embedding inside
+Inertia is just a matter of mounting the Blade component on the Inertia
+page and letting the React tree boot inside it. The same recipe works
+inside Inertia + React (direct) and Inertia + Vue (via the Vue wrapper).
+
+Both recipes assume the editor is being mounted inside an Inertia page
+that the host app owns — the host controls auth, navigation, and
+non-editor chrome; the editor takes over the canvas region.
+
+---
+
+## 1. Inertia + React
+
+The Blade `` mount works inside an Inertia-rendered
+template because Inertia hydrates inside an outer Blade layout. Mount the
+editor as a top-level page or inside a content slot, and import the
+editor's React bundle from the host's entrypoint.
+
+### Server-side: the Inertia page
+
+```php
+// app/Http/Controllers/PostEditorController.php
+public function edit(Post $post)
+{
+ return Inertia::render('Posts/Edit', [
+ 'post' => [
+ 'id' => $post->id,
+ 'title' => $post->title,
+ 'slug' => $post->slug,
+ 'status' => $post->status,
+ 'previewUrl' => route('posts.show', $post),
+ ],
+ ]);
+}
+```
+
+### Client-side: the React page
+
+```tsx
+// resources/js/Pages/Posts/Edit.tsx
+import { useEffect, useRef } from 'react';
+import { mountVisualEditor } from '@artisanpack-ui/visual-editor';
+
+export default function PostEditor({ post }) {
+ const mountRef = useRef(null);
+
+ useEffect(() => {
+ if (!mountRef.current) return;
+
+ const editor = mountVisualEditor(mountRef.current, {
+ resource: 'posts',
+ id: String(post.id),
+ apiBase: '/visual-editor/api',
+ initialTitle: post.title,
+ initialSlug: post.slug,
+ initialStatus: post.status,
+ previewUrl: post.previewUrl,
+ supports: { excerpt: true, featuredImage: true },
+ });
+
+ return () => editor.unmount();
+ }, [post.id]);
+
+ return (
+
+
+
+ );
+}
+```
+
+The `mountVisualEditor(el, config)` helper is the imperative equivalent
+of the Blade component — pass the same config keys (camelCase). It returns
+an `{ unmount, save, getBlocks }` handle.
+
+### Bridging editor events
+
+The same `ve:editor:*` events the Blade component dispatches fire here.
+Listen in the page component:
+
+```tsx
+useEffect(() => {
+ const onSave = (event: CustomEvent) => {
+ if (event.detail.resource !== 'posts') return;
+ if (event.detail.id !== String(post.id)) return;
+ // Sync Inertia state, show toast, etc.
+ };
+
+ window.addEventListener('ve:editor:save', onSave as EventListener);
+ return () => window.removeEventListener('ve:editor:save', onSave as EventListener);
+}, [post.id]);
+```
+
+### Inertia navigation and `wire:ignore`-equivalent
+
+Inertia doesn't have `wire:ignore` — when navigating between Inertia
+pages, the React tree unmounts entirely. The cleanup function in the
+`useEffect` handles this; the editor's autosave debounce flushes before
+unmount.
+
+To preserve unsaved edits during programmatic navigation, gate
+`Inertia.visit()` on the editor's `getBlocks()` returning a clean state:
+
+Inside the `useEffect`, hold the editor handle in a ref so it survives
+between renders:
+
+```tsx
+const editorRef = useRef>();
+
+useEffect(() => {
+ if (!mountRef.current) return;
+ editorRef.current = mountVisualEditor(mountRef.current, { /* …config */ });
+ return () => editorRef.current?.unmount();
+}, [post.id]);
+
+const handleNavigate = (href: string) => {
+ if (editorRef.current?.isDirty()) {
+ if (!confirm('You have unsaved changes. Leave?')) return;
+ }
+ Inertia.visit(href);
+};
+```
+
+---
+
+## 2. Inertia + Vue
+
+The Vue wrapper at `@artisanpack-ui/visual-editor/vue` exports a
+`` Vue component that wraps the React editor in a Vue tree
+(the editor itself stays React; Vue manages mount/unmount + prop
+reactivity).
+
+### Server-side: same Inertia page
+
+The controller is identical to the React recipe — Inertia doesn't care
+which client renderer the page uses.
+
+### Client-side: the Vue page
+
+```vue
+
+
+
+
+
+
+
+
+```
+
+The Vue component re-emits `ve:editor:*` browser events as Vue
+component events (`save`, `autosave`, `change`) so you can wire them
+declaratively.
+
+### Vue → React data flow
+
+Props on `` map 1:1 to the React editor's config. Changing
+a prop after mount re-renders the React tree with the new config — but
+the editor doesn't reload content on a re-render. Change `:id` or
+`:resource` and the editor will detect it and fetch the new entity.
+
+---
+
+## 3. Front-end rendering
+
+The Inertia + React / Inertia + Vue recipes above are for the **edit
+surface**. Rendering saved content on the public site uses the renderer
+packages:
+
+- React: `@artisanpack-ui/visual-editor-renderer-react`
+- Vue: `@artisanpack-ui/visual-editor-renderer-vue`
+
+```tsx
+// resources/js/Pages/Posts/Show.tsx
+import { BlockTree, GlobalStyles } from '@artisanpack-ui/visual-editor-renderer-react';
+
+export default function PostShow({ post }) {
+ return (
+ <>
+
+
+ >
+ );
+}
+```
+
+See [Renderers](renderers.md) for the full client renderer API.
+
+---
+
+## 4. SSR
+
+The editor itself is client-only (it relies on `window` and DOM APIs).
+Inertia SSR will skip the editor's render pass — wrap the mount in a
+`` (Inertia + Vue) or guard the `useEffect` (Inertia + React)
+so server rendering produces an empty mount point.
+
+Front-end **renderers** work in SSR — the React renderer renders to
+static HTML, the Vue renderer renders to static markup. Use the Blade
+renderer when you want SSR via Laravel itself (Inertia or not).
+
+---
+
+## 5. Authentication
+
+The editor's API calls inherit the host page's session cookies — no
+extra wiring needed when Inertia uses the standard `web` middleware. For
+Sanctum / API-token auth, override the API middleware:
+
+```php
+// config/artisanpack/visual-editor.php
+'api' => [
+ 'middleware' => ['api', 'auth:sanctum'],
+],
+```
+
+---
+
+## See also
+
+- [Blade component reference](blade-component.md) — the underlying mount contract
+- [Renderers](renderers.md) — public-site rendering
+- [Livewire](livewire.md) — alternative embedding inside a server-rendered Blade page
diff --git a/docs/migration.md b/docs/migration.md
new file mode 100644
index 000000000..f668147f4
--- /dev/null
+++ b/docs/migration.md
@@ -0,0 +1,148 @@
+# Migration
+
+Migrating content **from WordPress into the visual editor** is handled
+by a companion package, not by `visual-editor` itself. This page
+explains why, what the companion does, and what the in-package
+migrations cover.
+
+---
+
+## 1. WordPress import — companion package
+
+**Package:** `artisanpack-ui/visual-editor-wp-import`
+**Status:** Planned. Ships separately on its own release cadence;
+first version targets after V1.0.0 of `visual-editor`.
+
+The companion handles the one-way migration from a WordPress export
+(WXR or direct database connection) into your Laravel app:
+
+- Maps WP `posts`, `pages`, `wp_template`, `wp_template_part`,
+ `wp_navigation`, `wp_block` (patterns), and `wp_global_styles` into
+ the corresponding Laravel models.
+- Rewrites `core/*` block names to `artisanpack/*` using the same
+ transform table the block forks ship (`from:core/*` in each block.json).
+- Resolves attachment URLs to media library records.
+- Resolves user references to local user records by email or username.
+- Optionally preserves WP post ids, slugs, dates, and meta.
+
+Reasons it lives in its own package:
+
+- Importer dependencies (WP XML parsers, HTTP clients for remote media,
+ optional MySQL drivers) are heavy and only needed once.
+- Import is a one-off operation — bundling it into the runtime package
+ bloats every install.
+- Importer release cycle is faster than the editor's; a bug in the
+ importer shouldn't force a `visual-editor` patch release.
+
+When the companion is published, this page will link to its own
+documentation. Until then, manual migration is the path: export WP
+content, transform locally, seed via Laravel.
+
+---
+
+## 2. In-package migrations
+
+`visual-editor` ships these migrations under `database/migrations/`:
+
+| Migration | Purpose |
+|-----------|---------|
+| `2026_04_14_000000_create_ve_contents_table.php` | Creates the `ve_contents` fallback table used by the legacy `VisualEditorPost` model and the `/editor` test route. Host-app resource models don't touch this table. |
+| `2026_05_15_000000_drop_legacy_visual_editor_tables.php` | Drops the Phase D legacy tables (`visual_editor_templates`, `_template_parts`, `_global_styles`, `_navigations`, `_patterns`, `_pattern_categories`, and the pivot). Idempotent — safe on fresh installs. |
+
+cms-framework provides the actual storage tables for templates, parts,
+patterns, global styles, and menus. Install cms-framework and run
+`php artisan migrate` and the site-editor REST surface is backed by real
+tables.
+
+---
+
+## 3. Upgrading existing host-app models
+
+Adding `HasBlockContent` to an existing model requires a column:
+
+```php
+Schema::table('posts', function (Blueprint $table) {
+ $table->json('content')->nullable();
+});
+```
+
+Set `$blockContentColumn` on the model if the column name is something
+other than `content`.
+
+For models that already store HTML or Markdown in a different column,
+keep both: the legacy column remains the source of truth until you've
+backfilled the block-content column from it, then deprecate the
+legacy column. cms-framework follows this pattern — its `posts` and
+`pages` tables retain a `content longText` column alongside the new
+`block_content json` column. See plan-12 §4.2.
+
+---
+
+## 4. Upgrading from `v1.0.0-alpha.1`
+
+If you installed `v1.0.0-alpha.1` (the Gutenberg-adoption marker), the
+upgrade path to `v1.0.0` is:
+
+1. `composer update artisanpack-ui/visual-editor`.
+2. `php artisan migrate` to apply the legacy-table drop.
+3. `npm install` then `npm run build` to pick up the new editor bundle.
+4. Review the [CHANGELOG](../CHANGELOG.md) for breaking changes —
+ notable: all `core/*` blocks are now `artisanpack/*` via the
+ Phase I block fork; pasted upstream `core/*` markup auto-converts on
+ insert via the `from:core/*` transforms shipped on every fork.
+
+Existing post content saved against `v1.0.0-alpha.1`'s `core/*` blocks
+re-renders correctly — the renderers recognize both namespaces during
+V1. From V2 onwards, only `artisanpack/*` is registered for new content;
+old `core/*` content continues to render via the transform table.
+
+---
+
+## 5. Upgrading from a future version
+
+`visual-editor` follows semver:
+
+- Patch releases (`v1.0.x`) — bug fixes, no API changes.
+- Minor releases (`v1.x.0`) — new features, backwards-compatible.
+- Major releases (`v2.0.0`) — breaking changes, documented migration
+ path in that release's CHANGELOG.
+
+Each major release ships a `docs/upgrade-{from}-to-{to}.md` doc
+covering the breaking changes and required migration steps.
+
+---
+
+## 6. Manual content migration
+
+If `visual-editor-wp-import` isn't an option (custom CMS source,
+hand-crafted content), the manual path is:
+
+1. **Export source content** — get the source HTML/JSON into a
+ transformable shape.
+2. **Map to block trees** — write a transformer that produces
+ Gutenberg-shape block JSON:
+
+ ```php
+ [
+ [
+ 'name' => 'artisanpack/paragraph',
+ 'attributes' => ['content' => $htmlParagraph],
+ 'innerBlocks' => [],
+ ],
+ // …
+ ];
+ ```
+
+3. **Seed via Laravel** — set the block tree on each model via
+ `$model->setBlockContent($blocks)` and save.
+
+The block content column is plain JSON; you can backfill via a
+database seeder, an artisan command, or a one-off migration script.
+
+---
+
+## See also
+
+- [Getting started](getting-started.md)
+- [Content model](content-model.md) — `HasBlockContent`, resource map
+- [`visual-editor-wp-import` repo](https://github.com/ArtisanPack-UI/visual-editor-wp-import) (when published)
diff --git a/docs/navigation.md b/docs/navigation.md
new file mode 100644
index 000000000..bf39afaef
--- /dev/null
+++ b/docs/navigation.md
@@ -0,0 +1,200 @@
+# Navigation
+
+Navigation menus are editable through the site editor's Navigation
+section, persisted by cms-framework's `Menu` model, exposed via REST,
+and rendered on the public site through the `core/navigation` block.
+
+This page covers menu locations, the fallback chain, the `core/navigation`
+block, REST endpoints, and the nav editor UI.
+
+---
+
+## 1. Concepts
+
+- **Menu** — a named, ordered tree of links. Persisted in cms-framework's
+ `menus` table. Can be assigned to one or more **menu locations**.
+- **Menu item** — a single link inside a menu. Persisted in `menu_items`
+ (id, menu_id, parent_id, title, url, type, object, position).
+- **Menu location** — a theme-declared slot (e.g. `primary`, `footer`,
+ `mobile`). The theme decides what locations exist; the site editor
+ assigns menus to them.
+- **`core/navigation` block** — renders a menu by id or by location with
+ a fallback chain.
+
+---
+
+## 2. Menu locations
+
+Locations are declared in config:
+
+```php
+// config/artisanpack/visual-editor.php
+'site-editor' => [
+ 'navigation' => [
+ 'locations' => [
+ 'primary' => ['title' => 'Primary Menu'],
+ 'footer' => ['title' => 'Footer Menu'],
+ 'mobile' => ['title' => 'Mobile Menu'],
+ ],
+ ],
+],
+```
+
+The site editor lists declared locations in the Navigation section and
+lets authors assign any menu to any location.
+
+Locations are read-only via REST — `GET /visual-editor/api/menu-locations`
+returns the declared list; menu → location assignment is written via the
+menu's `location` field on `PUT /menus/{id}`.
+
+---
+
+## 3. Fallback chain
+
+When a `core/navigation` block renders, the resolver walks this chain:
+
+1. **Explicit menu id** on the block attributes — `{ menuId: 7 }`.
+2. **Menu assigned to the requested location** — `{ location: 'primary' }`
+ resolves to whatever menu currently holds `location = 'primary'`.
+3. **Fallback menu** — declared in config as the location's `fallback`
+ key:
+
+ ```php
+ 'locations' => [
+ 'primary' => [
+ 'title' => 'Primary Menu',
+ 'fallback' => 'auto', // 'auto' | 'first' | menu-slug | null
+ ],
+ ],
+ ```
+
+ - `'auto'` — auto-generate from top-level pages (the WordPress
+ default behaviour).
+ - `'first'` — the first menu by created_at.
+ - `'menu-slug'` — a specific menu's slug.
+ - `null` — render nothing.
+
+4. **Empty render** — emits a wrapping `