Header Pins Architecture
Decision record for the header-pins feature (Epic #1542, Wave 4). Read Frame Component Contract for the provider and layout shape that pins build on.
Header pins are toolbar shortcuts that activate a specific frameset at a specific layout. This document records the four key design decisions: data flow, routing trade-off, referential-integrity policy, and the two metadata-only registries.
1. Data flow
AppSettings.headerLeftPins[]
│
▼
resolvePinActivation() (per-pin: { status, route, template: PinTemplate })
│
├──► toolbar render (icons in the left header cluster)
├──► frameset host (/p/:slug route — generic host page for user pins)
└──► command palette (one palette entry per visible pin) resolvePinActivation (tauri-) is the single source of truth for what activating a pin means at runtime. All three downstream consumers import it instead of re-implementing the routing/layout logic themselves. It returns { status, route, template: PinTemplate } — no frameset id lookup is needed because each pin carries its complete layout template inline.
AppSettings.headerLeftPins is persisted in .zudotext.settings.json. The validator (packages/) runs on every load and write; it preserves unknown fields defensively rather than stripping them.
2. Routing trade-off
Built-in pins (Dashboard, Inbox, Archives) keep their canonical routes:
| Frameset id | Route |
|---|---|
dashboard | / |
inbox | / |
archives | / |
User-created pins use / — a generic frameset-host route backed by tauri-. The route slug is an opaque random string generated at pin-creation time and never shown in the UI.
Why separate routes for built-ins?
Built-in framesets must survive pin removal. A user may remove the Inbox pin from the toolbar without losing the Inbox frameset — it still backs the / route. Giving built-ins their own canonical routes decouples "the view exists" from "the toolbar icon is visible". User pins have no canonical route of their own, so / is sufficient and cheaper to maintain.
Why not a single / for everything?
The command palette, keyboard shortcuts, and any future URL-sharing feature all benefit from stable, predictable routes for the built-in views. Changing / to / for Inbox would break all existing shortcut bindings and external references.
3. Referential-integrity policy
Each pin stores a template: PinTemplate — a self-contained FramesetTree snapshot — rather than a framesetId reference. This eliminates the category of "dangling frameset id" staleness: the pin's structural layout travels with it and is not a pointer into a separate collection.
A pin can still become unresolvable if its template references a providerId that is no longer registered (e.g. a provider was removed). The policy for that case is:
| Layer | Responsibility |
|---|---|
Validator (validateSettings) | Preserves unknown / unresolvable pin entries. It does NOT delete data it cannot resolve — it leaves the decision to the renderer. |
Renderer (resolvePinActivation) | Filters at render time. A pin whose provider cannot be resolved is classified as missing-target and hidden from the toolbar. |
Dialog (HeaderPinsDialog) | Surfaces missing-target rows to the user as non-draggable, remove-only rows. The user decides whether to clean them up. |
This split means settings data is never silently destroyed by a validator running at load time, while the UI still presents only resolvable pins in the toolbar.
4. Two metadata-only registries — tech debt note
Two separate ProviderRegistry instances are built for metadata-only access (title, icon, layouts[] introspection, no renderLeaf calls):
| Name | Location | Primary consumer |
|---|---|---|
createPickerRegistry() | tauri- | HeaderPinsDialog "Add pin" flow — lists providers with layouts[] for the user to pick from |
buildStaticRegistry() | tauri- | Command-palette command generation — checks provider presence and reads layouts[] without rendering |
Both registries register all providers with a no-op or null draftStore (the inbox provider's store is only accessed during rendering, not during registry.get()). They exist because the real write-time registry is constructed inside write-page.tsx with a live SplitDraftStoreForView, which is not available at dialog-open time or command-palette-build time.
Known tech debt: these two registries are functionally identical and should be consolidated into a single createMetadataRegistry() factory. The split is an artefact of the order in which the picker UI (Epic #1542) and the command palette wiring (same epic) were developed. Consolidation is deferred to a follow-up.
See also
Frame Component Contract — provider shape,
layouts[]definition, persisted leaf schemaFrameset Architecture — core frameset types and tree invariants
Header Pins guide — user-facing reference for the same feature