zudo-text

検索したい単語を入力

いつでも検索バーを開ける

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:

  1. Page invariant — every route is <App><GlobalHeader /><Frameset /></App> or explicitly exempt.

  2. Provider contract — what a ViewProvider exposes, including the new Toolbar / Content / layouts fields.

  3. Persisted leaf-state schema v2 — the on-disk shape of a leaf.

  4. Toolbar slot shape — exact JSX / CSS contract for the per-frame toolbar.

  5. Active-frame border interaction — toolbar must not paint a competing border.

  6. 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 /> is tauri-app/renderer/components/toolbar.tsx — the route-level top bar (navigation, settings icon, drag region, and right-cluster icons). It is the only chrome that lives between <App> and <Frameset>. No second top bar is allowed.

  • <Frameset> mounts a saved frameset tree (tauri-app/renderer/hooks/use-saved-frameset.ts). The defaultLeaf is 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.

RouteFileJustification
/popped-out/:frameIdtauri-app/renderer/popped-out-page.tsxA 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.
/ios-degraded/*tauri-app/renderer/pages/ios-degraded/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:

RouteFiledefaultLeaf
/tauri-app/renderer/pages/write-page.tsxcore.inbox
/archivestauri-app/renderer/pages/archives-page.tsxcore.inbox (Note Tray over archives/ — epic #3426 Archives switchover)
/tagstauri-app/renderer/pages/tags-page.tsxcore.tags (or migrated to a single-leaf frameset by a future sub)

1c. Adding a new route — required steps

  1. Decide: conforming or exempt.

  2. 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.

  3. 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".

  4. 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/view-provider/src/types.ts) is the smallest unit a <Frameset> can mount. The contract has three layers:

2a. Identity and picker metadata

FieldTypeRequiredNotes
idstringyesStable, 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.
titlestringyesShort human label used in the empty-frame picker and the chrome's static title.
descriptionstringyesOne-line description shown in the empty-frame nav.
iconReactNodeyesPicker 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:

SlotComponentReceivesPurpose
HeaderActionsReact.ComponentType<HeaderActionsProps>frameId, narrowOptional discoverable actions in the expanded frame header. Use frame-scoped subscriptions, not a captured active-frame global.
ToolbarReact.ComponentType<{ active, ctx, props }>per-frame controlsRendered 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.
ContentReact.ComponentType<{ active, ctx, props }>the leaf's content areaRendered 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:

  1. If Toolbar and Content are 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.

  2. Else if render is defined → call render(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).

  3. Else → registry-level error.

New providers SHOULD use Toolbar + Content. Migrating existing providers is opportunistic — the inbox provider (core.inbox, tauri-app/renderer/view-providers/inbox-provider.tsx) 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

FieldTypeRequiredNotes
serialize(props): JsonValuefunctionyesConvert runtime props to a JsonValue. Functions, refs, stores, DOM nodes, and React elements MUST NOT round-trip — only primitive / array / object values.
deserialize(blob): propsfunctionyesReconstruct 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.
defaultPropsTPropsyesThe 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

FieldTypeRequiredNotes
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).
preferredOpenSplitSplitDirectionyesThe default direction the shell uses for a fresh split from this provider.
consumesReadonlyArray<ConsumeKey>optionalKeys this provider reads from the consumes bus. Used by the cycle detector and the data-flow visualiser.
canPopOutbooleanoptionalDefault 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)functionoptionalVeto a close request, e.g. dirty-unsaved state. Returning { ok: false, reason } blocks the close.
getDirtyState(props, ctx)functionoptionalWhether the leaf has unsaved state. Used by the unload guard.
acceptsFileDrop / handleFileDroppredicate / handleroptionalDnD onto the leaf.
layoutsArray<{ id, label, Component }>optionalNew. See section 2e for the layout-switching shape used by Sub 10 (#1495 inbox) and Sub 11 (#1496 archives).
applyInitialOptions(frameId, options)functionoptionalApply 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 defaultProps MUST include layoutId set to one of the listed ids.

  • The provider's serialize MUST include layoutId in the output blob.

  • The provider's deserialize MUST resolve unknown / missing layoutId to the first layout's id (never throw).

  • The shell renders the layout-switcher dropdown in the toolbar; clicking an item calls ctx.publish is not the right channel — use the existing tree-mutation seam: the shell intercepts the dropdown selection and updates the leaf's props.layoutId via the same updateFramesetTree path that drives every other persisted-prop change.

  • The provider's Toolbar renders below the chrome header. Discoverable actions belong in HeaderActions; body-specific controls can remain in Toolbar. 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 contractSettingsContent is designed to be safe for two hosts:

  1. Per-frame gear dialog (primary host): The frameset chrome adapter (frameset-chrome-adapter.tsx) opens a FrameSettingsDialog shell. The current leaf props are passed as initialProps; onConfirm writes through FramesetHandle.replaceProvider.

  2. Add Pin wizard options step (Wave 2 host): The wizard mounts SettingsContent with provider.defaultProps before a leaf is created. onChange accumulates 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.defaultProps as props — no live leaf or FrameContext is available in the wizard host.

  • SHOULD use local React state for intermediate editing and call onChange on 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/frameset/src/frame-settings-dialog.tsx (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:

  1. Mutates any out-of-props state the provider needs (e.g. writes the chosen layoutId directly into the provider's module-scoped instance cache).

  2. Returns the props JsonValue to pass to replaceLeafProvider.

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/view-provider/src/types.ts:

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:

  1. providerId is 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.

  2. instanceId is the cross-mutation identity key. It survives:

    • provider swap (replaceLeafProvider in frameset-tree.ts),

    • pop-out → docked-back cycles (setPoppedOut),

    • frameset switch when the provider has singletonScope: "app" (appScopedInstanceIds in use-saved-frameset.ts). frameId does NOT — frameId is regenerated when the leaf moves through a tree restructure.

  3. props is opaque to the frameset. Validation is delegated to the provider's deserialize, called by registry.renderLeaf exactly once per render. The frameset never reads, copies, or compares fields inside props — it treats the value as a black-box JsonValue for storage and equality. Two leaves with the same props reference compare equal; deep-equality is the provider's job.

  4. 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.json files are recreatable from defaults.

Where validation happens:

LayerResponsibility
validateFramesetTree (packages/view-provider/src/frameset-tree.ts)Tree shape: discriminated union, ratios in [0,100], unique frameId / instanceId per tree, allowed state transitions. NEVER inspects props.
registry.renderLeaf (packages/view-provider/src/registry.ts)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-app/renderer/hooks/use-saved-frameset.ts)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>
PropertyValueWhy
Heightexactly 28px base (scales with --display-scale)Matches the chrome header bar height (h-frame-header token in packages/frameset/src/frame-chrome.tsx; base value --spacing-frame-header: 28px defined in packages/ui-components/src/tokens.css). Two horizontal bars at the same token produce a single visual seam, not a stair.
Vertical layoutflex items-centerVertical centering of all toolbar children.
Inter-child spacinggap-xsMatches the chrome's existing header gap.
Side paddingpx-smMatches the chrome's existing header padding.
Shrinkshrink-0The toolbar must NOT shrink under content pressure — content goes under it via min-h-0 on the content slot.
Bottom edgeborder-b border-edgeSingle-pixel separator between toolbar and content. The neutral border-edge colour, never the active-frame colour (see section 5).
Backgroundbg-surfaceNeutral 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-edge lives on the toolbar root. Adding border-t to 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-app/renderer/view-providers/inbox-provider.tsx, 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/src/content/docs/architecture/active-frame-border-strategy.mdx, section "Artifact 4 — Exact named DOM owner per leaf state") lives on one DOM node: the div[data-testid="frame-chrome"] root in packages/frameset/src/frame-chrome.tsx. 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 rest

Rules the toolbar must obey:

  1. 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.

  2. No border on the toolbar's top edge. Only border-b lives on the toolbar. The chrome's header above already has its own border-b; the toolbar's bottom border separates toolbar from content. A border-t on the toolbar would double-paint the seam between header and toolbar.

  3. 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.

  4. The toolbar's border-b colour is border-edge, never border-active-frame. Active state is announced by the chrome wrapper's border + aria-current="true"; the toolbar is neutral regardless of active.

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/frameset/src/test-fixtures/contract-fixture-provider.tsx. 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:

  1. Identity and picker metadata — id "test.contract-fixture", title, description, icon. Visible in the empty-frame nav and the chrome's static title.

  2. Toolbar / Content render shape — uses the new Toolbar + Content slots (section 2b). The Toolbar renders with the exact class list from section 4a; the Content renders a single textarea-like surface.

  3. Persistenceserialize and deserialize round-trip a { counter, label, layoutId } props blob; deserialize returns defaultProps for any garbage input without throwing.

  4. Layout switching — exposes two layouts ("flat" and "badged") so the layout-switcher can be exercised end-to-end.

  5. Lifecycle hookscanPopOut: true, canClose returns { 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/frameset/src/test-fixtures/contract-fixture-provider.test.tsx that asserts:

  • All required fields are present on the returned ViewProvider.

  • serialize / deserialize round-trip lossless for every field in defaultProps.

  • deserialize(null), deserialize("garbage"), deserialize({ layoutId: "unknown" }) all return well-formed props (never throw).

  • Toolbar rendered 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 layouts array is non-empty and every entry has a Component that 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 .claude/skills/l-lessons-frame-component-architecture/SKILL.md and .claude/skills/l-lessons-frame-frameset-pin-model/SKILL.md describe header placement and frame-scoped state ownership. doc/zfb.config.ts enables claudeResources from ../.claude; doc dev/build regenerates the gitignored claude-skills/ mirrors. Edit those source skills when changing the contract, not generated copies.