Layout widgets
A layout widget renders no content of its own. It declares named slots, the customer
binds other widgets into them, and the platform hands your component the already-rendered
children as renderedSlots. A layout can also replace the platform’s own page layout on a
server-rendered page.
Layouts exist only in the suite (multi-widget) manifest shape. resolvePluginWidget
hardcodes category: "content" for every v1 single-widget manifest
(packages/homerunner-widget-core/src/manifest.ts), so a single-widget plugin can never
ship a layout — and the shape is frozen at first publish. See
The two manifest shapes.
Everything on this page needs widget-core 0.11.0+, except pages (0.12.0+) and
slots[].prefill (0.12.1+).
Enforcement levels used below — publish ERROR, publish warning, silent runtime truncation, advisory (unvalidated) — are defined once in Limits and error index.
Requirements at a glance
| Requirement | Shape | Rule | On violation |
|---|---|---|---|
category: "layout" on the widget summary | Suite | Exactly the string layout. Any other value normalizes to content at resolve time | Unknown string → publish ERROR manifest widget "{slug}" has an unknown "category" (content | layout). |
ssr: { "url": … } on the widget summary | Suite (layout) | An SSR bundle is mandatory. A layout composes its children server-side | "ssr": false passes the publish audit (advisory), then fails at render — see SSR |
slots[] on the widget summary | Suite (layout) | Declares the named regions | See slots[] |
A slots object in your zod config | Suite (layout) | The dashboard has nowhere to store bindings without it | Silent: the slot editor renders, edits are dropped on save. advisory |
default export in the SSR bundle | Suite (layout) | The only required SSR export | Runtime failure node, page degraded |
registerPluginWidget(…, { category: "layout" }) | Suite (layout) | Records the category for the client path | Silent: the shell renders with no renderedSlots, and on a server-rendered page the client render replaces the server’s slot children. advisory |
A layout is not allowed to nest inside another layout, and its shell can never be interactive on a server-rendered page. Both are covered below.
Manifest: category
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
category | "content" | "layout" | Optional (Suite) — defaults to content | Exactly one of the two literals | publish ERROR: manifest widget "{slug}" has an unknown "category" (content | layout). |
At resolve time the rule is summary.category === "layout" ? "layout" : "content" —
anything that is not the literal layout silently becomes a content widget, taking
slots and pages with it (both resolve to undefined on content widgets).
Rendering a layout through the content path is refused:
// packages/homerunner-renderer/lib/render-utils.tsx — console.warn, then a failure node
[plugin] "plugin:my-suite:frame" is a layout widget — it must render as a layout entry (with slots), not content. Skipping.
// failure-node reason:
plugin layout widget rendered as content — needs a layout entry with slots
Manifest: slots[]
slots is layouts-only. Declare one entry per region your component renders.
// pdp-suite 1.3.3 (the reference suite) — manifest.json, the pdp-frame summary
{
"slug": "pdp-frame",
"name": "PDP Frame",
"category": "layout",
"slots": [
{ "name": "hero", "prefill": ["property-title", "gallery"] },
{ "name": "main", "prefill": ["property-meta", "bednbath", "description", "amenities", "calendar"] },
{ "name": "sidebar", "prefill": ["booking"] },
{ "name": "bottom", "prefill": ["reviews"] }
],
"ssr": { "url": "dist/pdp-frame/pdp-frame-ssr.umd.js" },
"assets": {
"js": "dist/pdp-frame/pdp-frame.iife.js",
"css": "dist/pdp-frame/pdp-frame.css",
"fonts": ["https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap"]
},
"manifest": "widgets/pdp-frame.manifest.json",
"pages": ["pdp", "misc"],
"icon": "media/pdp-frame.svg",
"readme": "media/widgets/pdp-frame.md"
}
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
slots | Array<{name, accepts?, prefill?}> | Optional (Suite, layouts only) | Must be an array; only on category: "layout" | publish ERROR: manifest widget "{slug}" declares "slots" but is not a layout. / manifest widget "{slug}" needs a "slots" list. |
slots[].name | string | Required when the entry exists | Non-empty, ≤ 64 characters, unique within the widget | publish ERROR: manifest widget "{slug}" has an invalid or duplicate slot name. |
slots[].accepts | string[] | Optional | Filters the dashboard slot picker only. Omitted = ["content"] | Never validated — advisory (unvalidated) |
slots[].prefill | string[] | Optional (0.12.1+) | System widget keywords, ≤ 8 per slot | publish ERROR — see prefill |
The name check breaks out of the slot loop on the first bad entry, so a manifest with
two problems reports one error per audit run — fix and re-run.
A slot the manifest declares is not automatically a slot that renders. What renders is
derived from the customer’s stored bindings; see
settings.slots and
Page assignment.
slots[].accepts
accepts is read by exactly one consumer: the playground’s slot editor. A candidate
widget is offered when accepts contains its category (content / layout) or its
exact keyword (gallery, plugin:acme:hero). The layout’s own keyword is always
excluded, so a layout can never bind another instance of itself.
// src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/PlayGround.tsx
const accepts = slot.accepts?.length ? slot.accepts : ["content"];
const filterFn = (keyword: string) =>
typeof keyword !== "string" ||
(keyword !== widget.keyword &&
(accepts.includes(categoryOf(keyword)) || accepts.includes(keyword)));
Not supported yet.
accepts: ["layout"]can never admit anything. The same picker is always rendered withexcludeLayouts, which drops every layout-category row before theacceptspredicate runs — and even a hand-crafted binding is skipped by both render paths, because layouts cannot nest.
accepts is not validated at publish and is not read by the renderer. Treat it as a hint
to the customer, never as a guarantee about what your slot will contain.
slots[].prefill
(widget-core 0.12.1+) prefill lists the system widget keywords a slot expects.
When a customer creates the layout, the dashboard binds the feed’s existing widget for
each keyword so the layout opens populated instead of empty.
| Rule | Enforcement |
|---|---|
Each entry matches /^[a-z][a-z0-9-]{0,63}$/ | publish ERROR: manifest widget "{slug}" slot "{name}" has invalid prefill keyword(s): {list} (system widget keywords only, e.g. gallery). |
| At most 8 entries per slot | publish ERROR: manifest widget "{slug}" slot "{name}" needs a "prefill" list of at most 8 keywords. |
plugin: references are rejected by the same regex | publish ERROR (same message). Compose your own widgets with presets instead |
| At resolve time: malformed entries dropped, duplicates removed, list sliced to 8 | silent runtime truncation (normalizeLayoutSlots) |
What the dashboard does at creation time, in order:
- Union every slot’s
prefillkeywords, in declared order. - Create the ones the feed has no active widget for, with the platform’s own defaults. A
keyword this platform cannot create is reported in a toast as
{keyword}: not a widget this platform can createand skipped. - Bind the feed’s oldest active widget per keyword (
status === falsewidgets are skipped), in declared order, deduped within a slot. - Merge the result into the new widget’s
settings.slots. Slots that resolved to no ids are omitted entirely — the key never appears.
Prefill is creation-time only. It is never re-applied to an existing layout, and the renderer never reads it.
Not supported yet.
prefillkeywords are shape-validated only. Nothing checks them against the real system-widget keyword list, so a typo publishes cleanly and silently prefills nothing.
The mandatory slots config field
The dashboard’s slot editor is injected into your own config form. For each manifest
slot it writes a SlotWidgetArrayContainer of WidgetSelect rows under the form’s
slots object field. If your zod config does not declare that field, there is nowhere to
store bindings and the customer’s edits are dropped on save.
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/config.ts
export const configZod = widgetSchema.extend({
showSlotLabels: z.boolean().default(false).describe("Show slot region labels"),
stickyRail: z.boolean().default(true).describe("Keep the sidebar rail pinned"),
// Keys MUST match the manifest summary's slot names, exactly.
slots: z
.object({
hero: z.array(z.string()).default([]),
main: z.array(z.string()).default([]),
sidebar: z.array(z.string()).default([]),
bottom: z.array(z.string()).default([]),
})
.default({})
.describe("Widgets bound into each region"),
});
export const configSchema = zodToManifestSchema(configZod);
Rules:
- The object keys must equal the manifest slot names, character for character. A key with no matching manifest slot gets no editor; a manifest slot with no key has nowhere to save.
- Use
z.array(z.string()).default([])per slot and.default({})on the object. Both defaults matter: the field must surviveconfigZod.parse({})at creation time. - Do not add a
uiSchemaentry forslots. The dashboard replaces the wholeslotskey with its own panel: label Slot Configuration, description “Assign the widgets that render inside this layout’s slots”,order: -50(it sorts to the top). Each row is titled{slot} slotwith the descriptionaccepts: {list}. See Config schema and UI schema for why a plugin entry replaces a top-level key wholesale.
settings.slots: the stored bindings
Bindings are stored on the layout widget instance, not in the manifest:
// Read by packages/homerunner-widget-core/src/runtime/layout-csr-renderer.tsx (CSR)
// and packages/homerunner-renderer/lib/render-utils.tsx (SSR), out of widget.settings
"slots": {
"hero": ["cuid-of-a-widget", "cuid-of-another"],
"sidebar": ["cuid-of-a-third"]
}
Record<slotName, widgetId[]>. Array order is render order.
- CSR reads it structurally out of the widget’s settings and resolves each id to its keyword.
- SSR converts the ids to slot bindings and drops ids whose widget no longer exists on the feed (nothing prunes a slot when a widget is deleted).
- On an assigned page, the slot names the PDP renders are the keys present in the
stored
settings.slots— a slot the customer never bound simply does not appear. See Page assignment.
Settings arrive at your component raw — never zod-parsed for plugins, on either path. Parse them yourself; see Settings and options.
The component contract
Import the real type rather than hand-rolling it:
// packages/homerunner-widget-core/src/contracts.ts
export interface LayoutWidgetProps<
T extends WidgetSchemaType = WidgetSchemaType,
D extends Record<string, any> | undefined = undefined,
> extends WidgetProps<T, D> {
/** Pre-rendered React nodes for each slot. Keys are slot names. */
renderedSlots: Record<string, ReactNode>;
}
// import type { LayoutWidgetProps } from "@homerunner-next/widget-core/contracts";
What each path actually passes:
| Prop | SSR (server-rendered page) | CSR (single-snippet embed) |
|---|---|---|
options | RAW merged settings | RAW merged settings |
renderedSlots | Record<slotName, ReactNode> | Record<slotName, ReactNode> |
feedId | yes | yes |
widgetId | yes | yes |
resolvedTheme | cookie-derived, effectively always "light" in production | resolved on the client |
data | result of getInitialData, else undefined | always undefined |
widgetType | absent | present |
target | absent | absent |
LayoutWidgetProps declares widgetType as required, but the SSR path does not supply
it. Reading it during render is a hydration-mismatch trap — treat both widgetType and
target as optional in a layout.
renderedSlots differs between the paths for an empty slot:
- SSR: the key is present only when at least one child actually rendered. An unbound
slot, or one whose every binding was dropped, is
undefined. - CSR: every key of the stored
settings.slotsis present; an empty slot is[]— truthy, and renders nothing.
Write renderedSlots?.[name] ?? null and never assume a key exists.
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/widget.tsx
import React, { type ReactNode } from "react";
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { configZod } from "./config";
import "./pdp-frame.css";
export default function PdpFrame(props: {
options?: Record<string, unknown>;
renderedSlots?: Record<string, ReactNode>;
}) {
const cfg = parseWidgetConfig(configZod, props.options ?? {});
const slots = props.renderedSlots ?? {};
const region = (name: string, node: ReactNode) => (
<section className={`pdpf-region pdpf-${name}`} data-testid={`pdpf-${name}`}>
{cfg.showSlotLabels ? <span className="pdpf-label">{name}</span> : null}
{node ?? (cfg.showSlotLabels ? <div className="pdpf-empty">empty “{name}” slot</div> : null)}
</section>
);
return (
<div className={`pdpf${cfg.stickyRail ? " pdpf-sticky-rail" : ""}`} data-testid="pdp-frame">
{region("hero", slots.hero)}
{region("main", slots.main)}
{region("sidebar", slots.sidebar)}
{region("bottom", slots.bottom)}
</div>
);
}
The SSR entry is one line — a layout needs no getInitialData, dehydrateState or
getStaticAssets:
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/ssr-entry.ts
// SSR entry — the renderer hands `renderedSlots` to the default export.
export { default } from "./widget";
Those three hooks are still supported on a layout and run before the shell renders, inside the same try/catch. A throw in any of them replaces the whole layout with a failure node. See Component and SSR module.
Registration and mount
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/index.tsx
import { mount } from "@homerunner-next/widget-core/runtime";
import { registerPluginWidget } from "@homerunner-next/widget-core/globals";
import PdpFrame from "./widget";
import "./pdp-frame.css";
registerPluginWidget("pdp-suite", "pdp-frame", PdpFrame, { category: "layout" });
function doMount() {
document
.querySelectorAll<HTMLElement>("[data-hr-widget-container]")
.forEach((container) => {
if (container.querySelector('[data-hr-widget="plugin:pdp-suite:pdp-frame"]')) {
mount(container, {
widget: PdpFrame,
widgetType: "plugin:pdp-suite:pdp-frame",
category: "layout",
});
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", doMount);
} else {
doMount();
}
registerPluginWidget(plugin, widget, Component, { category: "layout" }) writes
// packages/homerunner-widget-core/src/globals.ts
window.HRPlugins["{pluginSlug}:{widgetSlug}"] = { component, category: "layout" };
category is omitted from the registry entry when you do not pass meta.category.
mount derives an effective category for plugin keywords as
config.category ?? getRegisteredWidget(widgetType)?.category. The registry write sits in
the same IIFE and therefore always precedes the mount, so a bootstrap that forgets
category on the mount() call still routes correctly. Pass it anyway — it is the
documented contract and it makes the file readable. See
Runtime, mount and the DOM contract for the full MountConfig.
Omitting it in both places is the one genuinely destructive mistake here. Without an
effective category, mount treats the shell as a content widget: the SSR no-op guard never
fires, and because a layout shell carries no data-hr-ssr-id and no props-registry entry
there is nothing to hydrate — so it takes the client-render path and replaces the server’s
shell, slot children and all, with an empty client render.
How a layout renders
SSR: the server composes your children
On a server-rendered page the renderer:
- Collects every binding across every slot. A binding whose widget is missing from the
feed is logged
[layout] Slot "{name}" references missing widget {id}and skipped. - Renders all slot children concurrently. Each child is a full island with its own SSR id, its own Declarative Shadow DOM template, its own props-registry entry and its own assets.
- Reassembles each slot in binding order into
renderedSlots[slotName] = <>{nodes}</>. - Loads your layout’s SSR bundle last, runs the optional data hooks, renders the shell, and unshifts the layout’s CSS and JS in front of every child’s assets.
The shell is emitted as static light DOM:
// packages/homerunner-renderer/lib/render-utils.tsx
<div
data-hr-widget={widgetConfig.type}
data-hr-widget-category="layout"
id={`${widgetConfig.id}:${widgetConfig.feed.id}`}
{...layoutHostThemeAttrs(/* data-hr-scheme="auto" or data-hr-theme="light|dark" */)}
>
<LayoutComponent {...props} /> {/* no <template shadowrootmode>, no shadow root */}
</div>
Three consequences that look like bugs and are not:
- No shadow root, no DSD template. Your layout stylesheet is loaded document-level, at page scope. See Layout CSS.
- No
data-hr-ssr-idand no props-registry entry. Only slot children get those. - The client mount is a deliberate no-op.
mountreturns early when the effective category islayoutand the root already has server-rendered children. Running the CSR path there would discard the server’s slots and re-fetch everything client-side.
Not supported yet. Because of the three points above, a plugin layout shell can never be interactive on a server-rendered page: no React tree is ever attached to it. Its slot children each hydrate themselves normally. Put interactivity in a content widget and bind it into a slot.
CSR: the layout composes its own children
In a single-snippet embed the shell does get a shadow root, and LayoutCsrRenderer
rebuilds the slot tree at runtime:
- Fetch the layout widget + feed, read
settings.slots. - Per child id: fetch its widget row to learn its keyword, skip nested layouts, load the
child’s IIFE so it self-registers, then wait for registration (hard 10 s timeout, per
child:
[widget-core] widget "{keyword}" did not register within 10000ms→ that slot child stays empty, the rest render). - Render your component with
renderedSlots= arrays of childless host divs ([data-hr-widget-container] > [data-hr-widget={keyword}][data-hr-options]). - After commit, imperatively
mounteach child into its host.
Child script URLs come from config.resolveChildJsUrl or, by default,
window.HRWidgetRuntime.resolveChildJsUrl — supplied by the host runtime bundle. Plugin
builds carry no URL rules and you never script-tag children yourself.
The layout pushes two things onto every child: the reserved __parentColorScheme key
(which only affects children whose own colorScheme is global) and, when the layout
resolves one, filter.property. Reading the pushed property is covered in
Composing a page.
Failure semantics
| What throws | Result |
|---|---|
A data hook (getInitialData / dehydrateState / getStaticAssets) on SSR | The layout is replaced by a hidden <div data-hr-widget-error="{keyword}" hidden> breadcrumb with reason layout SSR data prep failed: {message}; the page still responds — an assigned page layout failing this way leaves the page empty — and is marked degraded (60 s TTL). Already-rendered slot children are discarded |
| The layout component during SSR render | The whole page render 500s. The shell element is returned as a React node and rendered later in the page-level renderToString, which has no try/catch |
| The layout component on CSR | Caught by WidgetRenderBoundary: data-hr-error="render-failed" on the host plus the shared error state |
Parse defensively and default every slot. A content widget that throws costs one widget; a layout that throws costs the customer’s whole page.
Every failure reason a layout can produce, verbatim:
// packages/homerunner-renderer/lib/render-utils.tsx
// Content path — a layout keyword rendered as a plain content entry.
keyword does not resolve against the plugin manifest
plugin layout widget rendered as content — needs a layout entry with slots
// Layout path — every throw is wrapped as `layout SSR data prep failed: {message}`.
layout SSR data prep failed: keyword does not resolve against the plugin manifest
layout SSR data prep failed: "plugin:{slug}:{widget}" is not a layout widget (category: content)
layout SSR data prep failed: plugin layout widgets require an SSR bundle (`ssr: false` is content-only)
layout SSR data prep failed: SSR bundle failed to load from {url}
// A slot child that is itself a SYSTEM layout.
layout-in-layout nesting not allowed
A slot child that is a plugin layout produces no breadcrumb at all — it is dropped with a console line and the rest of the slot renders.
Layouts cannot nest
Refused on both paths, before anything renders:
// packages/homerunner-renderer/lib/render-utils.tsx — SSR, checked against the summary's category
[layout] Plugin layout widgets are not supported in slots yet. Slot "{name}" references "{keyword}". Skipping.
// packages/homerunner-widget-core/src/runtime/layout-csr-renderer.tsx — CSR, checked against the registration
[layout-csr] Skipping nested plugin layout child "{keyword}" — layouts cannot nest.
System layouts are caught separately by their -layout keyword suffix. Nothing in the
manifest expresses “this layout may nest”.
Layout CSS
A layout stylesheet must work in two environments:
- Document level, unisolated, on every server-rendered page (the shell is light DOM and
your CSS is appended to the page
<head>). - Inside a shadow root, on a CSR embed.
The rules that follow from that:
| Rule | Why |
|---|---|
Namespace every selector with a class prefix (.pdpf, .pdpf-region) | On an SSR page your rules apply to the customer’s whole document |
Never write a :host-only rule for the shell | :host matches nothing in light DOM, so the rule is inert on every server-rendered page |
| Never paint a background on the shell | Product rule: the host page owns the page canvas. The platform’s own stylesheet excludes [data-hr-widget-category="layout"] from its background paint on both paths |
Your layout’s CSS is unshifted ahead of the slot children’s stylesheets in the page’s CSS list, so a child’s rules win on source order at equal specificity.
/* pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/pdp-frame.css */
/* Light-DOM-safe shell: class-based rules only, no :host-only styles, no
painted background (the same CSS also runs inside a shadow root on CSR
embeds). */
.pdpf {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
grid-template-areas:
"hero sidebar"
"main sidebar"
"bottom bottom";
gap: 28px 32px;
align-items: start;
}
.pdpf-hero { grid-area: hero; }
.pdpf-main { grid-area: main; }
.pdpf-sidebar { grid-area: sidebar; }
.pdpf-bottom { grid-area: bottom; }
.pdpf-sticky-rail .pdpf-sidebar { position: sticky; top: 24px; }
@media (max-width: 860px) {
.pdpf {
grid-template-columns: 1fr;
grid-template-areas: "hero" "sidebar" "main" "bottom";
}
.pdpf-sticky-rail .pdpf-sidebar { position: static; }
}
Plugin layouts do not inherit the platform’s slotSpacing / gap settings — declare your
own spacing fields. See Styling and theming.
pages and page assignment
(widget-core 0.12.0+) pages declares the server-rendered pages a layout may be
assigned to. Being assignable is an explicit declaration, never an accident.
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
pages | PluginPageKey[] | Optional (Suite, layouts only) | Non-empty; every entry one of the six keys below; only on category: "layout" | publish ERROR: manifest widget "{slug}" declares "pages" but is not a layout. · manifest widget "{slug}" needs a non-empty "pages" list. · manifest widget "{slug}" has unknown page(s) in "pages": {list} (pdp | listings | checkout | confirmation | collection | misc). |
// packages/homerunner-widget-core/src/manifest.ts
export const PLUGIN_PAGE_KEYS = ["pdp", "listings", "checkout", "confirmation", "collection", "misc"] as const;
| Key | Meaning | Assignable today |
|---|---|---|
pdp | Property detail page | Yes |
listings | Listings page | Yes |
checkout | Checkout page | Yes |
confirmation | Confirmation page | Yes |
collection | A collection route’s layout | No — reserved vocabulary |
misc | Standalone embeds only | No, by definition |
At resolve time unknown keys are dropped and an absent or empty list becomes ["misc"] —
silent, not an error. Content widgets always resolve pages: undefined.
Not supported yet.
collectionis declared vocabulary with no assignment surface. The collection-route picker only offers instances of the systemlistings-layoutkeyword, so a plugin layout declaringcollectioncannot be chosen anywhere.
What the customer does
Feed → Page URLs → Page layouts: one row per server-rendered page, each a picker of Automatic plus every eligible layout on the feed. Your layout appears there when its plugin is approved and enabled on the feed, the widget is not kill-switched, and its summary declares that page. The assignment is stored on the feed at:
// src/lib/homerunner/reverse-proxy-routes.ts + packages/homerunner-renderer/lib/page-layouts.ts
feeds.additional_info.reverseProxy.pageLayouts = { pdp?, listings?, checkout?, confirmation? }
Absent or null means Automatic (the feed’s base system layout for that page).
What the renderer does with an assignment
An assigned plugin layout renders with backfill: false — stored bindings only.
Unlike a system layout, nothing is backfilled from the platform’s default slot map, so a
slot the customer never bound simply does not appear. Bindings pointing at deleted widgets
are dropped first.
The slot names the page renders differ by page, and this is the sharpest edge in the whole contract:
| Page | Slot names rendered | Extra rule |
|---|---|---|
pdp | The keys of the stored settings.slots — your own vocabulary | Renders every binding in every slot |
listings, checkout, confirmation | Fixed to ["content"] | Only slots.content is passed to your layout, and the page 404s when it resolves to no live widget |
So a layout that declares listings, checkout or confirmation must name one of its
slots exactly content and the customer must bind at least one widget into it. Every
other slot you declare receives nothing on those three pages. Their handlers return
{"error":"Explorer widget not found for this feed"},
{"error":"Checkout widget not found for this feed"} and
{"error":"Confirmation widget not found for this feed"} respectively, with HTTP 404 —
the page does not degrade, it fails.
For the property page, mirror the system layout’s slot vocabulary — hero, main,
sidebar, bottom — so the platform’s own widgets drop straight in.
Degrading to Automatic
Anything the renderer cannot honour falls back to Automatic and never 404s the page. Each reason is logged once:
// packages/homerunner-renderer/lib/page-layouts.ts
[page-layouts] feed {feedId} {page}: {reason} — using Automatic
// reasons:
assigned layout {id} is not on the feed (deleted or paused)
assigned widget {id} ({keyword}) is not a layout for "{page}"
assigned plugin layout {id} ({keyword}) is not live (plugin suspended, widget disabled, or not enabled on the feed)
assigned plugin widget {id} ({keyword}) does not declare the "{page}" page
Not supported yet. The dashboard and the renderer disagree about
deprecated. The dashboard drops a deprecated layout from the picker and flags a stored assignment as an error (“{display name}” cannot render the property page.), but the renderer does not checkdeprecatedat all — an already-assigned deprecated layout keeps being server-rendered. Deprecating a layout does not take it off a page; ask the customer to switch back to Automatic.
Presets
(widget-core 0.11.0+) A preset is a root-level, one-click composition: the widget picker offers it as its own entry that creates the layout, its children, and the slot bindings — atomically.
// pdp-suite 1.3.3 (the reference suite) — manifest.json, root level
"presets": [
{
"name": "PDP Starter",
"layout": "pdp-frame",
"slots": {
"hero": ["stay-hero", "property-title", "gallery"],
"main": ["stay-facts", "description", "amenities", "calendar"],
"sidebar": ["booking", "booking-cta"],
"bottom": ["reviews"]
}
}
]
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
presets | Array<{name, layout, slots}> | Optional (Suite) | Array, at most 8 entries | publish ERROR: manifest "presets" must be a list of at most 8 entries. |
presets[].name | string | Required | Non-empty | publish ERROR: each manifest preset needs a "name". |
presets[].layout | string | Required | A declared widget slug of this plugin with category: "layout" | publish ERROR: preset "{name}" must reference a declared layout widget. |
presets[].slots | Record<string, string[]> | Required | An object of arrays | publish ERROR: preset "{name}" needs a "slots" object. / preset "{name}" has an invalid slot binding shape. |
presets[].slots[*][] | string | — | Either a declared non-layout widget slug of this plugin, or a system widget keyword (/^[a-z][a-z0-9-]{0,63}$/) | publish ERROR: preset "{name}" references "{child}" which is neither a declared content widget nor a system widget keyword. |
A single slot array may freely mix your own slugs with system keywords, as PDP Starter
does.
What one click creates, in order:
- System-keyword children: the feed’s oldest active widget per keyword, creating the missing ones with platform defaults first. Never duplicated per preset.
- One instance of each referenced own content slug, display-named
{typed name} — {widget name}(the name the customer entered for the preset, then the widget’s own name), with settings = schema defaults + the feed’s branding +filter.feed_id. - The layout, with
settings = { ...layoutDefaults, slots }mapping each preset slot name to the created/ensured ids. - A redirect to the new layout’s playground.
A failure part-way through best-effort deletes the children it just created, then surfaces the original error.
In the picker the entry is labelled {plugin name}: {preset name}, described “One click
creates the “{layout}” layout with its slot widgets bound.“, and carries a chip listing
the layout and its children. See Composing a page.
Not supported yet. Preset slot keys are never validated against the layout’s declared slot names. A typo (
sidbar) publishes cleanly and creates a binding your layout never renders — the widget is created, bound, and invisible. Check your keys by hand againstslots[].name.
The customer-facing surface
- The widget picker lists every non-deprecated widget of an enabled plugin, layouts
included. A layout’s description gets
(layout — hosts other widgets in its slots)appended and it takes a Layers glyph instead of the Puzzle glyph. Per-widgeticonfalls back to the pluginicon, then to the glyph. deprecated: trueremoves the widget from the picker and disables Create widget on the plugin page. Existing placements keep rendering — see Manifest: widget summary.- Slot bindings are edited in the layout’s playground under Slot Configuration.
Known gaps, collected
Not supported yet. Each of these is repeated in context above.
accepts: ["layout"]can never match: the slot picker excludes layouts first, and both render paths refuse a nested layout.- Preset slot keys are not validated against the layout’s declared slot names.
prefillkeywords are shape-validated only, never checked against the real system-widget keyword list.- A plugin layout on a server-rendered page can never be interactive — no
data-hr-ssr-id, no registry entry, and the client mount is a no-op.collectionis reserved vocabulary with no assignment surface.- The renderer keeps server-rendering an assigned layout that is
deprecated, which the dashboard reports as an error.- No local harness composes a layout’s slots.
dev:ssr -- --widget {layout}previews the shell with every slot empty, becauserenderPluginSSRbuilds the props itself and passes norenderedSlots(widget-core 0.12.2). See Previewing SSR locally.
Checklist
- Suite manifest (
widgets[]), not the v1 shape. -
"category": "layout"on the summary. -
"ssr": { "url": … }— neverfalse. -
slots[]with unique names ≤ 64 chars;prefill≤ 8 system keywords per slot. -
pagesonly if you want the layout assignable; include a slot namedcontentif you declarelistings,checkoutorconfirmation. - A
slotszod object inconfig.tswhose keys match the manifest slot names exactly. -
ssr-entry.ts=export { default } from "./widget";. -
registerPluginWidget(…, { category: "layout" })andmount(…, { category: "layout" }). -
parseWidgetConfig(configZod, props.options ?? {})in the component. -
renderedSlots?.[name] ?? nulleverywhere — never assume a key exists. - Class-namespaced CSS, no
:host, no background on the shell. - The component cannot throw. A layout throw 500s the customer’s page.
A step-by-step build of exactly this is in Add a layout widget.