Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9374966
feat: composed template preview foundation (#619, #620, #621, #622)
ViewFromTheBox Jul 14, 2026
fc5696a
fix(composed-view): 200 discriminator + pivot to inert chrome preview
ViewFromTheBox Jul 14, 2026
528c14a
fix(composed-view): address critical/major review findings on #654
ViewFromTheBox Aug 3, 2026
3ed5da5
feat(composed-view): render real template chrome in the canvas (#655)
ViewFromTheBox Aug 3, 2026
8a55ba8
fix(composed-view): parse theme templates that ship raw markup only
ViewFromTheBox Aug 3, 2026
1688949
fix(composed-view): fork every core block name in template envelopes
ViewFromTheBox Aug 3, 2026
386783b
fix(patterns): count forked pattern refs in usage lookup
ViewFromTheBox Aug 3, 2026
b40e120
Merge pull request #654 from ArtisanPack-UI/feature/619-composed-view…
ViewFromTheBox Aug 3, 2026
c5cef9c
feat(composed-view): fall back to a default template with a toast
ViewFromTheBox Aug 3, 2026
73f0a30
Merge pull request #694 from ArtisanPack-UI/feature/624-composed-view…
ViewFromTheBox Aug 3, 2026
70b234f
feat: composed-view ribbon and site-editor slug deep links
ViewFromTheBox Aug 4, 2026
6afd9d2
fix(tests): restore Web Storage in the vitest jsdom environment
ViewFromTheBox Aug 4, 2026
42a32b2
Merge pull request #696 from ArtisanPack-UI/feature/623-composed-ribb…
ViewFromTheBox Aug 4, 2026
4a833d0
test(composed-view): pin chrome inertness, toggle state, bindings + docs
ViewFromTheBox Aug 4, 2026
0d87ec7
Merge pull request #697 from ArtisanPack-UI/enhancement/626-composed-…
ViewFromTheBox Aug 4, 2026
22f44ed
fix(composed-view): resolve high-severity review findings (H1-H3)
ViewFromTheBox Aug 5, 2026
cf4744f
fix(composed-view, site-editor): resolve medium-severity review findings
ViewFromTheBox Aug 5, 2026
aade6df
fix(composed-view, site-editor): resolve low-severity review findings
ViewFromTheBox Aug 5, 2026
02cf6db
fix(composed-view, site-editor): address pre-PR review findings
ViewFromTheBox Aug 5, 2026
997810d
fix(composed-view, site-editor): address CodeRabbit PR review
ViewFromTheBox Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

/**
* Fixture table for the #619 applied-template endpoint tests. Kept separate
* from `test_block_content_pages` so the PageResource contract test can
* continue asserting that `template` stays absent when a resource model
* doesn't declare it.
*
* @package ArtisanPack_UI
* @subpackage VisualEditor
*
* @author Jacob Martella <me@jacobmartella.com>
*
* @since 1.1.0
*/

declare( strict_types=1 );

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create( 'test_applied_template_pages', function ( Blueprint $table ) {
$table->id();
$table->unsignedBigInteger( 'author_id' )->nullable()->index();
$table->string( 'title' )->default( '' );
$table->string( 'template' )->nullable();
$table->json( 'content' )->nullable();
$table->timestamps();
} );
}

public function down(): void
{
Schema::dropIfExists( 'test_applied_template_pages' );
}
};
2 changes: 2 additions & 0 deletions docs/home.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ The block editor for a single resource — a post, page, or any custom Eloquent
- [[Post Editor]] — Surface tour and what each region does
- [[post-editor/Getting Started]] — Mount your first editor in five minutes
- [[post-editor/Blade Component]] — `<x-visual-editor />` reference
- [[post-editor/Composed View]] — Editing content inside its resolved template
- [[post-editor/Livewire Integration]] — Embedding inside Livewire components
- [[post-editor/Inertia Integration]] — Embedding inside Inertia (React or Vue)
- [[post-editor/Theming]] — Restyling editor chrome through DaisyUI tokens
Expand Down Expand Up @@ -115,6 +116,7 @@ All endpoints use the `/visual-editor/api/` prefix and run through the middlewar

### Content
- `GET/PUT /{resource}/{id}/content` — Block content CRUD for any registered resource
- `GET /{resource}/{id}/applied-template` — Resolved template chrome for the post editor's composed view
- `POST /blocks/preview` — Dynamic block editor preview rendering
- `GET /blocks` — Enabled-block manifest (allow/deny lists applied)
- `POST /query/resolve` — `core/query` block resolution
Expand Down
8 changes: 8 additions & 0 deletions docs/post-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ on boot and injected into the iframe via the React renderer's
Block selection, drag-and-drop, undo/redo, and keyboard shortcuts come
from `@wordpress/block-editor` unchanged.

The topbar's **View with template** switch composes the
resolved template's header and footer around the canvas as a read-only
preview, so authors can judge their content in context without leaving
the editor. Template chrome is edited in the site editor, reached from
the composed canvas's **Edit template ↗** deep link. See
[Composed view](post-editor/Composed-View.md).

---

## 3. Inspector sidebar
Expand Down Expand Up @@ -194,6 +201,7 @@ or Inertia. Embedding recipes:
## See also

- [Blade component reference](post-editor/Blade-Component.md)
- [Composed view](post-editor/Composed-View.md) — edit content inside its resolved template
- [Custom blocks](blocks/Custom-Blocks.md)
- [Renderers](renderers.md) — get saved content back onto the public site
- [Theming](post-editor/Theming.md) — restyle editor chrome
200 changes: 200 additions & 0 deletions docs/post-editor/Composed-View.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Composed view

The composed view previews an author's content *inside the template it
will actually render in* — header, footer, and all — without leaving the
post editor. Content stays fully editable; the surrounding template
chrome is a read-only preview.

It answers the question the bare canvas can't: "does this heading still
work under the site header, and does the page end sensibly above the
footer?" Before it, the only way to find out was to open Preview in
another tab and lose the editing context.

---

## 1. When to use it

Use it when the *shape* of the page matters:

- Checking a hero or opening heading against the real site header.
- Judging spacing and rhythm at the top and bottom of the content.
- Confirming which template a piece of content actually resolved to.

Keep it off for ordinary writing. The chrome is extra rendering on every
canvas update, and the content area is narrower on small screens because
the template's layout constraints apply to it.

The composed view is **not** pixel-parity with the front end. Template
wrappers that contain the content slot are split in two around it (see
§3), which the front end does not do. Use **Preview** when you need the
real thing.

---

## 2. Toggling it

The topbar carries a switch beside the save status, labelled **View with
template** (`role="switch"`). The label is constant and `aria-checked`
carries the on/off state, so the accessible name matches the visible text
— a label that swapped with the state would fail WCAG 2.5.3 (Label in
Name) and leave speech-input users unable to activate it by reading it.

- Every editor mount starts with the switch **off**, showing content
only. The view mode is ephemeral — it is not persisted per user or per
document.
- The first flip **on** issues
`GET /visual-editor/api/{resource}/{id}/applied-template`, passing the
template currently selected in the document panel rather than the one
last saved. The switch stays enabled while that request is in flight,
so switching back off always works — a request that never settles can
never strand the author in composed view.
- Results are cached per `(resource, id, template)` for the lifetime of
the editor mount, so later flips are instant. Picking a different
template in the document panel invalidates that cache and re-resolves.

Toggling is a presentation change only. It records no undo entry, marks
nothing dirty, and never touches the block tree — selection, undo history
and unsaved changes are all exactly where you left them when you flip
back.

---

## 3. What is editable, and what is not

| Region | State |
|--------|-------|
| Post title | Editable — the canvas title field, as in bare-content mode |
| Content blocks | Editable — the live block canvas, unchanged |
| Template header chrome | Read-only preview |
| Template footer chrome | Read-only preview |
| Ribbon at the top of the canvas | Not content — editor chrome (see §5) |

The template's blocks render *above and below* the block canvas, inside
the same iframe, so they pick up the theme's compiled CSS exactly as the
content does.

**Why a preview rather than a locked editable.** The chrome is rendered
through Gutenberg's block-preview provider, which mounts its own isolated
block-editor store and wraps the subtree in `useDisabled()`. That is what
makes it genuinely non-selectable and non-editable — not a `lock`
attribute, which only asks the UI nicely and still leaves an editable
surface with a live selection. The isolation matters for a second reason:
the content editor's own block tree is never swapped when you toggle, so
there is no state to lose and no feedback loop between the two trees.

The practical consequence: clicking the site header in the composed view
does nothing at all. That is intended, and the standing notice above the
canvas says so — *Only your content is editable here — the surrounding
template areas are edited in the site editor.* To change the chrome, use
the ribbon CTA (§5).

**Where the split happens.** The template is cut at its
`post-content` slot: everything before becomes header chrome, everything
after becomes footer chrome. When the slot is nested inside a layout
wrapper, the wrapper is cloned onto both sides so the surrounding layout
still reads correctly. If a template declares no content slot at all, the
whole template renders as header chrome and a warning notice says so —
*The template "…" has no content area, so your content is shown below the
whole template.*

**Bindings resolve against the current content.** `post-title`,
`post-author`, `post-date` and `post-featured-image` blocks inside the
template chrome resolve their bound values against the post being edited,
not against the template's sample data. The chrome renders inside the
same entity block context as the content, so it shows this post's title
and this post's author.

---

## 4. The fallback template

The composed view always has something to show. When no real template
resolves, it composes against a built-in **Default template** — a minimal
tree of post title, featured image, content slot, in that order.

It kicks in for three cases:

| Case | Notice tone | Copy |
|------|-------------|------|
| Content has no template selected | warning | *No template is set for this content — previewing on the default template.* |
| Selected slug resolves to nothing | warning | *The template "{slug}" is unavailable — previewing on the default template.* |
| The request failed outright | error | *The template could not be loaded — previewing on the default template.* |

Each is announced twice, deliberately: once as a toast when the toggle is
flipped on, and once as a standing notice above the canvas that is still
there a minute later when the toast has auto-dismissed.

The default template is a client-side constant, not a database record.
It is not editable, does not appear in the site editor's template list,
and has no slug — which is why the ribbon's **Edit template ↗** CTA is
hidden while composing against it.

---

## 5. The ribbon and the Edit template deep link

A sticky ribbon leads the composed canvas: *Editing content inside
{template name}*, plus a single **Edit template ↗** link.

It sits *inside* the canvas iframe so it scrolls with the preview and
reads as a property of the page rather than another editor toolbar.
Individual template parts get no per-part badge — one statement at the
top, not five competing controls on a surface whose whole point is to
read like the finished page.

The CTA opens the site editor in a **new tab**, at a query-string deep
link:

```text
/visual-editor/site?entity=template&slug=single
```

New tab on purpose: the post editor may be holding unsaved content, and
navigating away from it to edit template chrome would be a data-loss
trap.

The site editor parses that query string on mount, resolves the slug, and
lands on that template's editor view — rewriting the address bar to the
canonical path route as it goes. The full contract, including the
`entity_id` escape hatch and the unresolvable-slug behaviour, is in
[Site editor §5](../site-editor.md#deep-links-by-slug).

Build these links with `buildTemplateDeepLink()` from
`site-editor/deep-link.ts` rather than assembling the query string by
hand. It defaults to the package's own mount path; a host that mounts the
site editor elsewhere will get a CTA pointing at the default path until
that route base is threaded through to the post editor.

---

## 6. Endpoint

```http
GET /visual-editor/api/{resource}/{id}/applied-template?template={slug}
```

Returns the resolved template and its referenced template parts, or a
discriminated miss the client turns into the §4 fallback:

```jsonc
// hit
{ "status": "ok", "slug": "single", "name": "Single Post", "source": "db",
"blocks": [ /* … */ ], "template_parts": { "header": { /* … */ } } }

// miss
{ "status": "missing", "reason": "empty" }
{ "status": "missing", "reason": "unknown-slug", "slug": "does-not-exist" }
```

The `template` query parameter overrides the slug persisted on the model,
so a preview taken right after a template change does not race the
debounced save. A blank `?template=` is meaningful: it says the selection
was *cleared*, and is answered with `reason: "empty"`.

---

## See also

- [Post editor](../post-editor.md) — the surrounding surface
- [Site editor §5](../site-editor.md#5-url-routing) — deep-link contract
- [Templates](../site-editor/Templates.md) — hierarchy, fallback chain, template parts
57 changes: 57 additions & 0 deletions docs/site-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,62 @@ entity work:
These are shareable — bookmark or send to a colleague to jump straight
into editing that entity.

### Deep links by slug

The trailing path segment is an opaque entity identifier — the router
hands it to the entity fetch unchanged — but every link the site editor
produces for itself addresses the entity by database id. Callers outside
the SPA usually do not have one: the post editor's
[composed view](post-editor/Composed-View.md), for instance, knows only
the slug of the template it composed against. For those, the site editor
accepts a query-string deep link on the mount URL:

```text
/visual-editor/site?entity=template&slug=single
```

On mount the SPA parses the query string, resolves the slug through the
templates list endpoint, and navigates to that template's editor view.
That landing *replaces* the current history entry with the canonical path
route (`/visual-editor/site/templates/12`), so the query-string form is an
entry point rather than a URL the user is left holding — and Back does not
return to it. (A pushed entry would: the popstate handler re-reads the
pathname only, so going back to the query-string URL would show the index
while the address bar still claimed the deep link.)

| Parameter | Required | Notes |
|-----------|----------|-------|
| `entity` | yes | Only `template` is supported today. Unknown values are ignored. |
| `slug` | yes | Entity slug. Blank or whitespace-only values are ignored. |
| `entity_id` | no | Pre-resolved id. When supplied the slug lookup is skipped. Reserved for entity kinds that have no slug. |

Behaviour worth relying on:

- **Additive.** Mounting with no query string behaves exactly as it did
before — no lookup is issued and the SPA lands on its default section.
- **Unknown entity is a no-op**, not an error. A link written against a
future package version lands on the default section rather than
throwing.
- **Unresolvable slug lands on the Templates index** with a toast reading
`The template "{slug}" was not found.` A failed lookup does the same.
- **The access gate is unchanged.** `SiteEditorAccessGate` runs
server-side, before the SPA mounts, so a denied request still renders
the deny-by-default page and never reaches this parsing.

Build links with `buildTemplateDeepLink()` from
`site-editor/deep-link.ts` rather than assembling the query string by
hand, so both sides of the contract move together. The first consumer is
the post editor's [composed-view ribbon](post-editor/Composed-View.md#5-the-ribbon-and-the-edit-template-deep-link),
whose **Edit template ↗** button opens one of these links in a new tab.

`buildTemplateDeepLink()` defaults to the package's own mount path
(`/visual-editor/site`) and takes the route base as an optional second
argument. The composed-view ribbon currently relies on that default: the
post editor has no way to discover a `data-route-base` the host may have
overridden on the site-editor mount element. A host that mounts the site
editor at a non-default path will get a ribbon CTA pointing at the
default path until that value is threaded through to the post editor.

---

## 6. Preview
Expand Down Expand Up @@ -218,6 +274,7 @@ chain — user records win on the same slug. See [Templates §4](site-editor/Tem

- [Templates](site-editor/Templates.md) · [Global styles](site-editor/Global-Styles.md) · [Navigation](site-editor/Navigation.md) · [Patterns](site-editor/Patterns.md)
- [Access Gate](site-editor/Access-Gate.md) — site-editor access gate contract
- [Composed view](post-editor/Composed-View.md) — the post-editor surface that deep-links here
- [Content model](content-model.md) — `HasBlockContent` and authorization
- [Renderers](renderers.md) — render saved entities on the public site
- [Photo Grid](photo-grid.md) — container-level Photo Grid setting (group / columns / grid). theme.json defaults live under `settings.artisanpack.photoGrid` and ride the same global-styles plumbing.
Loading
Loading