zudo-text

検索したい単語を入力

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

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-app/renderer/lib/pin-activation.ts) 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/app-defaults/src/validate-settings.ts) 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 idRoute
dashboard/dashboard
inbox/
archives/archives

User-created pins use /p/:slug — a generic frameset-host route backed by tauri-app/renderer/pages/frameset-host-page.tsx. 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 /p/:slug is sufficient and cheaper to maintain.

Why not a single /p/:slug 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 /p/<some-slug> 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:

LayerResponsibility
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):

NameLocationPrimary consumer
createPickerRegistry()tauri-app/renderer/lib/picker-registry.tsHeaderPinsDialog "Add pin" flow — lists providers with layouts[] for the user to pick from
buildStaticRegistry()tauri-app/renderer/view-providers/index.tsCommand-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