zudo-text

検索したい単語を入力

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

l-lessons-frame-component-architecture

Lessons from the Frame Chrome Consolidation epic (#1482): what the frameset architecture means, the frame component contract, and why page-level chrome is the wrong pattern. Use when: (1) Adding a new...

Lessons: Frame Component Architecture

The Core Principle

Every page is a frameset host. Every interactive surface is a frame component with its own toolbar. Page-level chrome is the wrong pattern.

An interactive sub-surface is not automatically another frame. When navigation, dirty state, and content share one ownership boundary, keep them inside one provider. core.doc-cloud owns Projects + outline + page tabs + editor/preview inside one frame; EFE owns its directory tree inside the editor frame. Their collapsible panes use seam edge tabs and remain mounted. Do not model either outline/tree as a provider or cross-frame event.

Multi-frameset references (W7.2 / sub #1488): The Frame Component Contract (section 2) was written when each route could have multiple saved framesets identified by id. Under Frameset Arch v2 (Epic #1961), there is one singleton frameset per window — no framesets[] array, no framesetId on pins. If you see references to currentFramesetId, FramesetHandle.framesets, or useSavedFramesets (plural) in this skill, those reflect the v1 model. For the current model, read l-lessons-frame-frameset-pin-model.

Before the Frame Chrome Consolidation epic (#1482), the app had:

  • A <PageBar /> on the inbox route that duplicated controls already available inside the frame.

  • Search and archives as bespoke route components with their own top-level toolbar.

  • The terminal as a special-cased pane controlled by a page-level MaximizedPane toggle.

After the epic, the rule is: if the user interacts with something, it lives inside a provider's toolbar slot, not outside the frameset.

The Table-Cell Visual Model

The active-frame border follows a "table-cell" mental model (one 1px chrome-owned border per leaf, an inset box-shadow for the active state, zero layout shift). The canonical explanation — and why outline and extra wrapper divs are forbidden — lives in l-lessons-active-frame-border ("Visual model: the table-cell metaphor"). Read it before touching frame border or focus styling.

The Frame Component Contract

Full contract: doc/src/content/docs/architecture/frame-component-contract.mdx Sub-issue that produced it: #1488 (Wave 4, Frame Chrome Consolidation)

The contract has six sections:

Section 1 — Page Invariant

Every route is a thin <Frameset> host. Route files (*-page.tsx) must not contain a <PageBar />, <GlobalHeader /> copy, or any top-level toolbar. The DOM structure on any route is:

<App>
  <GlobalHeader />   (global — lives outside all routes)
  <Frameset>
    …leaves…
  </Frameset>
</App>

Section 2 — Provider Contract

A ViewProvider must expose:

  • id — stable string, kebab-case, namespaced (core.* for built-in providers).

  • title — human-readable name shown in the chrome header.

  • icon — ReactNode (icon component).

  • description — non-empty string for the empty-leaf picker.

  • singletonScope"none" | "frameset" | "app". Most providers use "none" (multi-instance). Use "frameset" when exactly one leaf may exist in a frameset (core.doc-cloud); reserve "app" for a truly app-global instance.

  • consumes — array of channel ids the provider reads from the bus.

  • canPopOuttrue | false | undefined. Set false for providers whose state cannot cross windows (draft editor, search, archives — all use false today).

  • serialize / deserialize — round-trip props to plain JSON. deserialize must never throw and must return valid props for any input (null, undefined, wrong types).

  • defaultProps — the "fresh mount" props used when the provider is added from the empty-leaf picker.

Section 2e — Layout Array

A provider that offers multiple presentations declares them in the layouts: LayoutDef[] array. Each LayoutDef has:

  • id — persisted in props.layoutId.

  • label — shown in the shell's dropdown (when it lands).

  • icon — ReactNode.

  • Component — the React component that renders the layout.

props.layoutId must be included in serialize / deserialize / defaultProps. Unknown or missing layoutId must fall back to the first layout's id without throwing.

Section 3 — Persisted Leaf-State Schema v2

Props are opaque to the frameset; only the provider's serialize / deserialize see them. The frameset stores the serialized blob verbatim. On load, deserialize is responsible for validating and migrating the blob — the frameset does not touch it.

store (the SplitDraftStoreForView for inbox) is injected by the page shell after deserialization and is never stored in the persisted blob.

For Doc Cloud, the safe serialized props are exactly projectSlug, initialSurface, editorLayout, and outlineCollapsed. Fetched project data, tabs, raw source, save machines/guards, and credentials belong to the one frameId-keyed cache or credential store, never the blob. Its matchIdentity excludes collapse. EFE follows the parallel rule: its identity includes the sorted paths and treeRoot, but excludes treeCollapsed.

Section 3e — Embedded collapsible panes

The collapse affordance is a named <button> on the pane seam, not a toolbar panel button. It needs a state-specific Collapse/Expand accessible name, aria-expanded, pointer and native keyboard activation, and an explicit :focus-visible outline. Keep the pane DOM node mounted while collapsed and mark the hidden/inert state appropriately; unmounting discards tree/scroll state and changes component ownership. Patch only the containing provider's collapse prop. Tests must assert the node identity survives both collapse directions.

Section 4 — Toolbar Slot Shape

The 28 px strip that every provider renders as its toolbar:

<div
  data-testid="frame-toolbar"
  data-frame-id={ctx.frameId}
  data-provider-id="core.<name>"
  className="flex items-center gap-xs px-sm shrink-0 border-b border-edge bg-surface"
  style={{ height: 28 }}
>
  {/* leading: icon + label */}
  {/* trailing: controls */}
</div>

The data-testid, className, and style.height are normative — contract test fixtures assert them.

Section 5 — Active-Frame Border Interaction

The frame chrome (not the provider) paints the active border. Providers must not add their own focus ring or border to the leaf bounding box. See l-lessons-active-frame-border skill.

Section 6 — Test Fixtures

Contract conformance tests live in *-provider.test.tsx alongside each provider. They use the shared fixture from __fixtures__/shared-provider-contract.ts and assert:

  • id is stable.

  • singletonScope is declared.

  • consumes is an array.

  • defaultProps is present.

  • icon is a non-null ReactNode.

  • serialize round-trips a known props object.

  • serialize returns plain JSON (no class instances).

  • deserialize handles null input gracefully.

  • deserialize handles missing/invalid required fields gracefully.

  • The provider registers in a fresh ProviderRegistry without error.

Pattern Lessons

Providers expose Toolbar + Content (or Toolbar + layouts[])

The preferred render shape (section 4d) is to expose a Toolbar component and either a Content component (single presentation) or a layouts[] array (multiple presentations). The legacy render() function is supported for backward compat but new providers should prefer the split shape.

serialize / deserialize for persisted props

Every field that should survive reload must be in serialize output. deserialize is the single migration point — it receives the raw stored blob and must return valid props defensively (no throw, no crash). Validate with type-narrowing guards, not as casts.

Optional canClose / canPopOut

canClose gives the provider a veto on close (e.g., warn about unsaved edits). canPopOut gates the pop-out button in the chrome header. If the provider holds in-memory state that would be lost on window close (draft editor, search), set canPopOut: false.

Stable id strings

The provider's id is stored in the persisted frameset tree. Changing it is a breaking change that requires a migration entry in use-saved-frameset.ts (singular, v2 singleton hook). Keep ids stable; rename only when migrating away from a deprecated id (and add the old id to DEPRECATED_EDITOR_PROVIDER_IDS or RETIRED_SPLIT_PANE_PROVIDER_IDS).

Reference Files

  • doc/src/content/docs/architecture/frame-component-contract.mdx — the canonical contract.

  • tauri-app/renderer/view-providers/search-provider.tsx — clean example of a provider with no persisted props (serialize / deserialize round-trip an empty object).

  • tauri-app/renderer/view-providers/archives-list-view-provider.tsx — example of a provider with layouts[] array and props.layoutId persistence.

  • tauri-app/renderer/view-providers/inbox-provider.tsx — most complex provider: multiple layout ids, per-leaf timeline state, store injection in deserialize.

  • tauri-app/renderer/view-providers/__fixtures__/shared-provider-contract.ts — test fixture.

  • packages/frameset/src/frame-chrome.tsx — the chrome that all providers are wrapped with.


Bug Fix 1512/1513 Additions (Subs 2, 5, 6)

Lesson (a) — chrome-rendered-layout-switcher (Frame Component Contract §2e, now enforced)

What changed: Frame Component Contract section 2e ("Layout Array") is now enforced at the chrome layer. packages/frameset/src/frame-chrome.tsx (lines 428-436) renders a LayoutSwitcherDropdown in the header's middle cluster whenever provider.layouts is non-empty. The chrome reads provider.layouts (type ReadonlyArray<ProviderLayoutMeta>) and provider.currentLayoutId, and fires onRequestLayoutChange(frameId, layoutId) to the page shell via the FrameChromeMeta.onRequestLayoutChange callback (line 253).

Where it applies: Both core.inbox and core.archives-list-view declare a layouts array. The chrome renders their dropdowns in exactly the same spot (header trailing, left of the icon buttons) with no per-provider branching.

What providers must NOT do: A provider that declares layouts: LayoutDef[] must NOT render its own layout switcher inside its toolbar slot. The chrome dropdown is the single owner. Having both would create two competing switchers. The archive provider originally had its own in-toolbar dropdown before Sub 2 (#1516) moved it to the chrome; that bespoke code was deleted.

Symbol inventory:

  • ProviderLayoutMeta{ id: string; label: string; icon?: ReactNode } — the slim shape the chrome receives (not the full LayoutDef<Props>; the chrome does not need Component).

  • LayoutSwitcherDropdownpackages/frameset/src/frame-chrome.tsx line 161 — renders the dropdown inside the chrome header.

  • FrameChromeMeta.layouts / FrameChromeMeta.currentLayoutId / FrameChromeMeta.onRequestLayoutChange — the chrome-level props that frameset-chrome-adapter.tsx populates from the provider registry and current leaf props.

  • onArchivesLayoutChange / useArchivesLayoutPersistence — the write-back bridge that routes the chrome onRequestLayoutChange event back into the persisted frameset blob for archives (mirrors the inbox's useInboxLayoutPersistence).

Persistence: the chrome dropdown fires onRequestLayoutChange(frameId, layoutId). The page shell wires that to framesetCommandRef.current.replaceProvider(...) which writes the new layoutId into the persisted leaf props. The layout survives reload.

Lesson (b) — empty-as-frame-component (core.empty replaces EmptyLeafNode)

What changed (Sub 6, #1520): The EmptyLeafNode union member ({ type: "empty-leaf", ... }) and the emptyRenderer prop on <Frameset> / <LeafRenderer> have been retired. Every leaf in the tree is now a LeafNode ({ type: "leaf", providerId: string, ... }). Empty frames use providerId: "core.empty".

Provider contract:

id:             "core.empty"
singletonScope: "none"    // unlimited empty frames
canPopOut:      false     // nothing to pop out
serialize/deserialize/defaultProps: {} (empty object)

Implemented in tauri-app/renderer/view-providers/empty-provider.tsx. The factory function makeEmptyProvider(registry, framesetCommandRef) is called inside each page's useMemo registry so the provider holds references to the live registry and frameset command ref.

No special-case path in the chrome adapter. tauri-app/renderer/components/frameset-chrome-adapter.tsx goes through the same registry.get(providerId) lookup path for "core.empty" as for any other provider. There is no if (leaf.type === "empty-leaf") branch — that code path was deleted.

Migration shape (v1, retired): the old { type: "empty-leaf", … } node was migrated to a core.empty LeafNode by migrateEmptyLeafNodes() in the v1 hook (preserving frameId so existing activeFrameId references kept working). That migration code has been deleted — see l-lessons-frameset-persistence → "v1 historical context". All leaves are now LeafNode | SplitNode; no empty-leaf union member remains.

Close button removes the leaf. The core.empty provider does NOT set canClose — the default chrome close button fires onRequestClose which removes the leaf from the frameset tree. Picking a provider from the empty-frame picker swaps in a fresh leaf via registry.get(pickedId).defaultProps.

Lesson (c) — popout-side rendering for store-bearing providers (#1556–#1559)

Context: Epic #1556 (Inbox Popout Fix) fixed a gap introduced by #1533. Issue #1533 correctly flipped core.inbox's canPopOut to true but forgot that buildPopoutRegistry() in popped-out-page.tsx was never updated to register core.inbox. The pop-out button appeared; clicking it opened a window that showed "unknown provider".

Why core.inbox cannot live in the static popout registry:

makeInboxProvider is the factory that produces the core.inbox provider object. Its signature is makeInboxProvider(store: SplitDraftStoreForView). The SplitDraftStoreForView is a per-window in-memory structure built from refs and state returned by React hooks (useInboxDraftStore, useDraftManager). It cannot exist before a React component mounts.

buildPopoutRegistry() is called inside a useMemo([]) on PoppedOutPage's first render — which is before any inner state or refs are initialized. There is no way to pass a store into it, because the store does not exist at that point.

The canonical solution: React-layer branch

When a provider's factory requires a per-window dependency that is only available inside React scope, it cannot live in the static popout registry. The fix is to detect the provider id in the page component and branch to a dedicated component that builds the dependency and the provider inside the React tree:

// In PoppedOutPage:
if (params.providerId === "core.inbox") {
  return <PoppedOutInboxRoot params={params} />;
}

PoppedOutInboxRoot (tauri-app/renderer/popped-out-inbox-root.tsx) then:

  1. Calls useDraftManager with { independent: true } to avoid stomping the host's active-draft pointer via bridge.drafts.setActive.

  2. Builds the store via useInboxDraftStore.

  3. Wraps makeInboxProvider(store) in useMemo to produce the provider.

  4. Mounts the required context providers (SettingsProvider, SubscriptionProvider, FrontmatterSchemaProvider) around the rendered content.

Key trade-off: no live mirror, no concurrent-edit protection

Each popped-out inbox window has its own independent SplitDraftStoreForView. The popout and host do not share in-memory draft state — this is explicit in inbox-provider.tsx:23-27. Two windows editing the same file is a last-writer-wins situation, the same as two host-side inbox splits. No conflict detection or lock is added. This is documented as a non-goal.

The general rule:

If canPopOut: true and the provider factory requires a per-window dependency (a live store, a subscription ref, a component-scoped hook), registering it statically will fail at runtime. Add a React-layer branch in PoppedOutPage and build the dependency inside a dedicated root component.

Cross-reference: tauri-app/renderer/popped-out-inbox-root.tsx (header comment contains the full context-provider audit); packages/frameset/INTEGRATION-NOTES.md "core.inbox provider gap fix" section.

FrameChrome V2 provider actions

The title is static; provider replacement uses Empty frame → picker. The Layout dropdown contains only provider.layouts. Optional HeaderActions receives { frameId, narrow }; use FrameHeaderActionGroup for wide tooltip buttons and narrow overflow from the same action list. Header width (560 base px, display-scale aware) owns this choice. Keep the adapter element memoized by component identity and frameId, and never mount actions in collapsed strips or rails. Layout rows continue to pair bg-active with text-active-fg (regression #1637).