Frame Component Contract
Single source of truth for how every page is composed, how providers expose their per-frame toolbar, how leaf state is persisted, and how the active- frame border interacts with provider-owned chrome. Originally written for issues #1494 / #1495 / #1496 / #1497 / #1498; the #1497 terminal provider is retained here only as historical context after its cloud-primary retirement.
Warning
This document originated as the deliverable for issue #1488 (Wave 2 of the Frame Chrome Consolidation epic #1482). Its shared frame-chrome rules remain normative for current providers. The original sub-task list also included the terminal provider (#1497); that reference is historical because the provider and its PTY backend were retired in the cloud-primary transition. It is not a shipped provider or an implementation target now.
See also: Frameset–Pin Contract — design decisions for matcher equivalence (D1), draft frameset slot (D2), live-tree selector (D3), pin highlight derivation (D4), pristine-snapshot rollback (D8), debounced-save flush (D9), and layout-change divergence (D10) introduced in Epic #1744.
Why this contract exists
The original expanded epic plan introduced four-plus new providers, including the since-retired terminal provider, plus restored layout-switching on inbox / archives on top of the inbox provider that #1485 had already merged. Without a single shared shape, every new provider would reinvent its own toolbar layout, its own persistence schema, and its own relationship with the page above it. Codex pass-2 named this "the real danger of the expanded plan: divergent persistence contracts across providers". This contract closed that gap; its provider-independent rules still apply.
The contract has six parts, each one normative:
Page invariant — every route is
<App><GlobalHeader /><Frameset /></App>or explicitly exempt.Provider contract — what a
ViewProviderexposes, including the newToolbar/Content/layoutsfields.Persisted leaf-state schema v2 — the on-disk shape of a leaf.
Toolbar slot shape — exact JSX / CSS contract for the per-frame toolbar.
Active-frame border interaction — toolbar must not paint a competing border.
Test fixtures — the Vitest fixture provider that #1494 / #1495 / #1496 / #1497 tests build on.
1. Page invariant
Every route in the app is one of two things — there is no third "page chrome" category.
1a. Conforming category
A conforming route has exactly this shape:
<App>
<GlobalHeader />
<Frameset defaultLeaf={<provider-id>} />
</App>Where:
<App>is the root layout: workspace sidebar, dialogs, settings overlay, command palette, global event listeners.<GlobalHeader />istauri-— the route-level top bar (navigation, settings icon, drag region, and right-cluster icons). It is the only chrome that lives betweenapp/ renderer/ components/ toolbar. tsx <App>and<Frameset>. No second top bar is allowed.<Frameset>mounts a saved frameset tree (tauri-). Theapp/ renderer/ hooks/ use- saved- frameset. ts defaultLeafis the provider id of the leaf that boots when a fresh / empty frameset is created for that route.
There is no "page-level utility bar" between <GlobalHeader /> and <Frameset>. All per-route controls (Publish, Sweep, Schema, layout switcher, search controls, and other provider-specific controls) live inside the active leaf's Toolbar slot, never on a wrapper above the frameset. Wave 1 #1487 deleted the last page-level utility bar (the inbox Publish / Sweep / Schema strip) for exactly this reason.
1b. Exempt category
A route may be exempt from the conforming shape only if it is fundamentally not a multi-frame workspace. Today's exempt list is enumerated in full below — adding a new exempt route requires editing this list and justifying it in the same change.
| Route | File | Justification |
|---|---|---|
/ | tauri- | A popped-out window renders exactly one provider's content with no <Frameset>. The leaf IS the entire window — there is no host tree to mount. |
/ | tauri- | Fatal-error / offline / no-workspace screens shown instead of the normal app. There is no workspace state to mount a frameset against. |
Manager zudotext.app (entire app) | tauri-app/renderer/ (root mode) | The manager runs the same renderer/ entry point with root-mode gating. It surfaces a generate-child-app dialog and never opens a workspace, so the <Frameset> shell does not apply. The manager pages are not bound by this contract. |
Routes currently in the conforming category:
| Route | File | defaultLeaf |
|---|---|---|
/ | tauri- | core.inbox |
/ | tauri- | core.inbox (Note Tray over archives/ — epic #3426 Archives switchover) |
/ | tauri- | core.tags (or migrated to a single-leaf frameset by a future sub) |
1c. Adding a new route — required steps
Decide: conforming or exempt.
Conforming — the page component must do nothing more than mount
<Frameset>. If it needs a top bar, add the controls to the relevant provider's Toolbar slot, not to the page.Exempt — append a row to the exempt table above with the route, file, and a one-sentence justification. A reviewer can reject any exempt entry whose justification reduces to "I didn't want to write a provider yet".
Never invent a third category. "It is mostly a frameset but with a small bar above" is not allowed — it is a conforming route whose toolbar lives in the leaf.
2. Provider contract
A ViewProvider (defined in packages/) is the smallest unit a <Frameset> can mount. The contract has three layers:
2a. Identity and picker metadata
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Stable, dot-namespaced. Convention: "core.<feature>" for built-ins (core.inbox, core.ai-assistant). User-installable providers use a vendor prefix. The id is the only thing persisted on disk to identify this provider — once shipped it is effectively forever. |
title | string | yes | Short human label used in the empty-frame picker and the chrome's static title. |
description | string | yes | One-line description shown in the empty-frame nav. |
icon | ReactNode | yes | Picker icon. Must be sized to fit the chrome's static title (typically <XxxIcon size="md" /> from @takazudo/ui-components). |
2b. Render surface
The provider's body has two vertically composed slots, plus an optional action slot in the frame header:
| Slot | Component | Receives | Purpose |
|---|---|---|---|
HeaderActions | React.ComponentType<HeaderActionsProps> | frameId, narrow | Optional discoverable actions in the expanded frame header. Use frame-scoped subscriptions, not a captured active-frame global. |
Toolbar | React.ComponentType<{ active, ctx, props }> | per-frame controls | Rendered into the leaf's fixed top slot, immediately below the chrome header bar. Body-specific controls live here; discoverable header actions use HeaderActions. See section 4 for the exact JSX / CSS contract. |
Content | React.ComponentType<{ active, ctx, props }> | the leaf's content area | Rendered below the toolbar, fills remaining space (flex: 1 1 0; min-height: 0). The provider owns scrolling inside this slot. |
The Toolbar and Content components receive the same three-prop bag:
interface ProviderRenderProps<TProps> {
/**
* Whether this leaf is the currently active frame in its frameset. The
* provider uses this to gate keyboard-shortcut listeners (only the active
* leaf should respond to Cmd+E etc.) and any focus-dependent behaviour.
*/
active: boolean;
/**
* Frame context — the seam to the surrounding shell (focus, becomeActive,
* publish, subscribe, requestClose / requestSplit / requestZoom /
* requestPopOut). See `FrameContext` in
* `packages/view-provider/src/types.ts`.
*/
ctx: FrameContext;
/** Deserialised provider props (the result of provider.deserialize). */
props: TProps;
}The legacy single render(props, ctx): ReactNode API is still supported for back-compatibility — providers shipped before this contract may continue to use it. The frameset shell inspects the provider in this order:
If
ToolbarandContentare both defined → render the leaf as<Toolbar /> <Content />stacked vertically inside the chrome's content slot. The chrome owns the active border; the provider owns everything inside.Else if
renderis defined → callrender(props, ctx)and place the result inside the chrome's content slot. The provider is then responsible for laying out its own toolbar internally; it must still follow section 4 (28 px height, exact classNames, no second toolbar layer above).Else → registry-level error.
New providers SHOULD use Toolbar + Content. Migrating existing providers is opportunistic — the inbox provider (core.inbox, tauri-) presently uses the legacy render shape and produces a toolbar that already matches section 4 verbatim. It will be migrated to Toolbar + Content in a follow-up.
2c. Persistence
| Field | Type | Required | Notes |
|---|---|---|---|
serialize(props): JsonValue | function | yes | Convert runtime props to a JsonValue. Functions, refs, stores, DOM nodes, and React elements MUST NOT round-trip — only primitive / array / object values. |
deserialize(blob): props | function | yes | Reconstruct runtime props from JsonValue. Must be totally defensive: pre-release schema changes, partial blobs, and outright garbage all resolve to defaultProps (or a sane partial) without throwing. The shell calls this in registry.renderLeaf so failures crash the leaf, not the frameset. |
defaultProps | TProps | yes | The props used for a freshly-created leaf and the fallback for any deserialise failure. |
The props field on disk is opaque to the frameset and to the registry — only the provider's own deserialize can interpret it.
2d. Lifecycle and shell integration
| Field | Type | Required | Notes |
|---|---|---|---|
singletonScope | "app" | "frameset" | "none" | yes | "none" = any number of instances, "frameset" = one per window, "app" = one across the entire app. core.inbox (Note Tray — backs both Inbox and Archives) is "none" (split panes). |
preferredOpenSplit | SplitDirection | yes | The default direction the shell uses for a fresh split from this provider. |
consumes | ReadonlyArray<ConsumeKey> | optional | Keys this provider reads from the consumes bus. Used by the cycle detector and the data-flow visualiser. |
canPopOut | boolean | optional | Default true. Set to false when the provider holds per-window in-memory state that cannot cross windows (e.g. core.inbox whose SplitDraftStoreForView is per-window). The chrome hides the pop-out button when this is false. |
canClose(props, ctx) | function | optional | Veto a close request, e.g. dirty-unsaved state. Returning { ok: false, reason } blocks the close. |
getDirtyState(props, ctx) | function | optional | Whether the leaf has unsaved state. Used by the unload guard. |
acceptsFileDrop / handleFileDrop | predicate / handler | optional | DnD onto the leaf. |
layouts | Array<{ id, label, Component }> | optional | New. See section 2e for the layout-switching shape used by Sub 10 (#1495 inbox) and Sub 11 (#1496 archives). |
applyInitialOptions(frameId, options) | function | optional | Apply user-selected options before the leaf mounts. See section 2g for the full contract. |
2e. Optional layouts
Providers that present the same data through several presentations expose a layouts array. The shell renders its Layout dropdown after the static icon and title and before provider HeaderActions and window controls. The dropdown lists only this provider's layouts, never other providers. The provider does not render this dropdown itself (a provider-owned mode selector can opt out through hideLayoutSelector).
interface LayoutDef<TProps> {
/** Stable id, persisted in `props.layoutId`. */
readonly id: string;
/** Human label shown in the dropdown. */
readonly label: string;
/** Optional icon for the dropdown row (provider's own icon set). */
readonly icon?: ReactNode;
/**
* The component rendered for this layout. Receives the same
* `{ active, ctx, props }` bag as `Content`. When `layouts` is defined the
* shell ignores `Content` and routes to the `Component` whose `id` matches
* `props.layoutId`.
*/
readonly Component: React.ComponentType<{
active: boolean;
ctx: FrameContext;
props: TProps;
}>;
}
// On the provider:
readonly layouts?: ReadonlyArray<LayoutDef<TProps>>;When layouts is present:
The provider's
defaultPropsMUST includelayoutIdset to one of the listedids.The provider's
serializeMUST includelayoutIdin the output blob.The provider's
deserializeMUST resolve unknown / missinglayoutIdto the first layout's id (never throw).The shell renders the layout-switcher dropdown in the toolbar; clicking an item calls
ctx.publishis not the right channel — use the existing tree-mutation seam: the shell intercepts the dropdown selection and updates the leaf'sprops.layoutIdvia the sameupdateFramesetTreepath that drives every other persisted-prop change.The provider's
Toolbarrenders below the chrome header. Discoverable actions belong inHeaderActions; body-specific controls can remain inToolbar. The Layout dropdown only changes layout.
The original consumer was Sub 10 (#1495), restoring inbox horizontal / vertical timeline modes via this shape. A second provider, archives-list-view-provider.tsx (Sub 11, #1496), later implemented its own four-layout switcher (list-detail / timeline / grid / table) the same way; that provider was retired by epic #3426 (Note Tray) — Archives is now the same core.inbox provider as Inbox, pointed at the archives/ directory, and shares its layouts array.
2f. Optional SettingsContent
Providers that have per-frame configurable options can expose a SettingsContent component. When defined, the chrome renders a gear icon button in the trailing cluster; clicking it opens a dialog that mounts SettingsContent.
interface ProviderSettingsProps<TProps> {
/** Current props snapshot (live leaf props or provider.defaultProps). */
readonly props: TProps;
/** Called with the full next props value. The host decides when to persist. */
onChange(next: TProps): void;
}
// On the provider:
readonly SettingsContent?: React.ComponentType<ProviderSettingsProps<TProps>>;Embeddability contract — SettingsContent is designed to be safe for two hosts:
Per-frame gear dialog (primary host): The frameset chrome adapter (
frameset-chrome-adapter.tsx) opens aFrameSettingsDialogshell. The current leaf props are passed asinitialProps;onConfirmwrites throughFramesetHandle.replaceProvider.Add Pin wizard options step (Wave 2 host): The wizard mounts
SettingsContentwithprovider.defaultPropsbefore a leaf is created.onChangeaccumulates the pending value; the wizard commits on finish.
Rules every SettingsContent must follow:
MUST NOT render its own Done / Cancel / Save buttons — the host dialog owns those controls.
MUST NOT call
onClose— that is the host's responsibility.MUST be safe to mount with
provider.defaultPropsasprops— no live leaf orFrameContextis available in the wizard host.SHOULD use local React state for intermediate editing and call
onChangeon blur or explicit confirmation steps — not on every keystroke — to avoid writing every character to disk.
The chrome-level ProviderMeta.hasSettings: boolean projects the presence of SettingsContent without exposing the component type to the chrome. The adapter (toProviderMeta) sets it from provider.SettingsContent != null. The chrome renders the gear button only when hasSettings is true; it fires onRequestSettings(frameId) on click, which the adapter handles.
The dialog shell lives at packages/ (exported as FrameSettingsDialog from @takazudo/frameset). The shell is mobile-aware: it uses MobileFullscreenDialog on viewports ≤ 640 px and a centered portal dialog on desktop.
2g. Optional applyInitialOptions
Providers that store some options outside of serialized props implement applyInitialOptions to apply user-selected options from the Empty-frame picker before the leaf mounts.
applyInitialOptions?: (
frameId: string,
options: Record<string, unknown>,
) => JsonValue;The Empty-frame picker calls this method after phase 2 commit (the user has chosen a provider and confirmed their options). The method:
Mutates any out-of-props state the provider needs (e.g. writes the chosen
layoutIddirectly into the provider's module-scoped instance cache).Returns the
propsJsonValueto pass toreplaceLeafProvider.
When to implement: only when a provider stores options outside of serialized props. The canonical example is core.inbox, which stores layoutId in inbox-instance-cache.ts per the post-#1965 refactor — the chosen layout id is NOT in props and replaceLeafProvider's props cannot reach it.
Default behavior when omitted: the caller falls back to provider.serialize({ ...provider.defaultProps, ...options }), which works for any provider whose options live entirely in serialized props (e.g. archives, kanban, todo — all of which serialize layoutId per the chrome's existing dropdown contract).
Existing providers are unaffected — the method is optional and omitting it changes no existing behavior.
3. Persisted leaf-state schema v2
A persisted leaf on disk is exactly the LeafNode type from packages/:
interface LeafNode {
readonly type: "leaf";
/** Stable id of this leaf. Unique within a tree. */
readonly frameId: string;
/**
* Stable id surviving provider swaps and pop-out / dock-back. Unique
* within a tree.
*/
readonly instanceId: string;
/** The provider id this leaf is currently bound to. */
readonly providerId: string;
/** Provider-opaque props, validated by provider.deserialize. */
readonly props: JsonValue;
/** Visible state of the leaf. */
readonly state: FrameState; // "normal" | "collapsed" | "zoomed" | "popped-out"
/** Optional title override (rare; provider title is used by default). */
readonly title?: string;
}Schema v2 rules:
providerIdis opaque to the frameset. The shell never inspects it beyond looking it up in the registry. If the registry does not know it, the leaf renders as an "unresolvable provider" placeholder; the frameset tree is not edited.instanceIdis the cross-mutation identity key. It survives:provider swap (
replaceLeafProviderinframeset-tree.ts),pop-out → docked-back cycles (
setPoppedOut),frameset switch when the provider has
singletonScope: "app"(appScopedInstanceIdsinuse-saved-frameset.ts).frameIddoes NOT —frameIdis regenerated when the leaf moves through a tree restructure.
propsis opaque to the frameset. Validation is delegated to the provider'sdeserialize, called byregistry.renderLeafexactly once per render. The frameset never reads, copies, or compares fields insideprops— it treats the value as a black-boxJsonValuefor storage and equality. Two leaves with the samepropsreference compare equal; deep-equality is the provider's job.No format version field. Per
CLAUDE.md"Pre-Release: No Backward Compatibility": the contract is allowed to change shape without a migration step until first release. Old.zudotext.settings.jsonfiles are recreatable from defaults.
Where validation happens:
| Layer | Responsibility |
|---|---|
validateFramesetTree (packages/) | Tree shape: discriminated union, ratios in [0,100], unique frameId / instanceId per tree, allowed state transitions. NEVER inspects props. |
registry.renderLeaf (packages/) | Looks up providerId, calls provider.deserialize(leaf.props) to obtain typed props, then invokes the provider's render path. |
provider.deserialize (each provider) | Validates props defensively; falls back to defaultProps for any failure. NEVER throws — render-time exceptions kill the entire leaf. |
useSavedFrameset (tauri-) | Tree-level normalisation when an old default tree shape is detected. Any future "the default tree changed shape" upgrade lives here. |
4. Toolbar slot shape
Every provider toolbar is a single horizontal strip at the top of the leaf, with this exact contract:
Provider header actions
ViewProvider.HeaderActions?: ComponentType<HeaderActionsProps> receives { frameId: string; narrow: boolean }. The registry accessor is getHeaderActionsFor(providerId); ProviderMeta.hasHeaderActions is the stable capability flag. The adapter memoizes a per-frame element in FrameChrome.headerActions, and the expanded header supplies narrow from its own useContainerNarrower<HTMLElement>(560) measurement. Keep the element's identity stable across typing and unrelated parent updates. Do not mount it in collapsed rail or strip branches.
Use FrameHeaderActionGroup({ actions, narrow }) from @takazudo/frameset for tooltip icon buttons that fold into one ToolbarKebabMenu when narrow. Each action has id, label, icon, onClick, optional testId, disabled and pressed. The same list drives both presentations. FrameHeaderActionButton is also exported for individual actions. Provider replacement is available through Empty frame → picker, not through the static title.
4a. Dimensions and class list
<div
data-testid="frame-toolbar"
data-frame-id={ctx.frameId}
data-provider-id={provider.id}
className="flex items-center gap-xs px-sm shrink-0 h-frame-header border-b border-edge bg-surface"
>
{/* provider's controls */}
</div>| Property | Value | Why |
|---|---|---|
| Height | exactly 28px base (scales with --display-scale) | Matches the chrome header bar height (h-frame-header token in packages/; base value --spacing-frame-header: 28px defined in packages/). Two horizontal bars at the same token produce a single visual seam, not a stair. |
| Vertical layout | flex items-center | Vertical centering of all toolbar children. |
| Inter-child spacing | gap-xs | Matches the chrome's existing header gap. |
| Side padding | px-sm | Matches the chrome's existing header padding. |
| Shrink | shrink-0 | The toolbar must NOT shrink under content pressure — content goes under it via min-h-0 on the content slot. |
| Bottom edge | border-b border-edge | Single-pixel separator between toolbar and content. The neutral border-edge colour, never the active-frame colour (see section 5). |
| Background | bg-surface | Neutral chrome surface, distinct from the content area's bg-bg so the user can see where chrome ends. |
4b. Content layout convention
The provider's controls are arranged left-to-right with a ml-auto separator splitting "leading controls" from "trailing controls":
<div className="flex items-center gap-xs px-sm shrink-0 h-frame-header border-b border-edge bg-surface">
{/* Leading: primary widgets the user reaches for most often (DraftBar,
search input, layout-specific sort dropdown). */}
<DraftBar ... />
<SchemaDiagnosticsBadge ... />
{/* Spacer pushes the trailing cluster to the right edge. */}
<div className="ml-auto flex items-center gap-2xs shrink-0">
{/* Trailing: actions and toggles that affect the whole frame
(Publish, Sweep, view-mode, layout-switcher when present). */}
<PublishButton ... />
<SweepButton ... />
<ViewModeToggle ... />
</div>
</div>All buttons in the trailing cluster use the standard small icon-button shape:
<button
type="button"
className="flex items-center justify-center p-xs text-fg-muted hover:text-fg transition-colors"
aria-label="..."
data-testid="..."
>
<Icon size="sm" />
</button>Icon size is "sm" (16 px) to match the chrome's own collapse / zoom / close buttons in frame-chrome.tsx.
4c. Forbidden patterns
No second toolbar layer above this slot. A provider must not render two stacked horizontal strips above the content. If you have more controls than fit, use a dropdown / overflow menu inside the trailing cluster.
No page-level toolbar. The page (write-page, archives-page, …) must not render a strip between
<GlobalHeader />and<Frameset>. That layout is forbidden by section 1. The Wave 1 #1487 deletion enforces this for the inbox; new providers must keep the property.No second border under the toolbar. Exactly one
border-b border-edgelives on the toolbar root. Addingborder-tto the content area, or wrapping the toolbar in another bordered div, double-paints the seam.No background other than
bg-surface. Custom colours (e.g.bg-bg-alt) drift the toolbar visually away from the chrome header above it and break the "single chrome strip" illusion.
4d. Reference implementation
The closest in-tree reference is the inbox provider's in-render toolbar: tauri-, lines 350–388 (the shrink-0 flex items-center … div containing <DraftBar />, <SchemaDiagnosticsBadge />, <PublishButton />, <SweepIcon />, <ViewModeToggle />). That implementation predates this contract and uses slightly different padding (py-sm pl-lg pr-md instead of px-sm) and background (bg-bg-alt instead of bg-surface) — both are tracked as follow-up nits to converge on the contract values above. Any NEW provider written after this document MUST use the values in section 4a.
5. Active-frame border interaction
The active-frame border (Wave 1 #1483 audit doc: doc/, section "Artifact 4 — Exact named DOM owner per leaf state") lives on one DOM node: the div[data-testid="frame-chrome"] root in packages/. The border is owned by the chrome, not by any provider.
The toolbar from section 4 lives inside that chrome wrapper, immediately below the chrome's header bar:
div[data-testid="frame-chrome"] ← OWNS the active border
├── header[data-testid="frame-header"] ← chrome's static title + Layout + HeaderActions + window controls (h-frame-header, 28px base)
├── div[data-testid="frame-toolbar"] ← PROVIDER'S toolbar (this contract, h-frame-header, 28px base)
└── div[data-testid="frame-content"] ← provider's <Content /> fills the restRules the toolbar must obey:
No
border-active-frame, no inset shadow, no outline. The toolbar must not paint anything in the active-frame colour. The chrome's outer border is the single visual cue for activity; a second active-frame stripe inside the leaf creates the "double-border" rendering bug Wave 1 #1483 fixed.No border on the toolbar's top edge. Only
border-blives on the toolbar. The chrome's header above already has its ownborder-b; the toolbar's bottom border separates toolbar from content. Aborder-ton the toolbar would double-paint the seam between header and toolbar.No border on the toolbar's left or right edges. The chrome's outer border owns the leaf perimeter. The toolbar must not paint a side border that would visually overlap.
The toolbar's
border-bcolour isborder-edge, neverborder-active-frame. Active state is announced by the chrome wrapper's border +aria-current="true"; the toolbar is neutral regardless ofactive.
The contract's Toolbar component receives active so it can adjust content behaviour (gate keyboard shortcut listeners, change which control gets focus on activation, dim a status indicator) — never to paint a second active-state border. If you find yourself reading active to drive border-color or box-shadow inside the toolbar, you are restating the chrome's border in the wrong place.
6. Test fixtures
A reusable Vitest fixture provider lives at packages/. It is the smallest possible provider that implements every clause of this contract. It was used by #1494 (search) / #1495 (inbox layouts) / #1496 (archives layouts) / #1497 (the since-retired terminal provider) / #1498 (deletion) tests as a known-good baseline.
The fixture exposes:
import {
createContractFixtureProvider,
type ContractFixtureProps,
} from "@takazudo/frameset/test-fixtures/contract-fixture-provider";What the fixture demonstrates:
Identity and picker metadata — id
"test.contract-fixture", title, description, icon. Visible in the empty-frame nav and the chrome's static title.Toolbar / Content render shape — uses the new
Toolbar+Contentslots (section 2b). TheToolbarrenders with the exact class list from section 4a; theContentrenders a single textarea-like surface.Persistence —
serializeanddeserializeround-trip a{ counter, label, layoutId }props blob;deserializereturnsdefaultPropsfor any garbage input without throwing.Layout switching — exposes two
layouts("flat"and"badged") so the layout-switcher can be exercised end-to-end.Lifecycle hooks —
canPopOut: true,canClosereturns{ ok: true }always (the fixture is never dirty),singletonScope: "none".
Each fixture instance is created via a factory so tests can override individual fields:
const provider = createContractFixtureProvider({
id: "test.contract-fixture-2", // override for multi-instance tests
});The fixture has its own Vitest spec at packages/ that asserts:
All required fields are present on the returned
ViewProvider.serialize/deserializeround-trip lossless for every field indefaultProps.deserialize(null),deserialize("garbage"),deserialize({ layoutId: "unknown" })all return well-formed props (never throw).Toolbarrendered into a leaf produces the exact 28 px class list from section 4a (regression test for any drift in the contract's CSS values).The
layoutsarray is non-empty and every entry has aComponentthat renders without crashing.
If a future change to the contract changes any of the section 4a class names or the persisted-props shape, the fixture spec is the first test to fail — making the contract self-policing.
Currently registered providers
For the user-facing description of every provider you can load into a Space — including id, title, when to use each one, layout options, pop-out support, and persistence behaviour — see the Frame Components Reference guide page.
Header pins and this contract
Header pins do not extend or modify the provider contract in any way. A pin is an entry in AppSettings.headerLeftPins[] that stores a template: PinTemplate (a self-contained FramesetTree snapshot), a display label, an icon id, and a routeSlug for deep-linking. At activation time resolvePinActivation returns { status, route, template: PinTemplate } — no frameset id or settings lookup is needed, because the pin carries its complete layout template inline. The provider itself is never told it has been pinned — its layouts[] array is read as a plain property, exactly as the frameset shell reads it to build the layout-switcher dropdown. The contract surface area is identical whether the user navigates to a frameset via a built-in route, a user pin, or the command palette.
Generated lessons references
The source lessons in . and . describe header placement and frame-scoped state ownership. doc/ enables claudeResources from .; doc dev/build regenerates the gitignored claude-skills/ mirrors. Edit those source skills when changing the contract, not generated copies.