Storage: UUID-shape gates level detection; readable-but-uneditable frontmatter shapes load and refuse writes; duplicate keys last-wins; inline-comment re-splice; null reads as missing. Architecture: the banner surface specified (origin-window host, dismiss vs heal, precedence stack, tones incl. in-progress rows and safe-copy Cancel); launch/window lifecycle (restoration preference, welcome policy, row-level restore failures); bracketed operations lock writes at the failed-reload lock's scope. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
18 KiB
App Architecture
Platform
- macOS 26+, Swift 6 (strict concurrency), SwiftUI.
- Codename
Kanban(target, scheme, bundle iddev.rzen.indie.Kanban); display name Lanework. - XcodeGen project (
project.yml→xcodegen generate). - Sandboxed; security-scoped bookmarks for reopening boards across launches.
Layering
filesystem (source of truth)
│ FSEvents
▼
Loader ──validates──▶ BoardModel (value types)
▼
BoardStore (one per open board, @Observable, MainActor)
▼
SwiftUI views (board window + card windows share the store)
One-way flow: files → watcher → loader → store → views. User actions go through a Writer that mutates files; the change comes back around through the watcher like any external edit. The app trusts its own writes no more than anyone else's — this is what makes external editors and agents first-class.
The one named exception is transient UI state rendering things that don't exist on disk — concretely the new-card placeholder (04-interactions.md): the inline editor for a card being created renders as a pseudo-card overlaid on the snapshot, with no disk presence and no UUID until the title commits. Commit creates the folder through the Writer and round-trips through the watcher like any write — the placeholder stays visible until the real card arrives, then hands off. Abandoning (Escape, empty commit, click-away) discards it; disk was never touched. Watcher reloads swap the snapshot underneath the overlay (like selection surviving a reload); if the placeholder's lane vanished in the reload, it is discarded — consistent with card windows dismissing when their card is deleted. Everything durable still round-trips through files.
Components
- Frontmatter — YAML value model: parse, serialize, atomic write, unknown-key preservation with key order. Owns the byte-identical round-trip guarantee. Pure, heavily unit-tested.
- BoardLoader — walks the folder tree, applies the fail-fast/skip rules, produces an immutable
BoardModelsnapshot. Pure function of the tree. - BoardWriter — every mutation (create, move, reorder, tombstone, style) as an explicit filesystem operation. No hidden state; a write is done when the file is on disk.
- BoardStore — per-board
@Observableobject holding the current snapshot plus transient UI state that must be shared across that board's windows (selection, drag state, search query, pending cut, the new-card placeholder, trash visibility). Debounces watcher reloads. - BoardStoreRegistry — refcounted registry so a board window and its card windows share one live store and one watcher. The board window owns the board (settled): card windows never outlive it — closing the board window closes its card windows too, so the last-window teardown and board-window close coincide. (The refcount still earns its keep ordering teardown while multiple windows close.)
- FolderWatcher — FSEvents (debounced), attached best-effort to whatever path the board lives at. There is only this one watching path: no NSMetadataQuery for iCloud Drive, no polling fallback for network volumes — on those warned-against locations (07-sync-collab.md) FSEvents delivery is unreliable and live reload silently degrades, accepted per 07's no-accommodations stance.
- Ranks — gapped fractional ordering math + compaction. Pure.
- DropSlot — drop-geometry math: hit zones and insertion-position targeting for drags (lane/position within the masonry, cross-board, Finder file drops). Pure, like Ranks.
- AgentGuide — writes/upgrades the board-root
CLAUDE.md(see 08-agent-integration.md). - HistoryStore — git plumbing for undo/redo (see 06-history-undo.md).
Live-reload resilience
- A failed reload never replaces a good snapshot. Fail-fast (01-storage-format.md) is the initial-load contract, where there is nothing to fall back on. Once a board is open, a watcher-triggered reload that fails (unparseable YAML, missing required fields — typically a non-atomic external write caught mid-flight) keeps the last good snapshot on screen and raises a non-modal banner carrying fail-fast's specifics (offending path + what's wrong). The watcher keeps watching; the next successful reload clears the banner automatically — transient breakage self-heals without the user losing the board, persistent breakage stays loudly visible. Editing is not locked out: writes go through the Writer as usual (the breakage is per-file and localized), and the reload debounce already absorbs most momentary invalid states before they surface.
- App-initiated git churn is bracketed. Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. The bracket also locks writes (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted.
- Selection survives reloads by UUID. Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. A liveness flip is a vanish for this purpose: re-resolution matches UUID and liveness side, so a foreign edit that tombstones a selected live card — or restores a selected tombstoned one — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's homogeneous-by-liveness invariant true across reloads. The search filter is deliberately absent from that list: the query string is transient state, but its result set is derived — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map).
- A failed reload after a bracketed operation locks the board read-only — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree wholesale: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. The lock's scope (shared with the vanished-root case below): every mutating command disables via menu validation — creation, delete and Put Back, paste, Move/Style/rename, trash operations, the popover's git controls — and the board refuses drops, including drags arriving from another board's window; selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it).
Write-failure surfacing
The read-side rules above have a write-side mirror — one banner vocabulary for both directions:
- The one-way flow makes write failures honest by construction. Views render only what is on disk, so a failed Writer operation (disk full, permissions, volume error) never shows phantom state — the action visibly doesn't happen. The failure surfaces in the same non-modal banner as read-side breakage, naming the operation and the cause ("Couldn't move 'Fix login' — disk full"). One-shot actions (move, tombstone, style, create) fail once and wait for the user to act again; nothing is queued behind their back.
- The debounced body save retries on its own cadence — keystrokes stay in the dirty buffer, so nothing is lost while the window stays open; the banner stands until a save lands. The one modal moment on the write-failure path: closing a window (or the board, or quitting) with a dirty buffer that cannot be written — the only state that exists nowhere but memory — raises an alert (retry / save a copy elsewhere / discard) instead of failing silently. Everything else on this path stays non-modal. (Deliberate confirmations elsewhere are their own stories: Empty Trash… and Delete Immediately on boards without git history — 03-board-ui.md, the branch-switch save-or-discard step — 06-history-undo.md, machine-key regeneration — 07-sync-collab.md, the raw-source Apply validation alert — 05-card-window.md, the once-per-board iCloud/network-volume warning on open/create — 07, and the SSH trust-on-first-use fingerprint confirmation with its mismatch hard-block — 07.)
- A vanished board root locks the board read-only — the bracketed-reload vocabulary applied to a root that is gone (volume unmounted, folder Finder-deleted while open): every write would land nowhere, so the last-good snapshot stays on screen, read-only, banner up. The watcher keeps watching; if the root returns (remount, Finder undo), the next successful reload clears the lock and pending dirty buffers save normally.
- Auto-commit failures beyond
index.lockcontention (06-history-undo.md covers the lock) — disk full mid-commit, repo corruption: the files are safely on disk but history stops advancing, which quietly suspends the undo trail and the flush-before-overwrite guarantee. That degradation is surfaced, not hidden: the banner states that changes aren't being recorded to history; the committer retries on the next debounce and the banner clears on the first successful commit. - Attachment import copy failures (source unreadable, destination full): the drop was accepted — "never refuses the drop" (01-storage-format.md ▸ Attachments) is policy, not an I/O guarantee — so a failed copy surfaces in the banner with the filename, and any partial file is removed; no half-copied attachment is ever left in
attachments/.
The banner surface (settled)
The non-modal banner named throughout the read- and write-side rules above is one UI component, specified here:
- Hosted by the window of origin. Every window hosts a banner strip; a condition surfaces in the window whose action produced it — debounced body save, attachment drop, and raw-source Apply failures in their card window; reload breakage, one-shot write failures, commit failures, and lock states in the board window. A card window that closes while its condition persists re-homes the banner to the board window (the condition is still true; it must stay visible somewhere).
- One-shots dismiss, conditions heal. One-shot failures ("Couldn't move 'Fix login' — disk full") carry an explicit dismiss control and no timeout — an error never evaporates unread. Persistent conditions (reload breakage, suspended auto-commit, read-only locks) have no dismiss: they describe ongoing state, standing until the next success clears them, per the rules above.
- Concurrent conditions stack. The strip presents independent rows, precedence-ordered: read-only lock > reload breakage > one-shot write failures > commit and attachment failures; newest first within a class. Each row heals or dismisses independently; beyond three rows the remainder collapse behind a "+N more" disclosure.
- Tones, not components. The banner has kinds — error, warning, info — sharing layout and the accessibility announcement path (10-accessibility.md). The card window's remote-change signpost (07-sync-collab.md) is this same component in the info tone: visually calm, no error color.
- In-progress operations are info rows (settled): bracketed git operations ("Pulling…", "Switching to 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment imports) each show an info-tone row with a spinner — determinate where progress is knowable. Completion clears the row (the VoiceOver completion announcement of 10-accessibility.md rides the same event); failure swaps it for the error row. Sighted and VoiceOver users learn one vocabulary.
- Cancel appears on safe copies only (settled): copy-shaped work — attachment imports, Duplicate, template instantiation — carries Cancel, meaning "remove the partial copy, nothing lost". Git brackets get no Cancel: seconds long, and aborting a rebase mid-flight is a repair job, not a cancel.
Windows
- Welcome window — Xcode-style: branding + actions left, recents right (board icon, name, location, lane/card counts, sorted by last opened).
- Board windows — one per board root; multiple boards open at once; per-board frame memory (repositioned onto a live screen if the saved one is gone).
- Card windows —
WindowGroup(for: CardWindowRef.self); at most one per card (reopen focuses); follows its card across lanes; dismisses itself if the card is deleted.
Launch and window lifecycle (settled)
-
Restoration is a preference — "Restore open boards at launch", default on. On: boards open at last quit reopen (bookmark-resolved), with their per-board frames and 05's card-window restoration. Off: every launch starts at welcome.
-
Welcome appears only when nothing restores — restoration off, nothing was open, or every restoration failed. Always reachable via Window ▸ Welcome to Lanework. Opening a board from welcome closes welcome.
-
Closing the last board window leaves the app windowless (menu bar alive) — the close is respected. Reactivation (Dock click) with no windows shows welcome.
-
A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome appears alongside whatever did restore, the failed board's recents row carrying fail-fast's specifics (load error) or the unavailable state per Graceful orphaning (offline volume, dead bookmark). Other restorations proceed unaffected — never a launch-time modal chain, never a silent drop.
-
Close flushes: closing a board window (and app quit) first closes the board's card windows — each open Edit session ends with its normal session commit (06-history-undo.md's granularity) — then flushes pending debounced work, editor saves before the pending auto-commit, before the store tears down. Nothing about this is conditional: a card window cannot exist without its board window (the ownership rule above), so the close flush is always the whole story.
Per-board app state
State that belongs to the app, not the user's files — the recents list, per-board window frames, the push-on-commit setting and the once-per-board iCloud warning flag (07-sync-collab.md), and whatever accumulates later — lives in a board registry in Application Support: one record per known board, anchored by the security-scoped bookmark the sandboxed app keeps anyway for reopening boards.
- Keyed by file identity, never by path. Bookmarks track renames and moves on the same volume; an opened URL is matched to its record by bookmark resolution / file identity, so a moved board keeps its settings. The recents list is this registry sorted by last-opened.
- Recents counts are registry-cached. The lane/card counts in the welcome window come from the record, stamped at last close — no directory scan at welcome time (which would be slow or hang on big/unavailable boards). Staleness until the next open is accepted. Records that can't be counted show without counts: unavailable boards per Graceful orphaning below; a board that fails to load just fails on open, fail-fast — the welcome row doesn't pre-detect it.
- Files-first stays absolute: nothing app-private is ever written into the board folder — no frontmatter keys, no sidecar files, no xattrs. Two machines sharing a board via a remote each keep their own record (push-on-commit and window frames are genuinely per-machine choices).
- Graceful orphaning: a record whose bookmark no longer resolves (board deleted, or moved across volumes where bookmarks can't follow) is orphaned — recents surface it as unavailable with Forget; its settings are conveniences and die with it (accepted).
- App-wide state has the same home. Not everything app-side is board-scoped: quick-style recents (03-board-ui.md), the SSH host-key assignment table and TOFU fingerprint store (07-sync-collab.md — host-scoped), the last-used card-window size (05-card-window.md), and peers live beside the registry in Application Support (or
UserDefaultswhere a scalar fits) — no per-board record involved. Secrets are the named exception: Keychain only, never here (07).
Caching and search
The old app loaded boards fast enough that the planned SwiftData cache was never built. Position for the rewrite: same discipline — the loader reads files directly; any cache introduced later must be rebuildable from files at any time and populated only by watcher events (never written by the UI path). Cross-board search is the feature that would force the cache into existence; until it ships, no cache.
Testing
- Unit: Frontmatter round-trip (including hostile YAML), Ranks, DropSlot zone math, Loader fixtures (valid, malformed, interrupted-create).
- UI: XCUITests over fixture boards via a debug-only
--open-boardlaunch hook (inline rename, new-card focus, drag cleanup — the flows that regress).
Changes from Kanban
- The store's transient-state grab-bag (selection, drag, search) gets an explicit home rather than accreting — exact shape TBD during implementation planning.
Open questions
None currently — the iCloud watching question dissolved with the decision not to support iCloud Drive boards (07-sync-collab.md).