Compare commits

..
3 Commits
Author SHA1 Message Date
rzen b1ea97c03e Locality means the board the drag was picked up from — a rename is not a departure
Minor ruled into 04 ▸ Cross-board copy: the Finder volume model — a
mid-drag rename of the source board carries the live drag with it (the
drag holds its source store's identity, never a frozen key, which would
let a board opened at the vacated path compare equal — the worse,
destructive failure). Until the carry ships the residue is recorded:
key follows folder, a mid-drag rename finishes a reorder as a copy —
cosmetic, seconds-bounded. Implementation filed.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 21:02:47 -04:00
rzen 1980570b27 The popover's two post-split postures named — the explanatory row and the mode-none door
Minors ruled into 03 ▸ the git-at-a-glance section: a single-branch
board's switch picker opens onto a disabled "No other branches" row —
teaching why it is empty and where creation went — and the Pro
mode-none section stays as header plus the Board Settings… door rather
than vanishing, the git story keeping its named place.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 21:02:47 -04:00
rzen b1aa0c4483 The parse memo's blind spot blessed on file — reachable, pinned, and still outside the app's care
Minor ruled into 02 ▸ the memoize ruling: a stamp-preserving rewrite is
a decision on file rather than a surprise in the field — git lives with
the same blind spot, the behavior is test-pinned, and the tightenings
(hashing, generation stamps) wait for a real-world defeat.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 21:02:47 -04:00
3 changed files with 3 additions and 3 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ The **one named exception** is transient UI state rendering things that don't ex
- **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.
- **Duplicate-id detection heals silently** (re-ruled 2026-07-29, superseding the repairable-condition banner): the loader's board-wide dedupe (01-storage-format.md ▸ Fractal layout rules) withholds losing occurrences from every snapshot; a scheduled heal remints them through the Writer (the loader itself never writes) and a warning-tone notice reports the repair — no banner, no button, nothing waits on consent. The withheld window is one heal cycle, not a standing condition; a remint racing a vanished duplicate (repaired elsewhere, a hand-deleted copy) is a no-op, never an error.
- **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal — and, blessed 2026-07-31, the store **skips the assignment entirely** when the fresh snapshot equals the current one: assigning an equal tree into an `@Observable` property still costs a render pass, so "costs nothing visible" becomes *costs nothing*), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **The skip's structural consequence — the two-counter split (blessed 2026-08-06):** once value-equal landings stop bumping the applied-snapshot generation, anything whose subject is the *walk* rather than the applied snapshot can no longer key on it. The store therefore carries two counters — snapshots **applied** (the committed-overlay hold's event, meaning unchanged) and walks **landed** with a snapshot in hand, bumped on every successful reload, equal or not; a failed reload bumps neither. The rule for choosing: **anything outside the snapshot, or about the walk itself, watches landed walks, not applied snapshots** — the card window's comment thread (comments are outside the snapshot, so a foreign comment arriving leaves the model value-equal), the comment search index (same shape), and the auto-committer's covering gate (what covers a flush is a completed walk, whether or not it found anything to show); on the applied counter each would sleep through exactly the value-equal landing it exists to notice. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry). **The walk memoizes its parse, never its result** (blessed 2026-07-31 — a performance posture, not a semantic change): the loader may reuse the previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged — the previous snapshot *is* the memo — while directory enumeration (folder discovery, attachment listings, trash entries) stays fresh every walk, because attachment changes never touch `index.md`. The loader's contract is result-purity with cost unspecified: same tree in, same snapshot out, and the memo can only change how fast. The mtime+size trust is the git-index heuristic; a writer that defeats it — content changed, mtime and size both preserved — is outside the app's care.
- **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal — and, blessed 2026-07-31, the store **skips the assignment entirely** when the fresh snapshot equals the current one: assigning an equal tree into an `@Observable` property still costs a render pass, so "costs nothing visible" becomes *costs nothing*), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **The skip's structural consequence — the two-counter split (blessed 2026-08-06):** once value-equal landings stop bumping the applied-snapshot generation, anything whose subject is the *walk* rather than the applied snapshot can no longer key on it. The store therefore carries two counters — snapshots **applied** (the committed-overlay hold's event, meaning unchanged) and walks **landed** with a snapshot in hand, bumped on every successful reload, equal or not; a failed reload bumps neither. The rule for choosing: **anything outside the snapshot, or about the walk itself, watches landed walks, not applied snapshots** — the card window's comment thread (comments are outside the snapshot, so a foreign comment arriving leaves the model value-equal), the comment search index (same shape), and the auto-committer's covering gate (what covers a flush is a completed walk, whether or not it found anything to show); on the applied counter each would sleep through exactly the value-equal landing it exists to notice. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry). **The walk memoizes its parse, never its result** (blessed 2026-07-31 — a performance posture, not a semantic change): the loader may reuse the previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged — the previous snapshot *is* the memo — while directory enumeration (folder discovery, attachment listings, trash entries) stays fresh every walk, because attachment changes never touch `index.md`. The loader's contract is result-purity with cost unspecified: same tree in, same snapshot out, and the memo can only change how fast. The mtime+size trust is the git-index heuristic; a writer that defeats it — content changed, mtime and size both preserved — is outside the app's care (blessed 2026-08-06 as a decision on file, the boundary being reachable in practice — a byte-length-preserving edit plus deliberate utimes, a restore tool replaying old attributes — and pinned by test: git itself lives with the same blind spot, and the named tightenings — content hashing, which is the read the memo exists to avoid, or fileSystemFileNumber/generation stamps — wait for a real-world defeat, not a hypothetical one).
- **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 container crossing is a vanish for this purpose** (resettled 2026-07-28 — the materialized trash): re-resolution matches UUID *and* container side (board vs `.trash/`), so a foreign move that trashes a selected board card — or restores a selected trash card — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's container-boundary invariant true across reloads. The old effective-liveness ancestor walk is retired with the tombstone model — presence in the snapshot is the whole question. 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 or moved to the trash (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 rule arms on every bracket exit, thrown operations included** (settled): an operation that fails or aborts mid-flight is precisely when the tree's state is least known, so the mandatory final reload runs regardless — succeeding, it renders whatever the operation left (often value-equal after a clean failure, whose tree is left as it was — 06-history-undo.md); failing, it locks exactly as above. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and restore, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles (these disable in place as controls — 05-card-window.md's in-content rule) — and the board refuses drops, including drags arriving from another board's window. **The lock is a predicate every mutating entry point consults, not a menu-validation sweep** (settled): mutating paths without a menu item exist beyond the checkboxes — the attachment row's ⌫/Remove (grammar key + context menu only, 11-command-nexus.md ▸ Context menus), plain-⌫ delete, Return-creation — and each disables with its surface or follows its command twin's validation; menu validation is the lock's most visible face, never its whole mechanism. **File ▸ Duplicate and File ▸ Save as Template join the disabled set** (settled): both copy the on-disk tree, which the lock marks as gone (vanished root), unknown (this failed-reload state), or unwritable — and both are specified to run the close flush first (03-board-ui.md, 09-templates.md), which the lock's suspended saves make impossible to honor. **One carve-out: under the unwritable-location lock alone (below), Save as Template stays live** — it reads the board and writes into Application Support, the copy-out-is-a-read principle applied (archiving the read-only DMG board being inspected is a legitimate errand). **The carve-out gates on the hazard itself, open sessions, not on lock provenance** (settled): the item disables while any open Edit or raw-source session holds unsaved content — content the lock's suspended saves cannot flush, which the template would silently miss (09-templates.md's never-misses-keystrokes guarantee outranks availability) — and re-enables when those sessions settle or the lock clears. A lock standing since open never meets this state: it disables the flip into Edit mode, so no session can start beneath it and the gate is vacuously open; unsaved sessions under this lock exist only when the symmetric probe (▸ Write-failure surfacing) raised it mid-session. Duplicate stays disabled even there — its destination is the same unwritable parent (a writable-parent/unwritable-board permission split was weighed and set aside as too rare to earn the inconsistency). Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it).
+1 -1
View File
@@ -57,7 +57,7 @@ The window-title widget opens the **board popover** — the one board-level surf
- **Board rename** (settled: this function stays in-app, unlike the pathfinder which dropped it with the inspector). Rename edits the board's frontmatter `title` only — the folder is never renamed by the app; the Finder document name is Finder's to change (01-storage-format.md's board-naming rule). A foreign rename landing while the popover is open resyncs the field from the snapshot only while the field is unfocused — a focused field keeps the user's keystrokes, the dirty-buffer courtesy applied here (settled).
- **Board styling** — the embedded style editor (Styling ▸ Controls above).
- **Git at a glance** — Pro tier surface, mode-aware, *display and daily operations only* (re-ruled 2026-07-31 — setup moved to the board settings sheet below; in the free tier this section is absent on ordinary boards and reduces to the contextual one-line Pro pointer on boards carrying an inert `.git` — 12-editions.md): the posture lines (repo-nested explanation, unreadable-repo and paused states — 06), branch display with the **switch picker**, and on remote-backed boards remote tracking (ahead/behind) with **Pull/Push** controls and the status badges (Authentication needed, queued pushes, last error — the badge points at the sheet, capture happens there). A **Board Settings…** row opens the sheet — the popover's one setup affordance.
- **Git at a glance** — Pro tier surface, mode-aware, *display and daily operations only* (re-ruled 2026-07-31 — setup moved to the board settings sheet below; in the free tier this section is absent on ordinary boards and reduces to the contextual one-line Pro pointer on boards carrying an inert `.git` — 12-editions.md): the posture lines (repo-nested explanation, unreadable-repo and paused states — 06), branch display with the **switch picker**, and on remote-backed boards remote tracking (ahead/behind) with **Pull/Push** controls and the status badges (Authentication needed, queued pushes, last error — the badge points at the sheet, capture happens there). **A single-branch board's picker opens onto a disabled explanatory row** (ruled 2026-08-06): with creation relocated to the sheet, the menu holds only the *other* local branches, and an empty menu reads as broken — a disabled "No other branches" row teaches both why the menu is empty and where creation went. A **Board Settings…** row opens the sheet — the popover's one setup affordance. **The Pro mode-none posture is header plus that door** (blessed 2026-08-06): with no repository the section stays — "Git" and the Board Settings… row alone — rather than vanishing, so the board's git story keeps its named place in the popover and the door stands exactly where a user looking for git will look.
## Board settings sheet
+1 -1
View File
@@ -18,7 +18,7 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
- **Multi-drag**: dragging any member of a multi-selection drags the whole selection; N contiguous shadows; drop inserts contiguously in preserved relative order — defined, for any multi-selection, as lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom).
- **Locality picks the default — the Finder volume model** (settled): within a board a drag is a **move** (rearranging); between boards it is a **copy** (transferring — the system copy badge shows over the foreign board). **⌥ always forces copy** and **⌘ always forces move**, Finder's exact modifier grammar; each is a no-op where its behavior is already the default. The badge tracks the effective operation live as the cursor crosses a board boundary.
- **Within-board ⌥-drag copies**: originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop. Lane drags never copy *within their board* — a within-board lane duplicate is **not available by drag** (⌥ is simply ignored there: the drag stays a clean reorder and the badge never shows copy); the duplicate itself is supported, via the clipboard (Lane paste below) — the usual shape: the keyboard path is the canonical one, drag the enhancement (10-accessibility.md).
- **Cross-board copy** (the default): cards and lanes (including multi-selections) drag between open boards; fresh-GUID duplicates land at the drop, originals stay, `created` is kept (a copy is a fork — 01-storage-format.md). Lanes copy cards and all — transferring workflow structure between boards is safe by default. A lane carries exactly its cards — the trash is board-level (`.trash/` — 03-board-ui.md), so there is nothing lane-nested to strip or carry: copy and ⌘-drag move alike transfer the lane's folder as it is (resettled 2026-07-28; the old tombstone-stripping rule is retired with the tombstone model).
- **Cross-board copy** (the default): cards and lanes (including multi-selections) drag between open boards; fresh-GUID duplicates land at the drop, originals stay, `created` is kept (a copy is a fork — 01-storage-format.md). **Locality means the board the drag was picked up from** (ruled 2026-08-06): a drag knows where it came from — the Finder volume model — so a rename or Finder move of the source board absorbed mid-drag is not a departure: the relocation carries the live drag with it (the drag holds its source *store's* identity, not a frozen copy of its key — a frozen key would let a new board opened at the vacated path compare equal to the renamed-away one, turning a copy into a silent cross-board move, the worse failure), and a within-board reorder stays a reorder across a mid-drag rename. Until the carry ships, the recorded residue — the minted key follows the folder, so a mid-drag rename finishes a reorder as a copy — is cosmetic and bounded by the seconds a drag is in flight. Lanes copy cards and all — transferring workflow structure between boards is safe by default. A lane carries exactly its cards — the trash is board-level (`.trash/` — 03-board-ui.md), so there is nothing lane-nested to strip or carry: copy and ⌘-drag move alike transfer the lane's folder as it is (resettled 2026-07-28; the old tombstone-stripping rule is retired with the tombstone model).
- **Cross-board move** (⌘-drag): a real filesystem move, works across volumes — identity travels. A moved folder whose UUID already exists in the destination board arrives as a fresh-UUID copy (01-storage-format.md's import-boundary rule); in a compound move (lane with cards, multi-selection) only the colliding folders are reminted — the rest is a true move (01's per-folder degradation).
- **Files from Finder**: dropped on a card → copied into its `attachments/` (any *file* type, multi-file; card highlights while hovered). Dropped on lane empty space → creates a card with the file attached, titled with the filename without its extension (multi-file drop: one card per file). **Folders are refused at hover** (settled — the attachment model is flat top-level files, and the importer refuses directories by design): a drag containing only folders never engages — no highlight, no drop proposal, the standard incompatible-payload read; a mixed drag proposes for its files only, and the drop imports the files while a loss row (02-architecture.md's warning tone) names the skipped folders ("Folders can't be attached — 2 skipped"). The create path thereby only ever fires with at least one importable file — no card is minted for an import that cannot succeed. **Created cards land at the drop position** (settled): resolved through the same card-grid zones an ordinary card drag uses, shadow included — drops are positional everywhere, and append-at-bottom stays the creation *trio's* rule, not the drop's. A multi-file drop shows **one nominal-height shadow per incoming file** (the multi-drag precedent; when macOS withholds item counts during hover the count floors at one shadow, the commit unaffected). **A release on the lane header resolves to the topmost position** (settled — forgiving beats a dead stripe: the header's chrome roles don't collide with a file payload). **The landing shadow is the create path's whole feedback** (settled): no lane-level highlight on top — each target gets one clear signal, and the card-attach highlight exists precisely because that target has no shadow.
- **A foreign reload mid-drag re-grounds the drag, never corrupts the drop** (settled — a two-second drag racing agent edits is the designed concurrency). Three rules compose: (1) **geometry re-derives** — the frozen-at-drag-start inputs are the *dragged items'* sizes and the physical pointer only (03-board-ui.md ▸ Motion); the analytic resting zones recompute against each new snapshot, so a foreign lane-count re-divide mid-drag just moves the zones and the next proposal targets the board as it now is. (2) **Proposals re-validate by liveness** — a proposal whose target lane vanished in the reload is invalidated; the shadow withdraws and no proposal stands until the pointer reaches a live target, and **release with no valid proposal cancels** — items return, nothing is written; a card is never filed under a vanished parent. (3) **An emptied drag cancels itself** — drag membership is already a UUID set that vanished items leave silently (02-architecture.md); when the *last* dragged item vanishes the replica dissolves and release is a no-op. Partial vanishing drops the survivors, matching the pending-cut precedent. **A cross-board lane arrival pre-divides the destination strip during hover** (settled): while a foreign lane drag proposes into a board, the destination's standard width is computed with the arriving run's units included, so the shadow draws at the width the lane will actually take — without this it overflows the strip (the pathfinder's stripWidthUnits). The first entry samples the un-widened standard for one frame before hysteresis settles — accepted, imperceptible.