Resolved on the Redesign board: raw-source Apply preserves a typed modified-by stamp; FolderWatcher is FSEvents-only best-effort with no iCloud/network fallback; read-only lock scope enumerated; matching touch-ups in board-ui, card-window, accessibility docs. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
90 lines
14 KiB
Markdown
90 lines
14 KiB
Markdown
# App Architecture
|
|
|
|
## Platform
|
|
|
|
- macOS 26+, Swift 6 (strict concurrency), SwiftUI.
|
|
- Codename `Kanban` (target, scheme, bundle id `dev.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 `BoardModel` snapshot. 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 `@Observable` object 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. 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, the search filter's result set) — 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. 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.lock` contention** (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/`.
|
|
|
|
## 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.
|
|
- **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 `UserDefaults` where 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-board` launch 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).
|