Bug-Fix 1512+1513 Forensic Audit & Fix Strategy
Single-source forensic audit and per-bug fix prescription for the base/bug-fix-1512-1513 epic. Subs 2 / 3 / 4 / 5 / 6 / 8 / 11 implement directly against this document.
Caution
This is a historical strategy document. The bug-fix-1512-1513 epic (#1514) and all its sub-issues (2–11) are complete. The prescriptions in each section have been implemented. Some file paths and hook names reference the v1 model (e.g.use-saved-framesets.ts plural, currentFramesetId, framesets[]) — these are accurate for the era of the fix but the v2 singleton model (Epic #1961) has since replaced that architecture. This document is preserved as a historical record.
Warning
This document is the deliverable for issue #1515 (Wave 1 of the bug-fix-1512-1513 epic #1514). Every later sub (2–11) consumes the verdict + prescription block in its section as binding direction. If a downstream sub disagrees with this doc, the doc wins and the doc must be amended via a follow-up to this issue — not silently overridden in code.
How to read this doc
Each numbered section follows the same shape:
Reproduce — the user-visible symptom plus the smallest gesture that reproduces it (matched against the screenshots in #1512 and #1513).
Static evidence — exact file:line refs, build-CSS grep output, or user-settings audit; no speculation.
Root cause — what specific line / mechanism is wrong, and why previous attempts failed.
Fix prescription for Sub N — one paragraph the implementer can execute mechanically. No "may", no "consider", no TBD.
The seven bug surfaces below cover every concern raised in #1512, #1513, the in-issue follow-up comment (fix5), and the user-feedback bugs 10/11/12 that the epic absorbed during planning.
1. Focus border (3 bugs, one cohesive fix in Sub 4)
This section covers three border bugs that ship as a single coherent fix:
1512 §2 / §4 — accent color. The user expects the active-frame border to track the accent color (their custom
#5CAAE9blue). It is showing as the theme default instead.1513 fix1 — nested suppression. When a frame is active and its contents own an internal selection highlight (timeline view's highlighted card, table row selection, …), the outer frame border is visual noise — the inside item highlight is enough.
1513 fix4 — wrap-around cutout. The active border should fully wrap around the focused area. The screenshot shows the right edge of the active border cut off so the rectangle is open on one side.
1.1 Static evidence
CSS-from-build (pnpm writing:build → tauri-app/dist-renderer/assets/index-rki4i5D3.css )
The token chain that resolves the active-frame border color in the built CSS:
/* Tailwind utility wrapper */
.border-active-frame{border-color:var(--color-active-frame)}
/* @theme bridge — packages/ui-components/src/tokens.css:122 */
--color-active-frame: var(--theme-active-frame-border);
/* :root fallback — packages/ui-components/src/tokens.css:212 */
--theme-active-frame-border: var(--palette-3);The single CSS rule that paints the active border in production is the Tailwind border-edge underlay plus an inline boxShadow:
inset 0 0 0 1px var(--color-active-frame)emitted from three places in packages/:
Line 255 — popped-out strip
Line 294 — collapsed strip
Line 333 — normal / zoomed leaf
…and one place in tauri- (empty-leaf branch). Everywhere else the active border is gated on isActive. There is no outline, no :focus-within selector, and no second border declaration competing for the same DOM node — the single-owner contract from architecture/ Artifact 5 holds in the build.
CSS variable resolution per theme (derived from build CSS + sources)
The active-frame border color is whatever applyColors() at line 377 last wrote into --theme-active-frame-border. That function reads ColorSettings.activeFrameBorder (color-settings.ts:62, 252, 374). For each shipping theme, the default resolution (no user override) is:
| Theme | Source | activeFrameBorder default |
|---|---|---|
| default-dark | palette index 3 | accent (warm gold-brown) |
| tokyo-night | palette index 3 | accent — historical row: tokyo-night was removed by the Color Ramp Restructure epic (#3555); only default-dark and default-light ship today |
| default-light | palette index 3 | accent |
(SEMANTIC_DEFAULTS.activeFrameBorder = 3, color-settings.ts:105.) This is what the comment at line 250–252 documents: "Active frame border: defaults to palette index 3 (accent) — same token as focusBorder, intentionally unified so both borders track the accent color."
So the default-resolution path is correct. The bug therefore lives on the user-override path: when a user has saved a non-accent value in color.activeFrameBorder it wins, and the chrome paints that color forever — even after the user changes their accent. The comment block at color-settings.ts:381-383 (left over from a previous debug pass) confirms the saved value is what applyColors() sees:
if (import.meta.env.DEV) {
console.log("[applyColors] activeFrameBorder ->", colors.activeFrameBorder);
}User's saved-settings audit
/ contains the smoking gun:
"color": {
...
"accent": "#AE8556",
...
"focusBorder": "#AE8556",
...
"activeFrameBorder": "#5CAAE9"
}The user's accent is the warm gold-brown #AE8556, but color.activeFrameBorder is independently saved as #5CAAE9 (blue) — the same blue the screenshot in issue #1512 §4 shows. This is the "raised hundreds of times" complaint: the saved overrides survive every new accent picker because no codepath ever invalidates them.
1.2 Root causes
Bug 1512 §2 / §4 — accent color drift. The activeFrameBorder field is persisted in AppSettings.color as a free-form hex string (packages/undefined. The user's prompts settings therefore continue to show blue forever because the literal #5CAAE9 is written into --theme-active-frame-border on every load.
Bug 1513 fix1 — nested suppression. Today every active leaf paints the outer 1 px box-shadow unconditionally. The leaf has no way to "opt out" when its own internals already provide a sufficient selection signal (the timeline card highlight, the table row selection, the kanban-card focus ring). :focus-within is the wrong tool because (a) the active-leaf signal is not DOM focus — it is the React useActiveFrame() state that survives blur, and (b) :focus-within would suppress the border in cases where the user just tabbed into a child input, which is the opposite of what the user wants. Suppression must be controlled by the provider declaring "my body is the highlight" — an opt-in attribute the chrome reads.
Bug 1513 fix4 — wrap-around cutout. The screenshot shows the active border missing on the right edge, terminating partway down. The chrome itself paints inset 0 0 0 1px on a single block-level container, so a cutout cannot come from the chrome — it must come from a child element overflowing the chrome. The most likely offender is a sticky / floating control (the inbox Archive button is rendered absolutely-positioned at the bottom-right of the editor pane in tauri-; DraftNumberBadge is positioned with absolute top-0 right-0 z-30 and bleeds beyond the chrome content slot when the chrome adds its 1 px shadow). The chrome's content slot at frame-chrome.tsx:410 is flex-1 min-h-0 overflow-hidden, so a child painted on top of the shadow's right edge will visually clip the inset shadow because the shadow is rendered behind the child's background-colored element. The screenshot in #1513 fix4 second image confirms this — the cutout aligns vertically with the floating Archive button.
1.3 Verdict + fix prescription for Sub 4
Sub 4 prescription — accent color (1512 §2 / §4)
VERDICT IMPLEMENTED (Sub 4, issue #1518) — ColorSettings.activeFrameBorder removed from packages/ (interface, SEMANTIC_DEFAULTS, SEMANTIC_CSS_NAMES, resolveSemanticColors(), schemaToColors(), colorKeyToCssVar). applyColors() now writes --theme-active-frame-border to colors.accent unconditionally on every call. A validateSettings() migration in packages/ deletes the stale color.activeFrameBorder key from any saved settings file (the user's prompts settings had #5CAAE9 overriding their #AE8556 accent). Guard tests: Row 12 in packages/ asserts applyColors writes the accent across all three shipping themes; the migration test in packages/ asserts the stale field is removed.
Sub 4 must drop color.activeFrameBorder from the persisted user-settings schema entirely. Remove the field from ColorSettings in packages/, from colorKeyToCssVar (line 374), and from the applyColors() write loop. Replace it with a runtime-only resolution: in applyColors(), after writing --theme-accent, write root.style.setProperty("--theme-active-frame-border", colors.accent) unconditionally, so the active-frame border is physically the same value as the accent on every load. Remove activeFrameBorder from SEMANTIC_DEFAULTS, SEMANTIC_CSS_NAMES, and resolveSemanticColors(). Update the colour settings UI section in packages/ so the field no longer renders. Per CLAUDE.md "Pre-Release: No Backward Compatibility", existing .zudotext.settings.json files keep their color.activeFrameBorder key — it is now silently ignored, no migration code is needed. Add a Vitest case in packages/ that asserts applyColors({...colors, accent: "#AE8556"}) results in document.documentElement.style.getPropertyValue("--theme-active-frame-border") === "#AE8556", and a second case that proves the previously-saved activeFrameBorder field has no effect on the resulting CSS variable.
Sub 4 prescription — nested suppression (1513 fix1)
VERDICT IMPLEMENTED (Sub 4, issue #1518) — opt-in data-suppress-frame-active-border="true" attribute added to the chrome contract. ActiveFrameOverlay in packages/ runs a useLayoutEffect after children mount that scans the chrome's descendants for the attribute; when found, it renders a hidden marker node with data-suppressed="true" instead of the visible overlay. Wired on the inbox timeline layouts in tauri- (both timeline-vertical and timeline-horizontal). Other surfaces (archives, kanban, mindmap, todo) can opt in independently — the mechanism is provider-agnostic. Guard tests: Row 13 asserts suppression triggers via descendant attribute, that non-"true" values do NOT suppress, and that toggling the attribute off restores the overlay.
Sub 4 must add an opt-in data-suppress-frame-active-border="true" attribute that providers set on the outermost element of their leaf body when their content already owns a sufficient selection signal. The chrome reads it in packages/ inside the normal/zoomed branch (line 320) by querying leafContentEl.querySelector('[data-suppress-frame-active-border="true"]') via a useLayoutEffect after children mount and gating the inline boxShadow style at line 333 on the absence of that match. Do NOT use :focus-within — the active-frame signal is React state, not DOM focus, so :focus-within would mis-fire on tab-into-child and miss on active-leaf-without-DOM-focus. The chrome must derive the suppression from the provider's declared attribute, not from focus state. Wire the attribute on the inbox timeline layouts (inbox-provider.tsx:543-552 — both timeline-vertical and timeline-horizontal branches), the archives list-detail / pile / grid / table container roots in tauri-, and the kanban / mindmap / todo board roots. Concrete leaves whose content is a single text editor (the text-editor inbox layout, the external-file-editor body) MUST NOT set the attribute — the user expects the active border there. The collapsed-strip and popped-out strip branches (lines 237 / 278) keep painting the active border unconditionally; suppression is normal-leaf-only.
Sub 4 prescription — wrap-around cutout (1513 fix4)
VERDICT IMPLEMENTED (Sub 4, issue #1518) — the chrome's outer wrapper (normal/zoomed branch) now uses position: relative with border border-edge and no inline boxShadow. A sibling <ActiveFrameOverlay> element with data-testid="frame-active-overlay", aria-hidden, classes pointer-events-none absolute inset-0, and inline box-shadow: inset 0 0 0 1px var(--color-active-frame) is rendered after the content slot when isActive && !suppressed. The overlay wins the painting order so floating absolutely-positioned children of the content slot (the inbox Archive button, DraftNumberBadge, …) can no longer clip the right edge of the inset shadow. The collapsed and popped-out strip branches retain the inline boxShadow on the chrome wrapper because those branches do not have floating-child bleed-through problems. Guard tests: Row 1, Row 3, Row 4, Row 7, Row 8, Row 9 migrated their chrome.style.boxShadow assertions to the overlay node; Row 14 asserts the overlay is the LAST child of the chrome and that a floating absolutely-positioned child does not inject a boxShadow on the chrome itself.
Sub 4 must change the chrome's content slot at packages/ to render the active-border boxShadow on a separate sibling overlay element that sits above the content slot with pointer-events: none; position: absolute; inset: 0. The current chain — inset 0 0 0 1px on the outer flex column whose overflow-hidden content slot then stacks floating children inside — lets any absolutely-positioned child (the inbox Archive button at inbox-provider.tsx:514-521, DraftNumberBadge etc.) paint over the inset shadow's right edge because both share the same stacking context. Reframe as: the outer <div> keeps border border-edge and position: relative; remove the boxShadow from the outer div's inline style; render a sibling <div data-testid="frame-active-overlay" aria-hidden="true"> after the content slot with position: absolute; inset: 0;
pointer-events: none; box-shadow: inset 0 0 0 1px var(--color-active-frame), and only when isActive and the suppression attribute is absent. Because the overlay is the last child, it wins the painting order without raising stacking contexts on the floating children, so the right edge stays continuous regardless of what the provider's body draws on top. Update packages/ to assert the overlay's box-shadow contains var(--color-active-frame) and that the outer wrapper's style.boxShadow is empty — the existing chrome.style.boxShadow assertions move from the outer node to the overlay node.
2. Scroll sync race in preview mode (1512 §2)
2.1 Reproduce
Two split inbox panes loaded on the same draft (activeDraft = 49). Click the link icon on the divider to enable scroll sync — the icon goes active. Toggle either pane to preview mode (Cmd+E). Scroll sync stops responding; the panes scroll independently again. Toggling back to edit mode restores sync.
2.2 Static evidence
The relevant publish effect is at tauri-:
useEffect(() => {
const payload: ScrollSyncPayload = {
key: `draft:${activeDraft}`,
getScrollDOM: () =>
viewMode === "edit"
? (editorViewRef.current?.scrollDOM ?? null)
: previewScrollDomRef.current,
};
ctxRef.current.publish<ScrollSyncPayload>(SCROLL_SYNC_CHANNEL, payload);
return () => {
ctxRef.current.publish(SCROLL_SYNC_CHANNEL, null);
};
}, [activeDraft, viewMode]);Note viewMode is in the dependency array. The cleanup function fires on every viewMode change and republishes null — clearing the leaf's publication on the bus.
The eligibility purge effect lives at tauri-:
useEffect(() => {
setEnabledMap((prev) => {
if (prev.size === 0) return prev;
const liveIds = new Set(rawPairs.map((p) => p.pairId));
let changed = false;
const next = new Map<string, boolean>();
for (const [pairId, enabled] of prev) {
if (liveIds.has(pairId)) {
next.set(pairId, enabled);
} else {
changed = true;
}
}
return changed ? next : prev;
});
}, [rawPairs]);A pair is "live" only when pubA && pubB are both present and have matching keys (line 191-192). If either leaf publishes null, the pair leaves liveIds, and the purge effect drops the user's enabled: true toggle from enabledMap — silently.
2.3 Root cause
The race is: leaf A toggles to preview mode → its useEffect cleanup fires and publishes null → React schedules a re-run of the same effect with the new viewMode → in between those two synchronous publishes, the bus subscriber in use-frameset-scroll-sync-pairs recomputes pairs (one missing publication), the purge effect fires because the pair is no longer eligible, and the enabledMap entry is deleted. When leaf A republishes 1 microtask later with the new viewMode, rawPairs recomputes back to eligible — but the toggle state is gone, so eligibility is restored off and the divider's ScrollSyncToggle flips back to disabled. The user never re-clicked the toggle, so they perceive sync as "lost on view-mode switch".
This is functionally identical to the legacy split-editor bug that needed a realignToken — the comment at the top of use-frameset-scroll-sync-pairs.ts (lines 7-18) explicitly claims the new architecture eliminates the realign-token by routing through ScrollSyncPayload.key, but only when the publish flow is single publish per leaf, no null transition. The current code violates that by publishing null then republishing on every dep change.
2.4 Fix prescription for Sub 5
Sub 5 must split the publish effect at tauri- into a single mount-only useEffect (deps [activeDraft] only — never viewMode), and capture viewMode through a ref. Replace the body with: keep an viewModeRef = useRef(viewMode); viewModeRef.current = viewMode updated on every render (no effect needed); the publish effect publishes once per activeDraft change with getScrollDOM: () => viewModeRef.current === "edit" ? (editorViewRef.current?.scrollDOM ?? null) : previewScrollDomRef.current. The cleanup remains publish(SCROLL_SYNC_CHANNEL, null) for unmount only. Result: toggling preview mode no longer republishes; the bus publication stays live with the same key, the pair stays eligible, and enabledMap keeps its toggle entry. Add a regression test in tauri- that mounts two inbox leaves with the same activeDraft, asserts publication exists for both, then re-renders one with viewMode: "preview" and asserts the publication on the bus has NOT fired a null between the two snapshots (use a vi.fn() proxy on ctx.publish). Because getScrollDOM now closes over a ref, scroll sync will continue to dispatch into the new live DOM (preview's markdown body) without ever transitioning the leaf out of the publication map.
3. Built-in frameset existence + reset migration
This section covers two bugs:
1512 §3 — Archives page lost. The user clicks the archives icon in the sidebar and lands on a "Directory View / External File Editor" layout instead of the archives list view.
User-feedback bug 10 — Search broken. The Search frameset is missing entirely or boots into the wrong tree.
3.1 Reproduce
Both reduce to the same shape: the user's persisted appSettings.framesets[] either omits a built-in frameset id (inbox / archives / search) entirely, or contains an entry with that id but a tree carrying a non-canonical provider. The screenshot in #1512 §3 shows archives resolved to a split with core.dir-view
core.external-file-editor— exactly what the user's
.zudotext.settings.json carries today (lines 396-431):
{
"id": "archives", "name": "Archives", "kind": "default",
"tree": {
"type": "split",
"first": { "providerId": "core.dir-view", ... },
"second": { "providerId": "core.external-file-editor", ... }
}
}This is a stale layout from a previous build that does NOT match the canonical archives default in packages/:
{
id: "archives", name: "Archives", kind: "default",
tree: {
type: "leaf", frameId: "archives-frame-list",
providerId: "core.archives-list-view",
...
},
...
}3.2 Static evidence
The canonical built-in framesets in packages/ define four entries:
inbox→ single leafcore.inboxarchives→ single leafcore.archives-list-viewsearch→ single leafcore.searchuntitled→ single empty-leaf
The user's settings has only three entries: inbox, archives (with the stale split tree), and untitled. The search frameset is missing entirely. The inbox entry's tree references frameId: "inbox-empty-1" while activeFrameId: "inbox-frame-editor" — the activeFrameId points at a frameId that does not exist in the tree, which is also a corruption symptom.
useSavedFramesets (tauri-normalizeLegacyLeaves which only retargets deprecated provider ids (core.draft-editor / core.markdown-preview → core.inbox, and core.terminal-pane / core.editor-pane / core.split-editor trees → fresh single-leaf inbox). It does NOT validate whether the canonical built-in framesets are present, and does NOT detect an archives entry whose tree references different providers than the canonical default.
3.3 Root cause
Algorithm bug (a) — stale built-ins. The user's persisted archives frameset was saved before sub-issue #1475 renamed the canonical archives layout from "dir-view + external-file-editor split" to "single-leaf core.archives-list-view". Today there is no migration pass that detects "this frameset has the canonical built-in id but the wrong tree shape" and resets it. The user's stale layout therefore loads forever.
Algorithm bug (b) — missing built-ins. When the user's settings file omits a built-in frameset id (the search case here), the default-merge in useSavedFramesets (lines 348-350) starts from defaultFramesets.map((f) => ({ ...f })) then overwrites with appSettings.framesets (line 378) — so any built-in id missing from the persisted list disappears entirely. The user can never reach the search frameset because it does not exist in the active list.
This is the Codex finding 2 the issue references: "any built-in frameset (id is inbox, archives, or search) whose tree lacks the canonical default provider gets its tree AND activeFrameId reset".
3.4 Fix prescription for Sub 6
Sub 6 must extend the migration in tauri- (normalizeLegacyLeaves) with two new passes that run AFTER the existing legacy-leaves migration:
Pass A — reset stale built-ins. For each canonical built-in id ("inbox", "archives", "search" — derive the set as new Set(defaultFramesets.filter(f => f.id !== "untitled").map(f => f.id)) so adding new built-ins automatically picks them up), if the user's frameset entry's tree does NOT contain a leaf with the canonical provider id (look it up from defaultFramesets — core.inbox for inbox, core.archives-list-view for archives, core.search for search), replace tree AND activeFrameId with a deep clone of the canonical default. Detect "lacks canonical provider" by walking the tree with forEachLeaf and checking leaf.type === "leaf" && leaf.providerId === canonicalProviderId; if no such leaf exists, reset. Surface the reset via onMigrationError(frameset, new Error(...)) with a message like Built-in frameset "<id>" reset to canonical layout so the host shows a non-blocking toast.
Pass B — add missing built-ins. For each canonical built-in id (same set as above) that is NOT present in the migrated framesets[], append a deep clone of the canonical default at the end of the list. Use the same onMigrationError toast pattern ("Built-in frameset 'search' restored"). Order is preserved by appending — do not re-sort the user's existing entries.
Both passes must use deep-cloned defaults (structuredClone is fine — the trees are pure JSON) so the user's entry shares no references with the constant array, and so future user edits cannot mutate the defaults. Add a Vitest case in tauri- that loads a settings shape matching the user's prompts file (archives = stale split, search missing) and asserts the migrated list has three canonical built-ins with the canonical leaf provider ids. Per CLAUDE.md "Pre-Release: No Backward Compatibility", this migration is a one-shot lossy reset of the built-in framesets only — user-saved custom framesets (kind: "user") remain untouched.
4. Frame-chrome consistency (1513 fix2 + fix3 + bug 11)
This section covers three closely-related contract violations:
1513 fix2 — layout switcher in chrome trailing cluster. The user wants the inbox layout dropdown ("Text editor / Timeline (vertical) / Timeline (horizontal)") moved from the inbox provider's own toolbar into the frame chrome's trailing cluster (next to the collapse / zoom / popout / close buttons), so every provider with a
layouts: []array gets the same control surface. Per user direction, scope INCLUDES archives (overrides Codex finding 1 which suggested keeping archives as-is).1513 fix3 — hide DraftBar in non-text-editor inbox layouts. When the inbox is in
timeline-verticalortimeline-horizontallayout, the DraftBar header is irrelevant — the timeline cards are the presentation. The DraftBar must hide.User-feedback bug 11 — archives
pile→timelinerename. The archives "Pile view" layout id has confusing semantics with the inboxtimelinelayouts. Rename for consistency.
4.1 Static evidence
The Frame Component Contract section 2e (doc/) prescribes: "providers that opt into layouts: [] MUST surface the selector via the shell-rendered dropdown in the trailing cluster, not in their own toolbar." Both inbox and archives currently violate this:
Inbox renders its own
<InboxLayoutSwitcher>attauri-inside the in-frame DraftBar.app/ renderer/ view- providers/ inbox- provider. tsx: 507 Archives renders its own
<LayoutSwitcher>inside<ArchivesHeader>attauri-.app/ renderer/ components/ archives/ archives- header. tsx
Both providers DO declare a layouts: [] array (inbox-provider.tsx:691-695, archives-list-view-provider.tsx:355-360), so the data is ready for the shell dropdown — only the wiring is missing.
The chrome's trailing cluster lives at packages/ (Collapse / Zoom / PopOut / Close buttons). There is currently no provision for layout selection.
The DraftBar visibility today: rendered unconditionally inside the inbox provider's toolbar branch when !draftBarCollapsed (inbox-provider.tsx:472) — the layoutId check happens only for isTextEditor (line 459) which gates the <ViewModeToggle> button, not the whole DraftBar.
The archives pile id today: declared in tauri- as type ArchivesLayoutType = "pile" | "grid" | "list-detail" | "table", in packages/ as the same union, and in tauri- as ARCHIVES_LAYOUT_IDS. The pile component lives at tauri- and supports direction: "horizontal" | "vertical" — it is a vertical/horizontal timeline of cards, isomorphic to the inbox timeline-vertical / timeline-horizontal layouts.
4.2 Verdicts (binding)
Rename target id
Sub 7 MUST rename the archives layout id from pile to timeline (not timeline-vertical). Rationale: the archives pile-view component already supports both vertical and horizontal sub-orientations via its own direction prop (pile-view.tsx) and the user's existing pile-view-direction keyboard shortcut. Splitting the rename into two ids would force a second persistence migration. Single id timeline, direction stays a per-layout pile-view sub-prop persisted via usePileViewSettings. Update label to "Timeline view" so the user-facing copy aligns with the inbox naming.
Chrome trailing-cluster ownership
The FrameChrome component owns the layout-switcher dropdown. The chrome reads provider.layouts from ProviderMeta, renders a <select> (or the same dropdown shape used today) before the Collapse button when provider.layouts.length > 1, and emits a generalized FRAME_LAYOUT_CHANGE_EVENT keyed by frameId + layoutId. Each provider listens for the event scoped to its own frameId and updates its persisted props.layoutId via the same path Sub 8 (#1495) already defined for the inbox.
4.3 Fix prescription for Sub 7
Sub 7 must extend ProviderMeta in packages/ with an optional readonly layouts?: ReadonlyArray<{ id: string; label: string; icon?: ReactNode }> field, and update toProviderMeta in tauri- to copy provider.layouts through (mapping each LayoutDef to the chrome shape, dropping the Component field). In FrameChromeProps, add currentLayoutId?: string and a new callback onRequestLayoutChange?: (frameId: FrameId, layoutId: string) => void. The chrome's normal/zoomed branch (frame-chrome.tsx:361) renders a layout dropdown before the Collapse button when provider.layouts && provider.layouts.length > 1. The chrome adapter wires onRequestLayoutChange to dispatch a new FRAME_LAYOUT_CHANGE_EVENT ("frame-layout-change", tauri-) with detail: { frameId, layoutId }. Resolve currentLayoutId from the leaf's props.layoutId (deserialize through provider.deserialize if needed). Inbox: replace the in-toolbar <InboxLayoutSwitcher> (inbox-provider.tsx:507) with nothing — delete that JSX. Replace the existing dispatch of INBOX_LAYOUT_CHANGE_EVENT in tauri- with a listener for FRAME_LAYOUT_CHANGE_EVENT filtered by providerId === "core.inbox". Archives: delete the <LayoutSwitcher> JSX in tauri-. Replace the useArchivesLayoutType(appSettings) writeback in tauri- with a FRAME_LAYOUT_CHANGE_EVENT listener (filter providerId === "core.archives-list-view") that calls handle.replaceProvider(frameId, { providerId, props: { layoutId: newId } }). Drop the archivesLayoutType field from AppSettings.layout (packages/CLAUDE.md "Pre-Release: No Backward Compatibility" no migration code is needed; existing settings keep the field as dead state. DraftBar visibility: in the inbox provider, gate the !draftBarCollapsed branch at inbox-provider.tsx:472 on layoutId === "text-editor" — when the user is on a timeline layout, the DraftBar header disappears entirely. Pile rename: replace every occurrence of "pile" with "timeline" in use-archives-layout-type.ts:5, archives-list-view-provider.tsx:71, archives-list-view-provider.tsx:78, archives-list-view-provider.tsx:357 (and the label string), tauri-, packages/, and the dead-state in defaults.ts. Update archivesLayoutType union to "timeline" | "grid" | "list-detail" | "table". Existing saved settings with pile are silently rejected by the deserialize fallback in archives-list-view-provider.tsx:366-370 (which already returns DEFAULT_ARCHIVES_LAYOUT_ID for unknown ids — no extra code needed).
5. core.empty ViewProvider design (1513 fix5)
5.1 Reproduce
In a saved frameset whose tree has an EmptyLeafNode, the leaf renders EmptyFrameNav ("What do you want to load here?") via FramesetEmptyLeafAdapter — but with no frame chrome header. The user's screenshot in #1513 fix5 shows the "Choose a view…" picker filling the leaf bare, missing the standard frame title bar with collapse / zoom / close buttons, while the adjacent inbox leaf has its full header. The user wants the empty leaf to look like every other leaf — same chrome at the top, just with "Empty" as the provider title.
5.2 Empty-leaf surface inventory (audit-only)
Today the empty-leaf concept threads through five places:
The
EmptyLeafNodetype —packages/:view- provider/ src/ types. ts: 455- 477 export interface EmptyLeafNode { readonly type: "empty-leaf"; readonly frameId: string; readonly state: FrameState; } export type FramesetTree = SplitNode | LeafNode | EmptyLeafNode; export type AnyLeaf = LeafNode | EmptyLeafNode;The
emptyRendererseam inLeafRenderer—packages/. Theframeset/ src/ leaf- renderer. tsx: 51- 72, 168- 171 <Frameset emptyRenderer={…}>prop bottoms out here.The empty-leaf branch in
FramesetChromeAdapter—tauri-. Whenapp/ renderer/ components/ frameset- chrome- adapter. tsx: 152- 174 !isLeaf(leaf)the adapter renders a bare<div>with the active-frame border treatment but no chrome header — this is exactly what the user is complaining about.FramesetEmptyLeafAdapter—tauri-(full file). Every page callsapp/ renderer/ components/ frameset- empty- leaf- adapter. tsx useEmptyLeafAdapter(registry, framesetCommandRef)and passes the result as<Frameset emptyRenderer={EmptyLeafAdapter} />.The frameset shell's empty-leaf logic —
packages/(collapsed/popped state gating), and theframeset/ src/ frameset. tsx: 152 defaultFramesetsuntitledentry inpackages/which usesapp- defaults/ src/ defaults. ts: 432- 444 type: "empty-leaf".
5.3 Design — core.empty ViewProvider
Per the user's direction in the in-issue follow-up comment (and strengthened during planning), the cleanest fix is to drop the empty-leaf node kind entirely and replace it with a regular LeafNode whose provider is a new built-in core.empty. The empty state then becomes "just another provider" — same chrome, same border, same collapse / zoom / close behaviour — and dozens of special-case branches collapse into one.
Provider shape (new file tauri-):
{
id: "core.empty",
title: "Empty",
description: "Pick a provider to load into this leaf.",
icon: <EmptyFrameIcon size="md" />, // new icon, plus stub in @takazudo/ui-components
defaultProps: {},
singletonScope: "none",
preferredOpenSplit: "right",
consumes: [],
canPopOut: false, // empty leaves never pop out
Toolbar: EmptyToolbar, // label-only — no controls
Content: EmptyContent, // EmptyFrameNav, identical JSX to today's adapter
serialize: () => ({}),
deserialize: () => ({}),
canClose: () => ({ ok: true }), // always allow close — no dirty state
}EmptyToolbar renders the contract-section-4a 28 px bar with just the icon and "Empty" text. EmptyContent renders the existing EmptyFrameNav JSX from tauri- (providers list, recents, "Load file…" + SpotlightPicker handling) verbatim.
Type-system change — drop EmptyLeafNode from FramesetTree:
// packages/view-provider/src/types.ts:471
export type FramesetTree = SplitNode | LeafNode;
// Drop AnyLeaf alias entirely; everywhere now uses LeafNode.Migration — any persisted leaf with type: "empty-leaf" becomes { type: "leaf", frameId, instanceId: <fresh>, providerId: "core.empty", props: {}, state } on load. Per CLAUDE.md "Pre-Release: No Backward Compatibility" this is a one-shot rewrite done in useSavedFramesets's normalization pass, not a data migration. The untitled default in packages/ switches to a regular leaf with the new provider id.
Adapter cleanup spec — FramesetEmptyLeafAdapter is deleted entirely. Every page that calls useEmptyLeafAdapter(...) drops that hook call and the <Frameset emptyRenderer={...}> prop. The emptyRenderer seam in LeafRenderer (leaf-renderer.tsx:51-72, 87-93, 168-171) is deleted; EmptyRenderer / EmptyLeafRendererProps types are removed. The empty-leaf branch in FramesetChromeAdapter (frameset-chrome-adapter.tsx:152-174) is deleted — the !isLeaf(leaf) early-return goes away because all leaves are now LeafNode. The frameset shell stops calling isAnyLeaf; every site that used AnyLeaf switches to LeafNode. The isLeaf predicate becomes a no-op type guard.
5.4 Fix prescription for Sub 8
Sub 8 must (1) create tauri- exporting emptyProvider exactly as specified above, with the EmptyContent component being a verbatim copy of the current FramesetEmptyLeafAdapter's body (providers list, recents, Spotlight flow). (2) Register emptyProvider in tauri- alongside terminalProvider and searchProvider. (3) In packages/, delete EmptyLeafNode, simplify FramesetTree to SplitNode | LeafNode, delete AnyLeaf, and update isAnyLeaf / isLeaf to single-leaf semantics. (4) In packages/, delete the EmptyRenderer, EmptyLeafRendererProps, the emptyRenderer prop, and the else branch at line 168-171 — LeafRenderer only handles LeafNode now. (5) In tauri-, delete the !isLeaf(leaf) branch entirely. (6) In tauri-'s normalization pass, walk the tree and rewrite every { type: "empty-leaf", frameId, state } to a regular leaf node with type: "leaf", the same frameId, a fresh instanceId derived from the frameId (e.g. frameId + "-inst-empty"), providerId: "core.empty", props: {}, and the original state preserved. (7) Delete tauri- file. (8) In packages/, update the untitled frameset's tree to use the new provider: { type: "leaf", frameId: "untitled-frame-empty", instanceId: "untitled-inst-empty", providerId: "core.empty", props: {}, state: "normal" }. (9) Drop every <Frameset emptyRenderer={…}> prop from every page. (10) Add a Vitest case in the adapter's test file asserting that an empty leaf in the canonical untitled frameset renders a data-testid="frame-chrome" element with data-state="normal" and a child data-testid="frame-toolbar" whose title is "Empty". The test must NOT find a bare-div empty rendering anymore. The chrome's existing collapse / zoom / popout / close buttons all light up automatically because the leaf is a regular LeafNode from the chrome's perspective.
6. Terminal panel deletion (1512 §1)
6.1 Reproduce
The user's screenshot at #1512 §1 shows the right side of the app displaying the legacy terminal panel — a dark column with a custom header ("~/Library/CloudStorage/Dropbox/ainotes/prompts" address-bar style, plus tab controls), distinct from every other frame in the frameset. The user's verdict: this panel is "old type, standalone panel" and must be retired. Splitting / multi-tab terminal is already covered by the frameset's core.terminal provider, which ships with Toolbar + Content chrome and full split / popout support. The legacy TerminalPanel is dead weight.
6.2 Inventory (audit-only)
Renderer-side surface
TerminalPanelJSX wrapper —tauri-. Theapp/ renderer/ app. tsx: 175- 247, 1675- 1700 TerminalPanelfunction renders a<div data-terminal-section>with the legacy<TerminalUtilBar>plus<MultiTabTerminalView>.TerminalUtilBar—tauri-(full file).app/ renderer/ components/ terminal- util- bar. tsx TerminalPane—tauri-(kept — used byapp/ renderer/ components/ terminal- pane. tsx core.terminalprovider).MultiTabTerminalView—tauri-.app/ renderer/ components/ multi- tab- terminal- view. tsx SplitTerminalView—tauri-.app/ renderer/ components/ split- terminal- view. tsx State holders in
app.tsx—terminalPosition,terminalVisible,terminalSize,terminalMounted, theuseTerminalTabs()state and allterm*callbacks (app.tsx:714-816).
Renderer settings shape
AppSettings.layout.terminalPosition—packages/, defaultapp- defaults/ src/ types. ts: 434 "right"in defaults.ts:183, validated in validate-settings.ts:426-432.AppSettings.layout.terminalVisible— types.ts:435, defaults.ts:184, validate-settings.ts:429-432 (per Codex finding 3).AppSettings.layout.terminalSize— types.ts:436, defaults.ts:185, validate-settings.ts:433-438 (per Codex finding 3).AppSettings.shortcuts.terminalNewTab— types.ts:284, defaults.ts:117, default"Mod+T"(legacy panel only — the frame chrome covers tabs viacore.terminal).AppSettings.shortcuts.terminalPrevTab— types.ts:285, defaults.ts:118.AppSettings.shortcuts.terminalNextTab— types.ts:286, defaults.ts:119.
Rust-side parity (per Codex finding 3)
Rust mirrors of the renderer settings shape live in two places:
tauri-— theapp/ core/ defaults/ default- settings. json terminalblock (fontSize, fontFamily, lineHeight, cursorStyle, shell) is kept (used bycore.terminalprovider). The Rust-side mirror does NOT carrylayout.terminalPosition/terminalVisible/terminalSizetoday — confirmed byhead -150of the file. So Rust-side parity for the deleted fields is a no-op.tauri-— validatesapp/ core/ src/ generator/ validate. rs: 153- 170 layout.terminalPosition(line 153-160) andlayout.terminalSize(line 170). These ARE present and MUST be removed. Lines 291-310 also have terminal-related test cases that referencelayout.terminalPosition; theterminal_cursor_style_enum_enforcedtest at line 291-296 stays (validatesterminal.cursorStyle, not the deleted fields).
Toolbar / menu / palette wiring
Toolbar icon —
terminal-toggleintauri-(icon hidden viaapp/ renderer/ components/ toolbar. tsx: 163- 169 onToggleTerminalVisiblenot being wired).menuBar.rightIcons—terminal-toggleentry inpackages/(app- defaults/ src/ defaults. ts: 324 MENU_BAR_RIGHT_ICON_IDS). User's saved settings at.zudotext.settings.json:317-319has{ id: "terminal-toggle", visible: true }— still wired into the menu-bar preference UI.Native menu —
tauri-. Search shows the file does NOT carry a "Show Terminal" item (line 74 comment is unrelated). No native menu hook for the legacy panel.app/ src/ native/ menu. rs Command palette entries that target the legacy panel only —
tauri-adds palette entries forapp/ renderer/ data/ app- commands. ts: 349- 356 terminal-split-right/terminal-split-down/terminal-close-pane/terminal-focus-next/terminal-focus-prev/terminal-new-tab/terminal-prev-tab/terminal-next-tab. The bindings at app.tsx:1558-1575 dispatch into the legacy panel viaTERMINAL_SPLIT_PANE_EVENT/TERMINAL_CLOSE_PANE_EVENTetc.Test selectors —
data-terminal-section(app.tsx:220) is only attached to the legacy panel. Any e2e selector that targets it must move to[data-frameset-provider="core.terminal"]on the frame chrome's content slot.
core.terminal is a full replacement
Confirmed by tauri- (full file): Toolbar and Content are both defined (contract section 2b — new shape), singletonScope: "none" (so each leaf gets its own PTY), preferredOpenSplit: "bottom", canPopOut: true, OPEN_PROVIDER_TERMINAL_EVENT is the palette path that opens (or focuses existing) terminal in the active frameset (app-commands.ts:42, 145, 312, 468). Splits go through ctx.requestSplit("right") (terminal-provider.tsx:82-90) and the chrome owns the close / popout buttons. Multiple PTYs per terminal work today by the user splitting the terminal frame. The user can also save a layout that has terminal in any position — frameset splits cover the use case the legacy terminalPosition setting served.
6.3 Fix prescription for Sub 9
Sub 9 must execute the deletion as a single coherent change:
Renderer: Delete tauri-, multi-tab-terminal-view.tsx, split-terminal-view.tsx (full files). Keep terminal-pane.tsx unchanged — it is consumed by core.terminal. In tauri-, delete the TerminalPanel function (lines 175-247), all terminal* state declarations (714-816), the useTerminalTabs import + state, all term* callbacks, the terminalPanel JSX block (1675-1700), the divider rendering at line 1797, the onToggleTerminalVisible prop on <Toolbar> (line 1854), and the {terminalPanel} site at line 1949. Keep caps.terminal capability checks and isTerminalUnavailable flag for the core.terminal provider's mobile/iOS gating. Delete the TerminalPanel-specific bindings at app.tsx:1516-1567 (layoutResetSplit site, terminalSplitRight, terminalSplitDown, terminalClosePane, terminalFocusNext, terminalFocusPrev); they all dispatch into the deleted panel. Keep the bindings that route to core.terminal (e.g. openProviderTerminal at line 1541 — dispatches OPEN_PROVIDER_TERMINAL_EVENT, picked up by the active frameset's terminal-provider opener). Reroute the keyboard shortcuts: terminalNewTab / terminalPrevTab / terminalNextTab are panel-specific and have no frame-equivalent today — drop the bindings at app.tsx:1567 and surrounding and remove the shortcut keys from packages/, defaults.ts:117-119, validate-settings.ts (the validator's shortcut block).
Renderer settings shape: Drop AppSettings.layout.terminalPosition / terminalVisible / terminalSize from packages/, drop the matching defaults in defaults.ts:183-185, drop the validation block in validate-settings.ts:426-438. Drop the AppSettings. keys above. Drop terminal-toggle from MENU_BAR_RIGHT_ICON_IDS in defaults.ts:324. Drop the terminal-toggle case in tauri-. The user's settings file keeps the dead keys — per CLAUDE.md "Pre-Release: No Backward Compatibility" no migration code is needed.
Rust-side parity (Codex finding 3): In tauri-, delete lines 153-160 (terminalPosition enum check) and line 170 (terminalSize range check). Delete the test case at lines 301-310 (layout_terminal_position_enum_enforced). The terminal_cursor_style_enum_enforced test at 291-296 stays — it covers terminal.cursorStyle which core.terminal reads. tauri- does not carry the deleted fields (verified) — no edit needed.
Test selectors: Replace any e2e or component test that uses [data-terminal-section] with [data-frameset-provider="core.terminal"] — but since the legacy panel is deleted, most occurrences are simply removed. Search the repo for data-terminal-section and TerminalPanel and confirm zero refs remain after the deletion.
Command palette entries: Drop the palette entries terminal-split-right, terminal-split-down, terminal-close-pane, terminal-focus-next, terminal-focus-prev, terminal-new-tab, terminal-prev-tab, terminal-next-tab from app-commands.ts:349-356 and the matching callbacks in the CommandCallbacks interface (app-commands.ts:173-180). Keep open-provider:terminal at line 312 — it is the canonical entry for opening a terminal frame in the active frameset.
After Sub 9, core.terminal provider is the single source of terminal UI. Saved framesets that use core.terminal continue to work; saved settings that referenced the deleted layout fields silently become dead state (the validator strips unknown keys — packages/).
7. Frame components docs design (user-feedback bug 12)
7.1 Audience and scope
End user. The user has 11 providers available (10 today plus the new core.empty after Sub 8) and no end-user-facing reference for what each one does or when to pick it. Today the only documentation lives inline in provider.description strings and the architecture doc frame-component-contract.mdx, which is implementer-facing.
7.2 Provider list (after Sub 8 lands)
Sub 12 must produce a documentation page that covers all 11 providers in this exact order:
| # | Provider id | Title | Source file |
|---|---|---|---|
| 1 | core.inbox | Inbox | tauri- |
| 2 | core.archives-list-view | Archives | archives-list-view-provider.tsx |
| 3 | core.search | Search | search-provider.tsx |
| 4 | core.terminal | Terminal | terminal-provider.tsx |
| 5 | core.dir-view | Directory View | directory-view-provider.tsx |
| 6 | core.external-file-editor | External File Editor | external-file-editor-provider.tsx |
| 8 | core.kanban-board | Kanban Board | kanban-board-provider.tsx |
| 9 | core.mindmap-board | Mind Map | mindmap-board-provider.tsx |
| 10 | core.todo-board | TODO Board | todo-board-provider.tsx |
| 11 | core.empty | Empty | new (Sub 8) |
7.3 Per-provider doc shape (binding)
For each provider, the doc page renders one section with this exact ordered structure:
Heading —
## ProviderTitle (id: core.xxx).Pull-quote description — copied verbatim from
provider.descriptionso the doc stays in sync with the picker.When to use — three to five bullet points written from the user's task perspective. E.g. for
core.search: "When you want to find a draft you wrote last month but can't remember the filename", "When you want to grep across all archived messages", etc. Sub 12 writes these — they are not in the codebase.Layouts (only when
provider.layouts.length > 1) — bullet list of layout ids and one-line "what each looks like" labels, sourced from the provider'sLayoutDef.labelstrings.Pop-out / persistence notes — one line stating
canPopOut: true | falseandsingletonScope. Sub 12 translates to user English ("Cannot be popped out into a separate window" forfalse; "One per Space" for"frameset"; "One per app" for"app"; "Multiple OK" for"none").(Optional) screenshot — one small screenshot showing the provider in its default layout, hosted in
doc/(kebab-case the provider id minus thesrc/ content/ docs/ architecture/ img/ frame- components/ <id>. png core.prefix). Skipped when the visual adds no information beyond the description.
7.4 Page intro design
The page front-matter:
---
title: Frame Components Reference
description: >
Every provider you can load into a Space — what each one shows,
when to use it, and what the layout switcher does.
---Below the front-matter, a one-paragraph intro explaining that a "frame component" is the swappable UI inside any leaf of a Space, and that the picker shown in an empty leaf (the core.empty provider's content surface) is the canonical entry point. Cross-link to architecture/ for the architecture-level explanation of Spaces and to architecture/ for the implementer-facing contract. Add a navigation hint: every Space's chrome dropdown lets the user swap the active provider via the toolbar's leading title.
7.5 Empty-leaf picker screenshot capture protocol
Sub 12 must capture the screenshot of core.empty in action by:
Generate a fresh app instance via
pnpm generate <name> /tmp/<name> --skip-scaffold.Open the app, switch to the
UntitledSpace (which is now acore.emptyleaf after Sub 8).Capture the full leaf area at the project's standard 1.5
displayScalematching the user's actual setting.Save as
doc/.src/ content/ docs/ architecture/ img/ frame- components/ empty. png
The screenshot is the single most informative artefact in the page because it shows the user the provider picker entry point that drives every other provider load.
7.6 Cross-link plan
The new page must:
Add a
<Link>toarchitecture/near the top of the intro (one sentence: "If you are building a new provider rather than choosing one, see the Frame Component Contract").frame- component- contract. mdx Receive backlinks from
architecture/(in its "Providers" section) and fromframeset. mdx guide/(one sentence noting that the manager's generated text apps share the same provider catalogue).manager- app. mdx Be added to the architecture sidebar order in
doc/aftersrc/ content/ docs/ architecture/ index. mdx frame-component-contract, beforeframeset.
7.7 Fix prescription for Sub 12
Sub 12 must create doc/ following the exact page intro design at §7.4 above; render one provider section per row of the table at §7.2 in that order; per provider, write the five-or-six-block content from §7.3 (heading, pull-quote, "When to use" bullets sub-12-authored, layouts list when applicable, pop-out / persistence notes, optional screenshot); capture and commit doc/ per §7.5; insert the cross-links from §7.6; verify via pnpm --filter @takazudo/doc check. The page is end-user documentation — implementer details (singletonScope: "none" raw string, acceptsFileDrop flag, etc.) MUST be translated to plain English first. The technical contract details stay exclusively in frame-component-contract.mdx. After Sub 12 lands the user has one canonical answer to "which frame component should I pick?" instead of having to read each provider's description string in the picker dropdown.
Cross-cutting notes
No backward compatibility for any of the seven sections. Per the project's
CLAUDE.md"Pre-Release: No Backward Compatibility" policy, every persistence change above (dropcolor.activeFrameBorder, droplayout.terminalPosition/terminalVisible/terminalSize, rewriteempty-leaftocore.emptyleaf, rename archivespile→timeline, droparchivesLayoutType) ships as a single coherent breaking change. Old.zudotext.settings.jsonfiles keep their dead keys — the validator silently strips unknowns and the new validator never reads them.Test plan, per sub. Each sub adds Vitest cases against the exact files mentioned in the prescription. The doc-rendering and e2e validation lives in Sub 4 (#1518) — not in this audit doc.
Branch / PR plumbing. Every sub PR targets
base/bug-fix-1512-1513; the epic branch targetsbase/frameset-arch. Subs are independent in code (different files, no merge conflicts) so they can land in any order, but the doc-site smoke (Sub 4 #1518) waits for Sub 1's merge.