From c5cef9c6af7ab01132f5429793be5dc13b9717ac Mon Sep 17 00:00:00 2001 From: Jacob Martella Date: Mon, 3 Aug 2026 16:09:14 -0500 Subject: [PATCH] feat(composed-view): fall back to a default template with a toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the applied-template endpoint cannot resolve a template for the current content, the composed view previously rendered bare content behind a "no template chrome is shown" banner — the toggle read as having silently done nothing. All three unresolvable states now compose against a minimal built-in default template (title, featured image, content slot) and announce why: - `missing` / `empty` — content has no template selected - `missing` / `unknown-slug` — the selected slug resolves to nothing - `error` — the request failed outright The first two read as warnings, the third as an error, but all land on the same canvas so the toggle stays on and functional. A toast fires once per toggle-on event; the latch keys on the message rather than a boolean so picking a second broken template without leaving composed mode still announces the new failure. The standing alert repeats the copy for after the toast auto-dismisses. The composed chrome now carries `templateSlug` (null on the fallback) and `isFallback` so the #623 ribbon can show the fallback name and hide its Edit template CTA — there is nothing to deep-link to. Closes #624 --- .../composed-view/__tests__/fallback.test.ts | 139 +++++++++++ .../__tests__/use-fallback-toast.test.tsx | 219 ++++++++++++++++++ .../editor/composed-view/default-template.ts | 63 +++++ .../editor/composed-view/fallback.ts | 135 +++++++++++ .../editor/composed-view/index.ts | 15 ++ .../composed-view/use-fallback-toast.ts | 73 ++++++ .../js/visual-editor/editor/editor-app.tsx | 76 +++--- 7 files changed, 679 insertions(+), 41 deletions(-) create mode 100644 resources/js/visual-editor/editor/composed-view/__tests__/fallback.test.ts create mode 100644 resources/js/visual-editor/editor/composed-view/__tests__/use-fallback-toast.test.tsx create mode 100644 resources/js/visual-editor/editor/composed-view/default-template.ts create mode 100644 resources/js/visual-editor/editor/composed-view/fallback.ts create mode 100644 resources/js/visual-editor/editor/composed-view/use-fallback-toast.ts diff --git a/resources/js/visual-editor/editor/composed-view/__tests__/fallback.test.ts b/resources/js/visual-editor/editor/composed-view/__tests__/fallback.test.ts new file mode 100644 index 000000000..1082c181d --- /dev/null +++ b/resources/js/visual-editor/editor/composed-view/__tests__/fallback.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import { ApiError } from '../../api-client'; +import { composedFallbackNotice, selectComposedTemplate } from '../fallback'; +import { splitTemplateAroundContentSlot } from '../split'; +import type { AppliedTemplate } from '../api'; +import type { AppliedTemplateState } from '../use-applied-template'; + +const unknownSlug: AppliedTemplateState = { + status: 'missing', + missing: { status: 'missing', reason: 'unknown-slug', slug: 'landing-xl' }, +}; + +const emptyTemplate: AppliedTemplateState = { + status: 'missing', + missing: { status: 'missing', reason: 'empty' }, +}; + +const networkError: AppliedTemplateState = { + status: 'error', + error: new ApiError( + 'Applied-template request failed with status 500', + 500, + null + ), +}; + +const resolved: AppliedTemplateState = { + status: 'ok', + template: { + status: 'ok', + slug: 'single-post', + name: 'Single Post', + source: 'db', + blocks: [], + template_parts: {}, + } as AppliedTemplate, +}; + +describe('selectComposedTemplate', () => { + it('passes a resolved template straight through', () => { + const selection = selectComposedTemplate(resolved); + + expect(selection).not.toBeNull(); + expect(selection?.name).toBe('Single Post'); + expect(selection?.slug).toBe('single-post'); + expect(selection?.fallbackReason).toBeNull(); + }); + + it.each([ + ['an unresolvable slug', unknownSlug, 'unknown-slug'], + ['an empty template field', emptyTemplate, 'empty'], + ['a network error', networkError, 'error'], + ])('routes %s to the default template', (_label, state, reason) => { + const selection = selectComposedTemplate(state); + + expect(selection).not.toBeNull(); + expect(selection?.fallbackReason).toBe(reason); + expect(selection?.slug).toBeNull(); + expect(selection?.name).toBe('Default template'); + expect(selection?.blocks.map((block) => block.name)).toEqual([ + 'artisanpack/post-title', + 'artisanpack/post-featured-image', + 'artisanpack/post-content', + ]); + }); + + it('splits the default template around its content slot', () => { + const selection = selectComposedTemplate(emptyTemplate); + const split = splitTemplateAroundContentSlot( + selection?.blocks ?? [], + selection?.name ?? '' + ); + + expect(split.slotFound).toBe(true); + expect(split.header.map((block) => block.name)).toEqual([ + 'artisanpack/post-title', + 'artisanpack/post-featured-image', + ]); + expect(split.footer).toEqual([]); + }); + + it('hands out a fresh block tree per call', () => { + const first = selectComposedTemplate(emptyTemplate); + const second = selectComposedTemplate(emptyTemplate); + + expect(first?.blocks[0]).not.toBe(second?.blocks[0]); + }); + + it.each([ + ['idle', { status: 'idle' } as AppliedTemplateState], + ['loading', { status: 'loading' } as AppliedTemplateState], + ])('composes nothing while %s', (_label, state) => { + expect(selectComposedTemplate(state)).toBeNull(); + }); +}); + +describe('composedFallbackNotice', () => { + it('names the unavailable template for an unresolvable slug', () => { + expect(composedFallbackNotice(unknownSlug)).toEqual({ + tone: 'warning', + message: + 'The template “landing-xl” is unavailable — previewing on the default template.', + }); + }); + + it('uses the no-template copy for an empty template field', () => { + expect(composedFallbackNotice(emptyTemplate)).toEqual({ + tone: 'warning', + message: + 'No template is set for this content — previewing on the default template.', + }); + }); + + it('falls back to the no-template copy when the miss carries no slug', () => { + const notice = composedFallbackNotice({ + status: 'missing', + missing: { status: 'missing', reason: 'unknown-slug' }, + }); + + expect(notice?.message).toBe( + 'No template is set for this content — previewing on the default template.' + ); + }); + + it('reads a network error as an error, not a routine miss', () => { + expect(composedFallbackNotice(networkError)).toEqual({ + tone: 'error', + message: + 'The template could not be loaded — previewing on the default template.', + }); + }); + + it('says nothing when a real template resolved', () => { + expect(composedFallbackNotice(resolved)).toBeNull(); + expect(composedFallbackNotice({ status: 'loading' })).toBeNull(); + expect(composedFallbackNotice({ status: 'idle' })).toBeNull(); + }); +}); diff --git a/resources/js/visual-editor/editor/composed-view/__tests__/use-fallback-toast.test.tsx b/resources/js/visual-editor/editor/composed-view/__tests__/use-fallback-toast.test.tsx new file mode 100644 index 000000000..b1f9e3426 --- /dev/null +++ b/resources/js/visual-editor/editor/composed-view/__tests__/use-fallback-toast.test.tsx @@ -0,0 +1,219 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { ToastProvider } from '@artisanpack-ui/react/feedback'; +import { describe, expect, it } from 'vitest'; + +import { ApiError } from '../../api-client'; +import { useComposedFallbackToast } from '../use-fallback-toast'; +import type { AppliedTemplateState } from '../use-applied-template'; + +interface HarnessProps { + viewMode: 'content' | 'with-template'; + state: AppliedTemplateState; +} + +function Harness(props: HarnessProps): JSX.Element { + useComposedFallbackToast(props); + + return
; +} + +function renderHarness(props: HarnessProps) { + const result = render( + + + + ); + + return { + ...result, + rerenderHarness: (next: HarnessProps): void => { + result.rerender( + + + + ); + }, + }; +} + +const unknownSlug: AppliedTemplateState = { + status: 'missing', + missing: { status: 'missing', reason: 'unknown-slug', slug: 'landing-xl' }, +}; + +const emptyTemplate: AppliedTemplateState = { + status: 'missing', + missing: { status: 'missing', reason: 'empty' }, +}; + +const networkError: AppliedTemplateState = { + status: 'error', + error: new ApiError('boom', 500, null), +}; + +const unavailableCopy = + 'The template “landing-xl” is unavailable — previewing on the default template.'; + +describe('useComposedFallbackToast', () => { + it('names the unavailable template when the slug does not resolve', async () => { + const { rerenderHarness } = renderHarness({ + viewMode: 'content', + state: { status: 'idle' }, + }); + + rerenderHarness({ viewMode: 'with-template', state: unknownSlug }); + + await waitFor(() => { + expect(screen.getByText(unavailableCopy)).toBeInTheDocument(); + }); + }); + + it('uses the no-template copy when the content has no template field', async () => { + const { rerenderHarness } = renderHarness({ + viewMode: 'content', + state: { status: 'idle' }, + }); + + rerenderHarness({ viewMode: 'with-template', state: emptyTemplate }); + + await waitFor(() => { + expect( + screen.getByText( + 'No template is set for this content — previewing on the default template.' + ) + ).toBeInTheDocument(); + }); + }); + + it('announces a network error separately from a routine miss', async () => { + const { rerenderHarness } = renderHarness({ + viewMode: 'content', + state: { status: 'idle' }, + }); + + rerenderHarness({ viewMode: 'with-template', state: networkError }); + + await waitFor(() => { + expect( + screen.getByText( + 'The template could not be loaded — previewing on the default template.' + ) + ).toBeInTheDocument(); + }); + }); + + it('fires exactly once per toggle-on event', async () => { + const { rerenderHarness } = renderHarness({ + viewMode: 'content', + state: { status: 'idle' }, + }); + + rerenderHarness({ viewMode: 'with-template', state: { status: 'loading' } }); + rerenderHarness({ viewMode: 'with-template', state: unknownSlug }); + + await waitFor(() => { + expect(screen.getByText(unavailableCopy)).toBeInTheDocument(); + }); + + // Re-renders while still toggled on must not stack a second toast. + rerenderHarness({ viewMode: 'with-template', state: unknownSlug }); + rerenderHarness({ viewMode: 'with-template', state: unknownSlug }); + + await waitFor(() => { + expect(screen.getAllByText(unavailableCopy)).toHaveLength(1); + }); + }); + + it('announces a different failure without leaving composed mode', async () => { + const { rerenderHarness } = renderHarness({ + viewMode: 'with-template', + state: unknownSlug, + }); + + await waitFor(() => { + expect(screen.getByText(unavailableCopy)).toBeInTheDocument(); + }); + + // Author picks a second, also-broken template from the document + // panel: a refetch, then a different miss. Silence here would read + // as the template change having done nothing at all. + rerenderHarness({ + viewMode: 'with-template', + state: { status: 'loading' }, + }); + rerenderHarness({ + viewMode: 'with-template', + state: { + status: 'missing', + missing: { + status: 'missing', + reason: 'unknown-slug', + slug: 'archive-wide', + }, + }, + }); + + await waitFor(() => { + expect( + screen.getByText( + 'The template “archive-wide” is unavailable — previewing on the default template.' + ) + ).toBeInTheDocument(); + }); + }); + + it('announces again after the toggle is flipped off and back on', async () => { + const { rerenderHarness } = renderHarness({ + viewMode: 'with-template', + state: unknownSlug, + }); + + await waitFor(() => { + expect(screen.getByText(unavailableCopy)).toBeInTheDocument(); + }); + + rerenderHarness({ viewMode: 'content', state: unknownSlug }); + rerenderHarness({ viewMode: 'with-template', state: unknownSlug }); + + // The first toast may still be on screen (auto-dismiss is on a + // timer), so the second toggle-on shows as a second copy rather + // than a replacement. + await waitFor(() => { + expect( + screen.getAllByText(unavailableCopy).length + ).toBeGreaterThan(1); + }); + }); + + it('stays silent when a real template resolves', async () => { + const { rerenderHarness } = renderHarness({ + viewMode: 'content', + state: { status: 'idle' }, + }); + + rerenderHarness({ + viewMode: 'with-template', + state: { + status: 'ok', + template: { + status: 'ok', + slug: 'single-post', + name: 'Single Post', + source: 'db', + blocks: [], + template_parts: {}, + }, + }, + }); + + await waitFor(() => { + expect( + screen.getByTestId('ap-composed-fallback-harness') + ).toBeInTheDocument(); + }); + + expect(screen.queryByText(/previewing on the default template/)).toBe( + null + ); + }); +}); diff --git a/resources/js/visual-editor/editor/composed-view/default-template.ts b/resources/js/visual-editor/editor/composed-view/default-template.ts new file mode 100644 index 000000000..6bc42a60d --- /dev/null +++ b/resources/js/visual-editor/editor/composed-view/default-template.ts @@ -0,0 +1,63 @@ +/** + * The composed view's built-in default template (#624). + * + * When the applied-template endpoint cannot resolve a template for the + * current content — no `template` field, an unknown slug, or a network + * failure — the composed view still has to render *something*, otherwise + * flipping the toggle looks like it silently did nothing. It composes + * against this minimal block tree instead: title, featured image, content + * slot, in that order. + * + * The tree is deliberately a client-side constant rather than a persisted + * template. Nothing about it is editable or linkable — it exists only so + * the canvas has a shape to fall back to — so writing it to the database + * would put a phantom template in the site editor's template list. + * + * Emitted in the *server* block shape (no `clientId`, no `isValid`), the + * same shape the endpoint returns, so it flows through the existing + * `splitTemplateAroundContentSlot` → `hydrateBlocks` pipeline unchanged. + * + * @since 1.6.0 + */ + +import { __ } from '@wordpress/i18n'; +import type { BlockInstance } from '@wordpress/blocks'; + +import { TEXT_DOMAIN } from '../../vendor/i18n'; + +/** + * Display name for the fallback template. A function rather than a + * module-level constant so the string is translated at call time, after + * the locale data has loaded. + */ +export function defaultTemplateName(): string { + return __('Default template', TEXT_DOMAIN); +} + +/** + * Build the fallback block tree. + * + * Returns a fresh array on every call: the caller hydrates it into a + * block-editor store, and handing out a shared instance would let one + * store's hydration leak `clientId`s into another's. + * + * Forked block names are used (not `core/*`) to match what the + * applied-template endpoint rewrites real templates to, so the split + * finds `artisanpack/post-content` as the content slot exactly as it + * would in a resolved template. + */ +export function createDefaultTemplateBlocks(): BlockInstance[] { + return [ + bareBlock('artisanpack/post-title'), + bareBlock('artisanpack/post-featured-image'), + bareBlock('artisanpack/post-content'), + ]; +} + +function bareBlock(name: string): BlockInstance { + return { + name, + attributes: {}, + innerBlocks: [], + } as unknown as BlockInstance; +} diff --git a/resources/js/visual-editor/editor/composed-view/fallback.ts b/resources/js/visual-editor/editor/composed-view/fallback.ts new file mode 100644 index 000000000..ef1f49d6c --- /dev/null +++ b/resources/js/visual-editor/editor/composed-view/fallback.ts @@ -0,0 +1,135 @@ +/** + * Fallback routing for the composed view (#624). + * + * Turns an `AppliedTemplateState` into the template the canvas should + * actually compose against, plus the copy that explains the choice. Three + * states route to the built-in default template: + * + * - `missing` / `empty` — the content has no template selected. + * - `missing` / `unknown-slug` — the selected slug resolves to nothing. + * - `error` — the request failed outright. + * + * The first two are ordinary author states and read as a warning; the + * third is a fault and reads as an error, but all three land on the same + * canvas so the author is never left staring at a toggle that appears to + * do nothing. + * + * Kept as a pure function (rather than folded into `editor-app.tsx`) so + * the routing is testable without mounting the whole editor. + * + * @since 1.6.0 + */ + +import { __, sprintf } from '@wordpress/i18n'; +import type { BlockInstance } from '@wordpress/blocks'; + +import { TEXT_DOMAIN } from '../../vendor/i18n'; + +import { createDefaultTemplateBlocks, defaultTemplateName } from './default-template'; +import type { AppliedTemplateState } from './use-applied-template'; + +export type ComposedFallbackReason = 'empty' | 'unknown-slug' | 'error'; + +export interface ComposedTemplateSelection { + /** Blocks to split + hydrate into the canvas chrome. */ + blocks: readonly BlockInstance[]; + /** Template name, for the notice and the #623 ribbon. */ + name: string; + /** + * Template slug, or `null` on the fallback. `null` is the signal that + * there is no template to deep-link to, so the ribbon's + * **Edit template ↗** CTA has to be hidden. + */ + slug: string | null; + /** `null` when a real template resolved. */ + fallbackReason: ComposedFallbackReason | null; +} + +export interface ComposedFallbackNotice { + tone: 'warning' | 'error'; + message: string; +} + +/** + * Pick the template the composed canvas renders against. + * + * Returns `null` while the hook is `idle` or `loading` — there is nothing + * to compose yet, and falling back mid-flight would flash the default + * template before the real one arrives. + */ +export function selectComposedTemplate( + state: AppliedTemplateState +): ComposedTemplateSelection | null { + if (state.status === 'idle' || state.status === 'loading') { + return null; + } + + if (state.status === 'ok') { + return { + blocks: state.template.blocks, + name: state.template.name, + slug: state.template.slug, + fallbackReason: null, + }; + } + + return { + blocks: createDefaultTemplateBlocks(), + name: defaultTemplateName(), + slug: null, + fallbackReason: + state.status === 'missing' ? state.missing.reason : 'error', + }; +} + +/** + * Copy explaining a fallback. Returns `null` when no fallback happened, + * so the caller can use a non-null result as "there is something to say". + */ +export function composedFallbackNotice( + state: AppliedTemplateState +): ComposedFallbackNotice | null { + if (state.status === 'error') { + return { + tone: 'error', + message: __( + 'The template could not be loaded — previewing on the default template.', + TEXT_DOMAIN + ), + }; + } + + if (state.status !== 'missing') { + return null; + } + + if (state.missing.reason === 'unknown-slug') { + const slug = state.missing.slug ?? ''; + + // An `unknown-slug` miss with no slug in the payload is + // contradictory, but a hand-rolled or proxied response can produce + // it. Naming an empty template in the copy reads as a bug, so fall + // through to the no-template wording. + if (slug !== '') { + return { + tone: 'warning', + message: sprintf( + /* translators: %s: requested template slug. */ + __( + 'The template “%s” is unavailable — previewing on the default template.', + TEXT_DOMAIN + ), + slug + ), + }; + } + } + + return { + tone: 'warning', + message: __( + 'No template is set for this content — previewing on the default template.', + TEXT_DOMAIN + ), + }; +} diff --git a/resources/js/visual-editor/editor/composed-view/index.ts b/resources/js/visual-editor/editor/composed-view/index.ts index d5bb22ae3..4bbc6e71b 100644 --- a/resources/js/visual-editor/editor/composed-view/index.ts +++ b/resources/js/visual-editor/editor/composed-view/index.ts @@ -12,6 +12,17 @@ export { type AppliedTemplatePart, type AppliedTemplateResult, } from './api'; +export { + createDefaultTemplateBlocks, + defaultTemplateName, +} from './default-template'; +export { + composedFallbackNotice, + selectComposedTemplate, + type ComposedFallbackNotice, + type ComposedFallbackReason, + type ComposedTemplateSelection, +} from './fallback'; export { hydrateAppliedTemplate, hydrateBlocks } from './hydrate'; export { splitTemplateAroundContentSlot, @@ -23,3 +34,7 @@ export { type AppliedTemplateState, type UseAppliedTemplateOptions, } from './use-applied-template'; +export { + useComposedFallbackToast, + type UseComposedFallbackToastOptions, +} from './use-fallback-toast'; diff --git a/resources/js/visual-editor/editor/composed-view/use-fallback-toast.ts b/resources/js/visual-editor/editor/composed-view/use-fallback-toast.ts new file mode 100644 index 000000000..1e230921d --- /dev/null +++ b/resources/js/visual-editor/editor/composed-view/use-fallback-toast.ts @@ -0,0 +1,73 @@ +/** + * Fires the composed-view fallback toast (#624). + * + * Exactly one toast per toggle-on event, no matter how the fallback was + * reached or how many times the component re-renders while it is showing. + * The latch is cleared when the view mode returns to `content`, so the + * next flip of the toggle announces the fallback again — an author who + * toggles back on has lost the earlier toast and deserves the reason a + * second time. + * + * The latch holds the *message*, not a boolean, so that picking a second + * broken template from the document panel without leaving composed mode + * still announces the new failure. A plain boolean would swallow it: the + * author would change the template and see nothing happen at all. + * + * Mirrors `useSaveNotifications`: a hook, so it can be exercised through a + * tiny harness rather than a full editor mount. + * + * @since 1.6.0 + */ + +import { useEffect, useRef } from 'react'; +import { useToast } from '@artisanpack-ui/react/feedback'; + +import { composedFallbackNotice } from './fallback'; +import type { AppliedTemplateState } from './use-applied-template'; + +export interface UseComposedFallbackToastOptions { + /** The editor's ephemeral composed-view mode. */ + viewMode: 'content' | 'with-template'; + state: AppliedTemplateState; +} + +export function useComposedFallbackToast( + options: UseComposedFallbackToastOptions +): void { + const { viewMode, state } = options; + const toast = useToast(); + const notifiedRef = useRef(null); + + useEffect(() => { + if (viewMode !== 'with-template') { + notifiedRef.current = null; + + return; + } + + const notice = composedFallbackNotice(state); + + if (notice === null) { + return; + } + + // A re-fetch of the same broken template produces the same copy and + // must stay silent; a *different* failure produces different copy + // and gets announced. + const key = `${notice.tone}:${notice.message}`; + + if (notifiedRef.current === key) { + return; + } + + notifiedRef.current = key; + + if (notice.tone === 'error') { + toast.error(notice.message); + + return; + } + + toast.warning(notice.message); + }, [state, toast, viewMode]); +} diff --git a/resources/js/visual-editor/editor/editor-app.tsx b/resources/js/visual-editor/editor/editor-app.tsx index 25f3b5077..95fe98d01 100644 --- a/resources/js/visual-editor/editor/editor-app.tsx +++ b/resources/js/visual-editor/editor/editor-app.tsx @@ -94,9 +94,12 @@ import { KeyboardShortcutsModal } from './keyboard-shortcuts-modal'; import { useSaveNotifications } from './save-notifications'; import { TopBar, type ViewMode } from './top-bar'; import { + composedFallbackNotice, hydrateBlocks, + selectComposedTemplate, splitTemplateAroundContentSlot, useAppliedTemplate, + useComposedFallbackToast, } from './composed-view'; import { usePersistence } from './use-persistence'; @@ -872,22 +875,36 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { enabled: viewMode === 'with-template', }); + // #624 — announce a fallback once per toggle-on event. Lives beside the + // selection below rather than inside it because the selection is a + // `useMemo` and firing a toast from one would be a render side effect. + useComposedFallbackToast({ viewMode, state: appliedTemplateState }); + const composedChrome: { header: readonly BlockInstance[]; footer: readonly BlockInstance[]; templateName: string; + /** `null` on the fallback — nothing for the ribbon to link to. */ + templateSlug: string | null; + isFallback: boolean; slotFound: boolean; } | null = useMemo(() => { - if ( - viewMode !== 'with-template' || - appliedTemplateState.status !== 'ok' - ) { + if (viewMode !== 'with-template') { + return null; + } + + // #624 — a missing / unknown / failed template routes to the + // built-in default tree instead of returning null, so the canvas + // always has a shape once the toggle is on. + const selected = selectComposedTemplate(appliedTemplateState); + + if (selected === null) { return null; } const split = splitTemplateAroundContentSlot( - appliedTemplateState.template.blocks, - appliedTemplateState.template.name + selected.blocks, + selected.name ); // Hydrate each side separately. The split clones a wrapper onto @@ -898,6 +915,8 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { header: hydrateBlocks(split.header), footer: hydrateBlocks(split.footer), templateName: split.templateName, + templateSlug: selected.slug, + isFallback: selected.fallbackReason !== null, slotFound: split.slotFound, }; }, [appliedTemplateState, viewMode]); @@ -908,11 +927,10 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { ? __('Loading applied template…', TEXT_DOMAIN) : null; - // Composed mode with nothing to compose. Without a banner the canvas - // renders bare content while the toggle still reads "With template", - // which looks like the toggle silently did nothing. Note this is the - // review-fix treatment only — the designed fallback (compose against a - // minimal default template + toast) is #624. + // Standing explanation of what the composed canvas is showing. The + // #624 toast announces a fallback the moment it happens; this notice + // is what an author still sees a minute later, once the toast has + // auto-dismissed and the canvas is all that's left. const composedNotice: { tone: 'info' | 'warning' | 'error'; message: string; @@ -922,37 +940,13 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { return null; } - if (appliedTemplateState.status === 'missing') { - return { - tone: 'warning', - message: - appliedTemplateState.missing.reason === 'unknown-slug' - ? sprintf( - /* translators: %s: template slug. */ - __( - 'The template “%s” could not be found, so no template chrome is shown.', - TEXT_DOMAIN - ), - appliedTemplateState.missing.slug ?? '' - ) - : __( - 'This content has no template selected, so no template chrome is shown.', - TEXT_DOMAIN - ), - }; - } + // #624 — composing against the built-in default template. Say + // so plainly: the canvas otherwise looks like a real (and very + // plain) template resolved successfully. + const fallback = composedFallbackNotice(appliedTemplateState); - if (appliedTemplateState.status === 'error') { - return { - tone: 'error', - message: - appliedTemplateState.error.message !== '' - ? appliedTemplateState.error.message - : __( - 'The applied template could not be loaded.', - TEXT_DOMAIN - ), - }; + if (fallback !== null) { + return fallback; } // Resolved, but the template never says where content goes, so