Pivot the trash to a materialized .trash — cards only, no Put Back

The tombstone model is retired wholesale: deletion is a move into the reserved
<root>/.trash/, always entering at the top (rank minting — no deleted: key, no
timestamp sort), with modified stamped for the future age-based purge. Lanes
are never trashed — lane delete is physical, native undo in-session the net.
Put Back is gone: restore is drag-out or cut+paste (⌘X now enabled in the
trash as the keyboard restore path). Trash cards are ordinary cards — search,
selection, clipboard, and rendering uniform — and one container boundary
replaces the effective-liveness, ancestor-walk, and kind-homogeneity machinery
across 02 and 04. Delete stages by place (board moves, trash deletes
permanently under the recoverability confirm); legacy deleted: keys migrate
without destroying (cards relocate, lanes return live, notices). Templates
exclude .trash; Duplicate carries it; the agent guide teaches delete-as-move.
Materialized reserved lanes are named as a pattern — .archive is WISHLIST #8,
and the backed-out release-presentation question is marked open in 03.

Ruling: Redesign board Resolved card e8f1a6c2; 19 mooted tombstone-era rulings
moved to the board's new Mooted lane.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-28 16:02:08 -04:00
parent 4cf5f09d93
commit 16c10d61c3
14 changed files with 79 additions and 76 deletions
+12 -10
View File
@@ -27,7 +27,7 @@ MyBoard.kanban/ ← board = the document
- **Level is position.** Root = board, depth 1 = lane, depth 2 = card. No `type` field.
- **Folder names are lowercase UUIDv4, immutable, never renamed.** The folder name is the primary key; titles live in frontmatter only. Collisions are impossible by construction. **The identity predicate is shape-only — lowercase v4 is the emission rule, not the gate** (settled): a folder counts as an identity iff its name is 8-4-4-4-12 hexadecimal (36 chars, hyphens in place) in **any case and any UUID version** — effectively "does `UUID(uuidString:)` parse it". The lenient gate exists for the headline use case: macOS's `uuidgen` and Swift's `UUID().uuidString` both print *uppercase*, and a strict gate would make an agent's standard-tool card a silently skipped stray — the worst failure for a files-first app; the version nibble protects no invariant (an agent's v7 is as unique as a v4). **Identity comparison is UUID-value equality, never string equality** — two case-spellings of one UUID are one identity everywhere (the import-boundary collision check included), matching default-APFS case-insensitivity, where they are one folder anyway; on a case-sensitive volume two case-spelled siblings are one identity with two folders, and **the loader dedupes them deterministically** (settled — a snapshot must never carry two items with equal ids): the canonical all-lowercase spelling wins where present, else the lexicographically first spelling; the loser takes the stray posture — skipped with a pointed log line, preserved verbatim, never rendered. (Content-distinct duplicates — a hand-copied folder keeping its UUID — are deduped by the earlier-occurrence-wins rule below.) The *app* still writes only lowercase v4 (`UUID().uuidString` explicitly lowercased), never renames an existing folder to canonicalize, and the agent guide keeps teaching lowercase — accept liberally, emit conservatively.
- **Identity lifecycle: moves keep the UUID, copies mint fresh ones.** Every move (between lanes, between boards, cut+paste) is a physical folder move — identity travels with it. Every copy (⌘C/⌘V, ⌥-drag duplicate, the cross-board drag default — 04-interactions.md's locality model — and template instantiation) mints fresh UUIDs for every folder it materializes; a copy keeps `created` (it's a fork) but is a new identity. **Whole-board copies are the carve-out**: Save as Template (09-templates.md) and File ▸ Duplicate (03-board-ui.md) copy the board folder wholesale, **GUIDs kept** — each flow states its own exclusions (Save as Template drops `.git` and tombstoned items, 09; Duplicate carries both and strips only the remote configuration, 03) — the remint rule governs *item-level* copies landing inside an existing board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it describes). Template GUIDs are inert anyway — instantiation remints at its own boundary. **Exception: template instantiation stamps `created`/`modified` fresh** — a board created from a template is born today, not forked from it (09-templates.md); Save as Template, by contrast, is an ordinary fork and keeps timestamps (inert anyway — instantiation restamps them). The app never produces a duplicate UUID: **within-board uniqueness is enforced at the import boundary** — a move-in (a cross-board ⌘-drag move or a cut+paste — 04-interactions.md) whose UUID already exists anywhere in the destination board is degraded to a copy (fresh UUID minted, content intact; the source is removed as usual for a move). **Degradation is per-folder, at the finest grain**: in a compound arrival (a lane with its cards, a multi-card drag or paste), only the colliding folders are reminted — everything else keeps its identity and remains a true move, and source removal is uniform regardless (the whole selection leaves the source, as for any move). A lane drag with one colliding nested card thus stays a lane *move*; just that card arrives reminted. One gesture silently yielding moved originals plus reminted copies is the intended behavior, not an accident — the collision is an identity repair, invisible in content terms. Boards are **independent identity namespaces**: the same UUID may legitimately exist in two boards (e.g. after a cross-board move-out undone at the source — 06-history-undo.md); such forks are tolerated and only ever meet at an import boundary, where the collision is neutralized. A user hand-copying a card folder in Finder can still produce duplicate ids — in the same lane or another. **Duplicate ids within a board are never tolerated** (settled): the loader keeps exactly one occurrence per id, board-wide — the case-twin dedupe generalized to content-distinct duplicates. **The occurrence that entered the board earlier wins**: on git boards, the path history already tracks outranks the newcomer (both tracked: the path that entered history first); without history, the older folder (filesystem birth date) wins, with deterministic traversal order (lane `order`, then card `order`, then the folder-name tie-break) the last resort when dates tie or are unreadable. Every later occurrence is withheld from rendering — preserved verbatim, pointed log line — so the snapshot invariant (one item per id) holds by construction and nothing is lost on disk; but unlike a true stray it is **surfaced, not silent** (settled): the board window raises a warning-tone condition banner naming the duplicate ("Duplicate id: 'Fix login' exists twice"; several fold into one row with a count) and offering **Repair** — copy semantics applied at detection: the hand copy was a copy, so Repair gives each withheld occurrence the fresh identity the import boundary would have minted, reminting its folder to a fresh lowercase v4 through the Writer (a rename — content, frontmatter, children untouched), after which it renders as an ordinary item. The loader itself still never writes — detection is read-only, the remint runs under user intent and commits normally ("Repair duplicate of 'Fix login'" — 06-history-undo.md's vocabulary gains the verb) — and the banner follows the condition lifecycle (02-architecture.md ▸ The banner surface): it heals when the duplicates are gone, via Repair or externally (another device repaired first; the copy was hand-deleted), and Repair racing a vanished duplicate is a no-op heal, never an error. Case-spelled twins of one identity (above) stay silent strays — they are spelling artifacts of the same item, not copies, and reminting one would *create* duplicate content the user never made.
- **Identity lifecycle: moves keep the UUID, copies mint fresh ones.** Every move (between lanes, between boards, cut+paste) is a physical folder move — identity travels with it. Every copy (⌘C/⌘V, ⌥-drag duplicate, the cross-board drag default — 04-interactions.md's locality model — and template instantiation) mints fresh UUIDs for every folder it materializes; a copy keeps `created` (it's a fork) but is a new identity. **Whole-board copies are the carve-out**: Save as Template (09-templates.md) and File ▸ Duplicate (03-board-ui.md) copy the board folder wholesale, **GUIDs kept** — each flow states its own exclusions (Save as Template drops `.git` and `.trash/`, 09; Duplicate carries both and strips only the remote configuration, 03) — the remint rule governs *item-level* copies landing inside an existing board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it describes). Template GUIDs are inert anyway — instantiation remints at its own boundary. **Exception: template instantiation stamps `created`/`modified` fresh** — a board created from a template is born today, not forked from it (09-templates.md); Save as Template, by contrast, is an ordinary fork and keeps timestamps (inert anyway — instantiation restamps them). The app never produces a duplicate UUID: **within-board uniqueness is enforced at the import boundary** — a move-in (a cross-board ⌘-drag move or a cut+paste — 04-interactions.md) whose UUID already exists anywhere in the destination board is degraded to a copy (fresh UUID minted, content intact; the source is removed as usual for a move). **Degradation is per-folder, at the finest grain**: in a compound arrival (a lane with its cards, a multi-card drag or paste), only the colliding folders are reminted — everything else keeps its identity and remains a true move, and source removal is uniform regardless (the whole selection leaves the source, as for any move). A lane drag with one colliding nested card thus stays a lane *move*; just that card arrives reminted. One gesture silently yielding moved originals plus reminted copies is the intended behavior, not an accident — the collision is an identity repair, invisible in content terms. Boards are **independent identity namespaces**: the same UUID may legitimately exist in two boards (e.g. after a cross-board move-out undone at the source — 06-history-undo.md); such forks are tolerated and only ever meet at an import boundary, where the collision is neutralized. A user hand-copying a card folder in Finder can still produce duplicate ids — in the same lane or another. **Duplicate ids within a board are never tolerated** (settled): the loader keeps exactly one occurrence per id, board-wide — the case-twin dedupe generalized to content-distinct duplicates. **The occurrence that entered the board earlier wins**: on git boards, the path history already tracks outranks the newcomer (both tracked: the path that entered history first); without history, the older folder (filesystem birth date) wins, with deterministic traversal order (lane `order`, then card `order`, then the folder-name tie-break) the last resort when dates tie or are unreadable. Every later occurrence is withheld from rendering — preserved verbatim, pointed log line — so the snapshot invariant (one item per id) holds by construction and nothing is lost on disk; but unlike a true stray it is **surfaced, not silent** (settled): the board window raises a warning-tone condition banner naming the duplicate ("Duplicate id: 'Fix login' exists twice"; several fold into one row with a count) and offering **Repair** — copy semantics applied at detection: the hand copy was a copy, so Repair gives each withheld occurrence the fresh identity the import boundary would have minted, reminting its folder to a fresh lowercase v4 through the Writer (a rename — content, frontmatter, children untouched), after which it renders as an ordinary item. The loader itself still never writes — detection is read-only, the remint runs under user intent and commits normally ("Repair duplicate of 'Fix login'" — 06-history-undo.md's vocabulary gains the verb) — and the banner follows the condition lifecycle (02-architecture.md ▸ The banner surface): it heals when the duplicates are gone, via Repair or externally (another device repaired first; the copy was hand-deleted), and Repair racing a vanished duplicate is a no-op heal, never an error. Case-spelled twins of one identity (above) stay silent strays — they are spelling artifacts of the same item, not copies, and reminting one would *create* duplicate content the user never made.
- `index.md` = YAML frontmatter between `---` delimiters, then Markdown body.
- **Reserved child names** at card level: `attachments/` (specified below) and `comments/` (enhanced schema — see below). Never levels themselves, never identity (identity-bearing siblings are UUIDs).
- **Name shape gates level detection** (settled): only folders passing the identity predicate (the shape-only rule above — 8-4-4-4-12 hex, any case, any version) are lane/card candidates. Anything else — even with an `index.md` inside — is a stray: skipped with a log warning, preserved verbatim, never rendered. A hand-made `notes/` folder or a board nested inside another can never brick a load; the cost — a hand-authored lane named `todo/` doesn't render — is what the warning and the agent guide exist for.
@@ -49,7 +49,6 @@ MyBoard.kanban/ ← board = the document
| `created` | ISO-8601 | no | Set at creation, with timezone |
| `modified` | ISO-8601 | no | Updated on every app write **that rewrites this `index.md`** — see below |
| `modified-by` | string | no | Self-reported writer identity, set by external writers only; the app clears it on every write — see below |
| `deleted` | ISO-8601 | no | Tombstone — see Deletion |
| `background` | string | no | Palette name or `#RRGGBB[AA]` — see 03-board-ui.md |
| `icon` | string | no | SF Symbol name, per-level defaults |
| `iconColor` | string | no | Palette name or hex |
@@ -85,7 +84,7 @@ Body: lane description / WIP policy / notes.
Body: the card's content — the whole point.
Schema-owned display fields are Lanework's to interpret — **coerce where a sensible reading exists, fall back to the field's default where none does** (settled). A scalar of the wrong YAML type reads as its source text (`title: 2048` displays as "2048", `width: "2"` reads as 2); where no sensible reading exists — a sequence or mapping where a scalar belongs, a non-integer width, an unparseable timestamp — the field falls back to its default: untitled placeholder, width 1, no color, no icon. **Ranges are part of the sensible reading** (settled): an integer `width` below 1 reads as 1 — the table's ≥ 1 is a validity bound, and 03-board-ui.md's width division must never see a zero or negative unit — while a non-finite `order` (`.nan`, `.inf`) is the same loud malformed-input rejection as a non-numeric one: fail-fast's `order` guard covers unusable values, not merely non-numeric ones (NaN has no place in the total order the tie-break and midpoint math assume). The hostile-YAML suite gains both shapes (`width: 0`, `order: .nan`). Coercion is read-side only; the bytes on disk are **preserved verbatim, never rewritten**. One tombstone nuance: any *present* `deleted:` key tombstones the item — an unusable timestamp still deletes, its date merely unknown (the user's intent to delete outranks the broken date). **Duplicate keys: last one wins** (settled — the coercing read; strict YAML would reject the file, so this is a deliberate divergence in the editor's favor): a *top-level* key appearing twice reads as its last occurrence, earlier occurrences preserved verbatim on disk and invisible. A duplicate inside a nested mapping value remains a YAML error — the rescue applies where the editor's slip actually happens, the top level. Last-wins resolves *before* strictness: a duplicated `order` reads as its final occurrence and only that value faces the malformed-order check — duplication itself is never a fail-fast. An app write of a duplicated key rewrites the winning (last) occurrence and removes the earlier ones — the app owns the keys it writes, and leaving a stale twin would resurrect it if the winner were later removed; removing a key removes all its occurrences. **App rewrites preserve comments** (settled): comments on their own lines always survive a rewrite; an inline comment on a rewritten value line is re-spliced after the new value — best-effort, guaranteed for plain scalar lines (the realistic case), dropped only in pathological shapes. **Unquoted colons in a value read as the string they meant** (ratified — the third member of the rescue family, same posture as last-wins and coercion): the classic slip `title: Fix: the thing` — invalid YAML ("mapping values are not allowed in this context") — recovers when, and only when, the scanner error's mark lands on a line that *starts a top-level key* and whose remainder is a **plain unquoted scalar**: everything after the first `: ` reads as the value. A remainder opening with a quote or flow character (`"`, `'`, `[`, `{`) is a different breakage and stays fail-fast; nested/indented shapes never match the guard and stay errors. Mechanism mirrors the duplicate-key rescue: compose retries on a modified copy (the offending value swapped for a same-line placeholder), so one recovered line leaves every other line's diagnostics intact — a second, genuinely broken line still fails loudly with its own error. Read-side only, bytes preserved verbatim; the span editor keys by the line's key prefix, so the file stays fully editable, and the first app write of that key emits the value properly quoted — the slip heals on first touch. Recovery is **silent with a log line** (the family's posture: the recovered value is exactly what the writer meant, so there is nothing actionable to surface). The agent guide keeps teaching quoting — the rescue is a net, not a license. Test obligation: the hostile-YAML suite gains the `key: value: more` shape (none covers it today). Fail-fast remains reserved for structure (`schema`, `order`, YAML validity) — and it covers *malformed*, not just missing: an `order` that is present but non-numeric is the same loud malformed-input rejection as a missing one. **Readable-but-uneditable shapes load; writes to them refuse** (settled): frontmatter the surgical editor can't key by spans — a whole-frontmatter flow mapping, non-scalar keys, or frontmatter whose YAML resolves to an *empty* mapping while still carrying text (`{}`, `null`, `~` between the delimiters: zero keys load fine, but appending a key after that text would produce unparseable YAML; truly blank or comment-only frontmatter stays editable) — reads and renders normally, and any app write to that file fails loudly through the per-file write-failure banner (02-architecture.md ▸ Write-failure surfacing) naming the shape, never a silent corruption and never a load rejection.
Schema-owned display fields are Lanework's to interpret — **coerce where a sensible reading exists, fall back to the field's default where none does** (settled). A scalar of the wrong YAML type reads as its source text (`title: 2048` displays as "2048", `width: "2"` reads as 2); where no sensible reading exists — a sequence or mapping where a scalar belongs, a non-integer width, an unparseable timestamp — the field falls back to its default: untitled placeholder, width 1, no color, no icon. **Ranges are part of the sensible reading** (settled): an integer `width` below 1 reads as 1 — the table's ≥ 1 is a validity bound, and 03-board-ui.md's width division must never see a zero or negative unit — while a non-finite `order` (`.nan`, `.inf`) is the same loud malformed-input rejection as a non-numeric one: fail-fast's `order` guard covers unusable values, not merely non-numeric ones (NaN has no place in the total order the tie-break and midpoint math assume). The hostile-YAML suite gains both shapes (`width: 0`, `order: .nan`). Coercion is read-side only; the bytes on disk are **preserved verbatim, never rewritten**. One legacy nuance: a *present* `deleted:` key is migration input, not live state — the item relocates per the Deletion section's legacy rule, whatever the timestamp's condition. **Duplicate keys: last one wins** (settled — the coercing read; strict YAML would reject the file, so this is a deliberate divergence in the editor's favor): a *top-level* key appearing twice reads as its last occurrence, earlier occurrences preserved verbatim on disk and invisible. A duplicate inside a nested mapping value remains a YAML error — the rescue applies where the editor's slip actually happens, the top level. Last-wins resolves *before* strictness: a duplicated `order` reads as its final occurrence and only that value faces the malformed-order check — duplication itself is never a fail-fast. An app write of a duplicated key rewrites the winning (last) occurrence and removes the earlier ones — the app owns the keys it writes, and leaving a stale twin would resurrect it if the winner were later removed; removing a key removes all its occurrences. **App rewrites preserve comments** (settled): comments on their own lines always survive a rewrite; an inline comment on a rewritten value line is re-spliced after the new value — best-effort, guaranteed for plain scalar lines (the realistic case), dropped only in pathological shapes. **Unquoted colons in a value read as the string they meant** (ratified — the third member of the rescue family, same posture as last-wins and coercion): the classic slip `title: Fix: the thing` — invalid YAML ("mapping values are not allowed in this context") — recovers when, and only when, the scanner error's mark lands on a line that *starts a top-level key* and whose remainder is a **plain unquoted scalar**: everything after the first `: ` reads as the value. A remainder opening with a quote or flow character (`"`, `'`, `[`, `{`) is a different breakage and stays fail-fast; nested/indented shapes never match the guard and stay errors. Mechanism mirrors the duplicate-key rescue: compose retries on a modified copy (the offending value swapped for a same-line placeholder), so one recovered line leaves every other line's diagnostics intact — a second, genuinely broken line still fails loudly with its own error. Read-side only, bytes preserved verbatim; the span editor keys by the line's key prefix, so the file stays fully editable, and the first app write of that key emits the value properly quoted — the slip heals on first touch. Recovery is **silent with a log line** (the family's posture: the recovered value is exactly what the writer meant, so there is nothing actionable to surface). The agent guide keeps teaching quoting — the rescue is a net, not a license. Test obligation: the hostile-YAML suite gains the `key: value: more` shape (none covers it today). Fail-fast remains reserved for structure (`schema`, `order`, YAML validity) — and it covers *malformed*, not just missing: an `order` that is present but non-numeric is the same loud malformed-input rejection as a missing one. **Readable-but-uneditable shapes load; writes to them refuse** (settled): frontmatter the surgical editor can't key by spans — a whole-frontmatter flow mapping, non-scalar keys, or frontmatter whose YAML resolves to an *empty* mapping while still carrying text (`{}`, `null`, `~` between the delimiters: zero keys load fine, but appending a key after that text would produce unparseable YAML; truly blank or comment-only frontmatter stays editable) — reads and renders normally, and any app write to that file fails loudly through the per-file write-failure banner (02-architecture.md ▸ Write-failure surfacing) naming the shape, never a silent corruption and never a load rejection.
## Enhanced schema (reserved, out of scope)
@@ -110,13 +109,16 @@ Carried over unchanged — gapped fractional ranks:
- **Import collisions auto-rename, Finder-style** (`shot.png``shot 2.png`): an import never overwrites an existing file and never refuses the drop. Never-refuses is policy, not an I/O guarantee — a failed copy (source unreadable, destination full) surfaces per 02-architecture.md ▸ Write-failure surfacing, with any partial file removed.
- **Subfolders are tolerated, preserved verbatim, never created by the app, and not surfaced** in those surfaces. Their contents stay reachable via Reveal in Finder, and body-embedded relative paths into them (`![](attachments/sub/x.png)`) still render — preview resolves against the card folder.
## Deletion — tombstones
## Deletion — the materialized trash (resettled 2026-07-28)
- Deleting writes `deleted: <ISO-8601>` into frontmatter; tombstoned items (and everything beneath) are hidden from the board. **Hiding beneath is the renderer's walk, not a stored flag** (pinned by the fixture suite): the snapshot flags only the item carrying `deleted:` — a live card inside a tombstoned lane stays `isDeleted == false`, and every consumer of the model (board view, trash, search, counts) must treat ancestor tombstones as hiding the subtree rather than filtering on the item flag alone and surface in the **trash quasi-lane** (03-board-ui.md) — a pure view. **Tombstoned items stay exactly where they are on disk**, which is what makes restore (Put Back = removing the key) position-preserving: the item reappears at its recorded `order` among its current siblings. One acknowledged edge: a renumber rescales *visible* siblings only (tombstones are inert to ordering), so a post-renumber Put Back can land among different neighbors than the ones it was deleted between — same lane, deterministic placement, just not always the original gap.
- Tombstones are **inert to ordering**: appends and renumbering operate on visible siblings only; a restored item re-enters at its recorded `order`, ties broken deterministically.
- A folder that disappears without a tombstone (Finder deletion) is also a delete; the app reflects it (such items are gone entirely — they never enter the trash).
- A `deleted:` key at **board level** is legal per the common frontmatter table but meaningless — a board can't tombstone itself out of its own window. The loader ignores it and logs (the lenient-values philosophy: preserved verbatim, invisible in the UI).
- Physical removal is the trash's **Delete Immediately / Empty Trash** (03-board-ui.md). Time-based auto-purge of old tombstones: deliberate follow-up, not in the initial release.
The tombstone model is **retired** — no `deleted:` key, no in-place flags, no ancestor-walked hiding. Deletion is a **move**:
- **Deleting a card moves its folder into `<board-root>/.trash/`** — a reserved, app-claimed name at board root (joining `CLAUDE.md` and the seeded `.gitignore` on the claimed list; agents and hand-editors should treat it as the app's). `.trash/` holds card folders directly — same shape as a lane's children, no `index.md` of its own. Every arrival lands at the **top**: the move mints an `order` rank above the current topmost, so the trash sorts newest-first by ordinary ranks — no timestamp sort, no tie-break rules. The move **stamps `modified`** (deletion is an edit to the card's story — the deliberate exception to moves-don't-stamp), which is what a future age-based auto-purge will read (deliberate follow-up, not in the initial release).
- **Deleting a lane is physical** — the folder and its contents are removed. There is no lane trash; the recovery net is native undo in-session (13-native-undo.md) and git history on git boards (06-history-undo.md's delete-never-forgets).
- **Restore is a move out**: any move of a card folder from `.trash/` into a lane — drag, or cut+paste — is an ordinary move; there is no restore-specific machinery and no Put Back. Position and lane are chosen at restore time.
- A folder that disappears entirely (Finder deletion) is also a delete; the app reflects it — such items are gone, they never enter the trash.
- Physical removal in-app is the trash's **Delete / Delete Immediately / Empty Trash** (03-board-ui.md).
- **Legacy `deleted:` keys migrate on load-and-write, never destroy** (settled): a card carrying `deleted:` is relocated into `.trash/` (key removed — the loose-file relocation posture: detection read-only in the loader, the fix through the Writer, a graceful warning-tone notice, deferred under any read-only lock); a lane carrying `deleted:` returns **live** with the key removed and a notice — resurrection is the safe direction, nothing is destroyed by migration. A `deleted:` key at board level remains meaningless — ignored and logged, preserved verbatim.
## Malformed input — fail fast
@@ -138,4 +140,4 @@ No migration is implied by these — pathfinder boards have no compatibility cla
## Open questions
None currently — trash-bin behavior is settled: in-place tombstones with the trash as a pure view (Deletion above; UI in 03-board-ui.md).
None currently — trash behavior is settled: the materialized `.trash/` (Deletion above; UI in 03-board-ui.md).
+6 -6
View File
@@ -28,7 +28,7 @@ The **one named exception** is transient UI state rendering things that don't ex
- **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. (The EchoLedger's receipts are not this bullet's "hidden state": a receipt describes a *completed* write, and the ledger lives beside the Writer, not in it — no write is ever pending in memory.)
- **BoardWriter** — every mutation (create, move, reorder, delete, style) as an explicit filesystem operation. No hidden state; a write is done when the file is on disk. (The EchoLedger's receipts are not this bullet's "hidden state": a receipt describes a *completed* write, and the ledger lives beside the Writer, not in it — no write is ever pending in memory.)
- **EchoLedger** — the write-provenance ledger (the "Writer/echo machinery" that 06-history-undo.md ▸ Interaction with external writers — its Commit attribution rule — and 10-accessibility.md ▸ Live board announcements consume; settled). Every BoardWriter operation drops a receipt of its expected on-disk outcome before returning: path → content hash for writes (attachment imports hash during the copy — the bytes stream through the app anyway), an absence marker for deletes, an old→new pair for folder moves; a newer app write to the same path supersedes the receipt. Classification runs per observed changed file in a debounce window: current on-disk content matches the receipt → **app-mediated**, receipt consumed; no receipt, or mismatch → **foreign**. Final content deciding is what settles the races: an agent writing byte-identical bytes over a fresh app write matches and classifies app-mediated — with identical bytes the misattribution is unobservable in the tree, accepted; a foreign edit landing on an app-written path inside the same window misses the hash and the file classifies foreign — last writer wins the file, the app's subsumed intermediate never separately recorded (the diff compares snapshots, not a journal — 06-history-undo.md). Consumers: the auto-committer's author field and two-commit split (06), and the announcement filter (10) — on no-git boards the ledger runs identically with the announcer as its only consumer. **In-memory, per-store, dies with the session** — losing it costs attribution and nothing else, so the launch catch-up commit (06) classifies everything foreign: the app never vouches for changes it didn't witness. Bracketed operations don't consult it (they commit themselves and announce once at completion), and the reload-granularity origin tag (Live-reload resilience below) is orthogonal: it classifies *reloads*, the ledger classifies *files*. Feeds attribution and announcements only — never the render path (Layering above).
- **BoardStore** — per-board `@Observable` object holding the current snapshot plus transient UI state that must be shared across that board's windows (TransientBoardState — see Changes from Kanban). Coalesces watcher reloads — at most one tree walk in flight, signals landing mid-walk fold into one follow-up; the debounce itself lives in FolderWatcher (below).
- **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.)
@@ -44,14 +44,14 @@ The **one named exception** is transient UI state rendering things that don't ex
- **Duplicate-id detection surfaces as a repairable condition** (settled): the loader's board-wide dedupe (01-storage-format.md ▸ Fractal layout rules) withholds losing occurrences from every snapshot; a warning-tone condition banner names them and offers **Repair** — a Writer remint under user intent (the loader itself never writes). The condition heals when the duplicates are gone, however they go — Repair here, a repair elsewhere, a hand-deleted copy.
- **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, so quiet reconciliations cost nothing visible), 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. **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).
- **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. **Liveness here is effective ancestor-walked** (settled): tombstoning a card's *lane* ejects the card too, its own flag notwithstanding — the card renders nowhere (03-board-ui.md collapses a tombstoned lane to a single restorable trash entry), and nothing invisible may stay selected, drag-included, or pending-cut. 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 effectively deleted the ancestor walk included (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 Put Back, 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).
- **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).
### 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"). **The operation is a closed enum, not a string** (settled): Writer errors identify the failed operation as an enum case (create, move, reorder, tombstone, restore, style, …) carrying the affected item's title where known; the banner owns all user-facing phrasing and localization from that vocabulary, and a new Writer operation without a banner rendering is a compile-time hole, not a silent default. **The vocabulary grows with the surfaces** (settled): inline rename and the body save get their own cases when wired ("Couldn't rename 'Fix login'…", "Couldn't save 'Fix login'…") — style stays styling-only, never the generic frontmatter bucket; growing the enum is cheap by design. Free-form English survives only inside the diagnostic `reason`, never as the banner's verb. One-shot actions (move, tombstone, style, create) fail once and wait for the user to act again; nothing is queued behind their back.
- **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"). **The operation is a closed enum, not a string** (settled): Writer errors identify the failed operation as an enum case (create, move, reorder, delete, restore, style, …) carrying the affected item's title where known; the banner owns all user-facing phrasing and localization from that vocabulary, and a new Writer operation without a banner rendering is a compile-time hole, not a silent default. **The vocabulary grows with the surfaces** (settled): inline rename and the body save get their own cases when wired ("Couldn't rename 'Fix login'…", "Couldn't save 'Fix login'…") — style stays styling-only, never the generic frontmatter bucket; growing the enum is cheap by design. Free-form English survives only inside the diagnostic `reason`, never as the banner's verb. One-shot actions (move, delete, 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 renamed or moved board root follows its file identity** (settled): the board the app has open is the *file*, not the path string — the registry's security-scoped bookmark is the identity, mid-session as much as across opens (01-storage-format.md calls Finder renames ordinary, and mid-session must honor that). On any root-gone signal — the watcher's path stops delivering, a write lands on a stale path — the app first **re-resolves the bookmark**: if it resolves to a new location, the rename/move is absorbed transparently — the watcher re-attaches there, Writer URLs and card-window keys re-derive from the new root, one full reload runs, and the window title follows the folder-name fallback where it applies — no banner, no lock, nothing was ever wrong. Only when the bookmark does not resolve is the root truly vanished (below). Either way, a root-gone signal **cancels any armed debounced tree-event delivery** (settled): both outcomes end in a full reload — at the re-resolved root, or on the root's return from the vanished-root lock — so delivering a stale tree event for a path that just stopped being the root would only be noise.
- **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 — and the rename re-resolution above found nothing): 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. **A root change landing mid-bracket is owned by the root-change path, not the bracket** (settled): re-resolution runs immediately even inside a bracket — a rename is absorbed transparently and the bracket's final reload simply runs at the re-resolved root; a true vanish raises this lock at once and the bracket's eventual final reload becomes a no-op rather than a redundant failure. Nothing is lost by skipping it: the root's return runs the reconciling reload, and an operation the vanish killed mid-flight is 06-history-undo.md's own-leftovers case, recognized at the next open or flush. (The composition matters for a pull-rebase mid-flight when a volume unmounts — 07-sync-collab.md.)
@@ -92,7 +92,7 @@ The non-modal banner named throughout the read- and write-side rules above is on
State that belongs to the app, not the user's files — the recents list, per-board window frames, the open-now restoration flag (Launch and window lifecycle above), 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. **One bookmark per open board** (settled): the open flow mints it once and threads it through — the persisted record and the live store's mid-session re-resolution share the same bookmark, never two independent mints.
- **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. **The counts are live items only** (settled): tombstoned lanes and cards — and cards hidden beneath a tombstoned lane — don't count; the row advertises the board's working size, and the trash is an errand, not inventory. 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. **A first open that fails fail-fast still records** (settled): the registry record is created before loading, so the failed board lands in recents carrying fail-fast's specifics — retry after fixing the file is one click, uniform with the restored-board failure row. **The record's provisional display name is the folder name** (settled): fail-fast means the frontmatter can't be trusted, and the folder name is the Finder document name the user just picked; the first successful load replaces it with the cached title. A never-successfully-opened record is not a special class — it lingers in recents like any other, and Forget is the eraser for a genuinely mistaken open. **Records that collapse onto one file identity merge silently** (settled — a restored registry file, or an orphan whose bookmark re-resolves onto a recreated board): on detection, the record with the newest `lastOpened` wins wholesale and the others retire — recents never shows one board twice, and per-board settings are conveniences that don't earn a merge UI.
- **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. **The counts are working items only** (settled, re-grounded 2026-07-28): cards in `.trash/` don't count; the row advertises the board's working size, and the trash is an errand, not inventory. 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. **A first open that fails fail-fast still records** (settled): the registry record is created before loading, so the failed board lands in recents carrying fail-fast's specifics — retry after fixing the file is one click, uniform with the restored-board failure row. **The record's provisional display name is the folder name** (settled): fail-fast means the frontmatter can't be trusted, and the folder name is the Finder document name the user just picked; the first successful load replaces it with the cached title. A never-successfully-opened record is not a special class — it lingers in recents like any other, and Forget is the eraser for a genuinely mistaken open. **Records that collapse onto one file identity merge silently** (settled — a restored registry file, or an orphan whose bookmark re-resolves onto a recreated board): on detection, the record with the newest `lastOpened` wins wholesale and the others retire — recents never shows one board twice, and per-board settings are conveniences that don't earn a merge UI.
- **The row's title and icon are registry-cached too — with live write-through** (settled): the record carries the board's `title`, `icon`, and `iconColor` beside the counts, and the welcome row reads only the record — it never opens any board's `index.md` (the same hang-avoidance that motivated the counts rule). Unlike the counts' at-close stamp, these three refresh **whenever an open board's reload changes them**: the store already holds the new snapshot, so an in-app Board rename (03-board-ui.md) lands in the record instantly — never a welcome row showing a name the user just changed away from — and a foreign rename of an *open* board rides the same path for free. The honest residual: renaming a board that isn't open (an agent editing its root `index.md`) stays stale until the next open — accepted, the counts' staleness class. The title falls back to the folder name per 01-storage-format.md, cached at the same moments.
- **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).
@@ -109,7 +109,7 @@ The old app loaded boards fast enough that the planned SwiftData cache was never
## Changes from Kanban
- The store's transient-state grab-bag gets an explicit home (settled, m3): **TransientBoardState**, one per store, holding state by how a reload treats it. **Item-referencing sets** — selection, drag membership, the pending cut — share one shape (a UUID set plus the liveness side it lives on) and one constraint rule, *members must exist in the current universe*, applied in two directions by one primitive: a reload re-resolves each set independently against the new snapshot (present on the same effective-liveness side, ancestor-walked), and the search filter constrains the selection to its visible set — **the trashed side has exactly one definition: the set with trash rows** (settled): a card carrying its own `deleted:` under a tombstoned lane is in *neither* universe (03-board-ui.md's ancestor walk subsumes it — no row, no membership), so an anchor or selection can never survive on an item that renders nowhere; universe and rows are one function, never a broader set with a pointer-side subset — the hidden-cards-leave-the-selection rule and the reload-survival rule are one rule, expressed once. **Derived state is stored as its inputs only**: the search query is kept, its result set never is — the predicate re-runs against each snapshot (04-interactions.md's live filter). **The overlays** — transient render state covering the gap between a gesture and its disk echo, each discarding itself at handoff. The **new-card placeholder** is anchored to its lane, not to items: no UUID until the title commits, discarded when a reload drops or effectively tombstones its lane, and handed off by discarding itself the moment the created card's UUID appears in a snapshot. The **held drop proposal** (settled — 03-board-ui.md ▸ Motion) is its kin on the other side of a write: at drag release the proposed arrangement keeps rendering over the snapshot while the move write brackets, and the proposal discards itself when the echo reload lands (positions match, nothing visibly moves); a failed write or a reload that vanishes the dragged items discards it and the board animates back to snapshot order. **Its home is the app-wide DragSession, not this per-store state** (settled 2026-07-28 — the one overlay that outlives a store's scope): a drag inherently crosses boards, so the hold lives once on AppModel, keyed by board root and snapshot generation; per-store homing would need a store-to-store hand-off mid-gesture for no behavioral gain. The placeholder kinship is semantic — hold, hand off at echo, discard on vanish — not residential. A refused write produces no echo reload, so **a short timeout stands in for the failed-write discard signal** (accepted): the hold snaps back animated when no echo arrives. Trash visibility rides along as a plain per-open value: hidden on every open, never persisted — visiting the trash is an errand, not a layout choice.
- The store's transient-state grab-bag gets an explicit home (settled, m3): **TransientBoardState**, one per store, holding state by how a reload treats it. **Item-referencing sets** — selection, drag membership, the pending cut — share one shape (a UUID set plus the container side it lives on, board or trash) and one constraint rule, *members must exist in the current universe*, applied in two directions by one primitive: a reload re-resolves each set independently against the new snapshot (present on the same container side — resettled 2026-07-28, the materialized trash: presence is the whole test, no ancestor walk, no effective liveness), and the search filter constrains the selection to its visible set — the hidden-cards-leave-the-selection rule and the reload-survival rule are one rule, expressed once. **Derived state is stored as its inputs only**: the search query is kept, its result set never is — the predicate re-runs against each snapshot (04-interactions.md's live filter). **The overlays** — transient render state covering the gap between a gesture and its disk echo, each discarding itself at handoff. The **new-card placeholder** is anchored to its lane, not to items: no UUID until the title commits, discarded when a reload drops its lane, and handed off by discarding itself the moment the created card's UUID appears in a snapshot. The **held drop proposal** (settled — 03-board-ui.md ▸ Motion) is its kin on the other side of a write: at drag release the proposed arrangement keeps rendering over the snapshot while the move write brackets, and the proposal discards itself when the echo reload lands (positions match, nothing visibly moves); a failed write or a reload that vanishes the dragged items discards it and the board animates back to snapshot order. **Its home is the app-wide DragSession, not this per-store state** (settled 2026-07-28 — the one overlay that outlives a store's scope): a drag inherently crosses boards, so the hold lives once on AppModel, keyed by board root and snapshot generation; per-store homing would need a store-to-store hand-off mid-gesture for no behavioral gain. The placeholder kinship is semantic — hold, hand off at echo, discard on vanish — not residential. A refused write produces no echo reload, so **a short timeout stands in for the failed-write discard signal** (accepted): the hold snaps back animated when no echo arrives. Trash visibility rides along as a plain per-open value: hidden on every open, never persisted — visiting the trash is an errand, not a layout choice (the trash itself is `.trash/` on disk — 03-board-ui.md).
## Open questions
+14 -15
View File
@@ -4,7 +4,7 @@ The board window: layout, lanes, cards, and styling. Interaction mechanics (sele
## Layout — full visibility
- **Every lane is always on screen.** The window width divides across the lanes' width units — no horizontal scroll, no enforced minimum lane width. Resizing the window is the width control. (Settled emphatically in the old app: horizontal scroll strays from what kanban is for.) The degenerate case is accepted, not floored: enough lanes/units in a small window compress every lane, titles and cards truncate gracefully, and the remedy is the user's (fewer units, bigger window). A minimum-width setting that reintroduces scroll was considered in the pathfinder and deliberately rejected. **Lane-count changes re-divide, never resize**: new lane (⇧⌘N), lane paste or cross-board lane drop, lane tombstone, Put Back, and Show/Hide Trash (the quasi-lane's fixed unit joins and leaves the division — Trash below) all re-divide the existing window width across the new unit total — window-growing behavior belongs to the right-edge drag alone (Lane below).
- **Every lane is always on screen.** The window width divides across the lanes' width units — no horizontal scroll, no enforced minimum lane width. Resizing the window is the width control. (Settled emphatically in the old app: horizontal scroll strays from what kanban is for.) The degenerate case is accepted, not floored: enough lanes/units in a small window compress every lane, titles and cards truncate gracefully, and the remedy is the user's (fewer units, bigger window). A minimum-width setting that reintroduces scroll was considered in the pathfinder and deliberately rejected. **Lane-count changes re-divide, never resize**: new lane (⇧⌘N), lane paste or cross-board lane drop, lane delete (and its undo), and Show/Hide Trash (the trash lane's fixed unit joins and leaves the division — Trash below) all re-divide the existing window width across the new unit total — window-growing behavior belongs to the right-edge drag alone (Lane below).
- A lane spans a **whole number of width units** (`width` frontmatter, ≥ 1, no cap). Cards stay standard width; a wide lane flows them into as many interior masonry columns as it has units.
- New lanes are created via a **File-menu item** (the one committed surface; ⇧⌘N — 11-command-nexus.md); new cards from the lane (see 04-interactions.md for creation flows). **The created lane becomes the sole selection** (settled): ⇧⌘N → Board ▸ Rename is a pure keyboard path — the create-then-act texture (Return-creation re-selects its lane, ⌫ picks a successor).
@@ -45,7 +45,7 @@ One **style editor** component — a background palette grid and a curated symbo
- **Curated symbol grid**: a hand-picked set (roughly five dozen kanban-relevant SF Symbols); its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — named symbols the running OS knows, that is: inventories grow per macOS release, so a newer-OS name renders the level default on an older Mac, value preserved on disk — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch.
- **Off-palette values display leniently**: a hand-written hex background or uncurated symbol shows as the current value in the editor (labeled verbatim, outside the grids); choosing any well replaces it.
- **Batch edits**: a multi-selection shows per-dimension mixed state (no well selected, "—" where a value would read); choosing a well applies to the whole selection — one gesture, one commit on git boards.
- **The Style… popover tracks its target set live and dismisses when it empties** (settled): its target is the selection, re-resolved across reloads by 02-architecture.md's UUID rule — a member that vanishes or flips liveness leaves the set and the mixed-state display recomputes; a set emptied by a foreign reload dismisses the popover (the inline-rename discard applied here) — it never silently retargets to the board, and nothing writes into a vanished or tombstoned folder. **The read-only lock instead disables its wells in place** (settled): a popover open when the lock lands stays open, content disabled — the banner names why, and the lock never yanks a surface (the Edit buffer's keeps-its-place posture). The embedded anchors need no rule of their own: the card sidebar dismisses with its card's window, and the board popover's target is the board itself.
- **The Style… popover tracks its target set live and dismisses when it empties** (settled): its target is the selection, re-resolved across reloads by 02-architecture.md's UUID rule — a member that vanishes or flips liveness leaves the set and the mixed-state display recomputes; a set emptied by a foreign reload dismisses the popover (the inline-rename discard applied here) — it never silently retargets to the board, and nothing writes into a vanished folder (a member moved to the trash leaves the set like any other departure). **The read-only lock instead disables its wells in place** (settled): a popover open when the lock lands stays open, content disabled — the banner names why, and the lock never yanks a surface (the Edit buffer's keeps-its-place posture). The embedded anchors need no rule of their own: the card sidebar dismisses with its card's window, and the board popover's target is the board itself.
- **Quick-style row, recents only**: card and lane context menus carry one compact row of recently used backgrounds plus the Style… item — one-click recolor for the common case; the pathfinder's second full-palette tier is gone. Before any background has ever been applied, the row is omitted entirely — never an empty strip. Recents are app-wide and persist app-side (user preference, never board data).
- **Keyboard path**: Style… is a menu item with a shortcut (04-interactions.md's contract); inside the editor the grids are arrow-navigable and every well Tab-reachable (10-accessibility.md).
@@ -59,18 +59,17 @@ The window-title widget opens the **board popover** — the one board-level surf
## Trash
Deletion is a two-stage, Finder-style story: ⌫ tombstones (01-storage-format.md), and the **trash quasi-lane** is where tombstoned items live on screen. It is a **pure view** — tombstoned cards keep their `deleted:` key and stay exactly where they are on disk; nothing about the storage schema is trash-specific.
**Resettled 2026-07-28 — the materialized trash.** The tombstone model (a `deleted:` flag on items left in place, rendered by a pure-view quasi-lane) is **retired**: it generated a standing tax of nesting rules — ancestor walks, effective liveness, entry-vs-universe splits, kind-homogeneous selection — that this design replaces wholesale. Deletion is now a **move**: deleting a card moves its folder into **`<board-root>/.trash/`**, a reserved, materialized container (01-storage-format.md). A trashed card is an ordinary card in a special place — search, selection, rendering, styling, and clipboard all treat it exactly like any other card, and `.trash/` is self-describing in Finder and to agents.
- **Rendering**: trailing (rightmost) position, visually distinct — dimmed/hatched header, trash SF Symbol, count badge; no new-card button; not draggable, not resizable, excluded from lane reordering. It spans a **fixed one width unit** — no `width` frontmatter, and neither the stepper nor the edge drag applies — consumed only while shown: Show/Hide Trash is a re-divide trigger (Layout above), dividing the window across lane units + 1. A small window compresses like any lane add — accepted, not floored.
- **Contents**: the board's tombstoned cards, sorted by `deleted` timestamp (newest first). **The sort is fully deterministic** (settled — the trash's order is load-bearing for input: arrow walks, ⇧-ranges, and the rubber band all read it, 04-interactions.md): ties — a multi-card ⌫ stamps one second onto N cards — break by folder name, ascending; an entry whose `deleted` value doesn't parse (01-storage-format.md's unusable-timestamp rule) sorts as *oldest*, after every dated entry, folder-name-ordered among its kind — a corrupt stamp must not outrank fresh deletions for the trash's most prominent rows. Lane entries interleave in the same single ordering by their own `deleted` stamp. A tombstoned *lane* appears as a single restorable entry — its cards were hidden with it, not individually tombstoned, and it restores as a whole. **The ancestor walk is absolute here too** (settled): the lane's single entry subsumes *everything* beneath it — a card that carries its own `deleted:` under a tombstoned lane has **no row of its own** (01-storage-format.md's consumer rule, no trash carve-out; 02-architecture.md's effective liveness agrees: such a card renders nowhere). The entry's card count counts what Put Back returns to the board — cards without their own flag; individually tombstoned descendants aren't in that number, since they come back to the *trash* (below).
- **Visibility**: hidden by default; **View ▸ Show Trash** toggles it (no default chord — ⇧⌘T belongs to the system's Show Tab Bar, window tabbing staying enabled, 11-command-nexus.md; stable title with checkmark state, per 04-interactions.md's configurable-bindings rules). Transient board-scoped state, held in the BoardStore (02-architecture.md; one board window per board, so board-scoped and per-window coincide today) — resets to hidden on open, not persisted (visiting the trash is an errand, not a layout choice). Hidden trash is invisible to search; shown, it participates in the filter like any lane.
- **Put Back** (context menu, Finder vocabulary; ⌘⌫ on a tombstoned selection — Finder's own symmetry): removes `deleted:` — the item reappears in its lane at its old `order` (ties break deterministically). **Putting back a lane splits its contents by flag** (settled): cards hidden with the lane return to the board with it; cards carrying their own `deleted:` stay tombstoned — their rows reappear in the trash. Recovering one of those is deliberately two steps: Put Back the lane, then Put Back the card (the one-step cascade was considered and rejected — the card has no row to act on while its lane is tombstoned, per Contents above). Restore fidelity is perfect because nothing ever moved.
- **Drag-to-restore**: dragging a card out of the trash into one of its own board's lanes restores it at the drop position (key removed, `order` set, folder moved only if the destination lane differs). Dropped on another board it follows the drag locality model (04-interactions.md) — a live copy by default, the tombstoned original staying put; ⌘-drag for the true restore-move.
- **Keyboard, selection, and clipboard semantics** inside the shown trash (navigation, no mixed live/tombstoned selections, copy-out-only clipboard, inert moves) are specified in 04-interactions.md ▸ The trash, keyboard-first.
- **No editing in the trash**: tombstoned cards don't open — double-click does nothing beyond selection; Put Back or drag out first (Finder vocabulary: the trash is for restoring or purging, not working). Tombstoning a card whose window is open dismisses that window (05-card-window.md).
- **Delete Immediately** (per item, ⌥⌘⌫) and **Empty Trash…** (confirmed, ⇧⌘⌫) physically remove the folder(s) — Finder's trash trio throughout. **Delete Immediately confirms exactly where the loss is real** (settled): on boards without app-managed git history — mode none and repo-nested — the alert stands between one keystroke and unrecoverable deletion; on git boards it acts immediately, since the content remains reachable in history (06-history-undo.md's delete-never-forgets). A deliberate divergence from Finder's always-confirm: the prompt tracks actual recoverability, not ceremony. Empty Trash… confirms everywhere (bulk scope, not per-item recoverability, is what it guards) — and its scope is **the whole trash, search-independent** (settled): emptying purges every tombstone on the board, filter or no filter, the confirmation naming the true count — **counted in entries, the rows the user sees** (settled): a tombstoned lane's single entry counts once, and the alert appends "Deleting a lane also deletes every card inside it." to cover the subsumed interiors honestly ("Permanently delete 5 items" — 06-history-undo.md's plural folding); a bulk command about the trash itself never silently narrows to the visible subset, and menu validation's "non-empty" reads the board's tombstones, not the filtered view. Time-based auto-purge remains a deferred follow-up (01-storage-format.md).
- Every trash operation is an ordinary file write — auto-committed and undoable on git boards; on no-git boards the trash itself is the delete-recovery story (07-sync-collab.md).
- **Naming constraint**: two "Trash" concepts coexist — attachment Remove moves the file to the *system* Trash (05-card-window.md), while card/lane deletion lands in this in-app quasi-lane. UI copy must keep them distinguishable: Finder's "Move to Trash" phrasing is reserved for the system Trash; board deletion says "Delete", and the quasi-lane is "Trash" / "Show Trash". Final strings settled in one naming pass when the trash UI copy is written.
- **Cards only. Lanes are never trashed** (settled): deleting a lane deletes it — folder and contents, physically. The net is undo, not the trash: native undo restores it in-session (13-native-undo.md), git boards keep it reachable forever (06-history-undo.md's delete-never-forgets); undo not surviving relaunch is accepted. No dialog — same posture as before.
- **Entry is always at the top** (settled): every arrival — ⌫/⌘⌫ delete and drag-to-trash alike — lands at the trash's topmost position, minting an `order` rank above the current top. Newest-first ordering falls out of ordinary ranks by construction: **there is no `deleted:` key and no timestamp sort** — the trash sorts by `order` like any lane. The move **stamps `modified`** (the one exception to moves-don't-stamp — deletion is an edit to the card's story), which is what a future age-based auto-purge will read (deferred, 01-storage-format.md).
- **Rendering**: trailing (rightmost) position when shown, visually distinct — dimmed/hatched header, trash SF Symbol, count badge; no new-card button; not draggable, not resizable, excluded from lane reordering. Fixed one width unit, consumed only while shown; Show/Hide Trash is a re-divide trigger (Layout above). **Visibility**: hidden by default; View ▸ Show Trash toggles (no default chord — ⇧⌘T belongs to the system's Show Tab Bar, 11-command-nexus.md); per-open transient state, resets to hidden, never persisted. Hidden, the trash is invisible to every gesture and to search; shown, its cards participate in the filter **exactly like any other card** — the point of the pivot.
- **No Put Back** (settled): the valuable item is the card; where it goes on the way out is the user's cheap decision. Restoring is an ordinary move out: **drag** a trash card into any lane at any position, or **⌘X in the trash, ⌘V into a lane** — the clipboard works on trash cards like on any card, which is also the keyboard-native restore path (10-accessibility.md). Dropped on another board it follows the drag locality model (04-interactions.md).
- **No editing in the trash**: trash cards don't open — double-click stops at selection; move it out first. Moving a card to the trash dismisses its open card window (05-card-window.md), and an external move-in observed by reload does the same.
- **Permanent deletion**: on a trash card, **Delete (⌫/⌘⌫) is permanent** — one delete vocabulary, staged by place: on the board it moves to the trash, in the trash it removes the folder. **⌥⌘⌫ Delete Immediately** skips the trash from anywhere. Both **confirm exactly where the loss is real** (carried over): on boards without app-managed git history the alert stands between one keystroke and unrecoverable deletion; on git boards they act immediately (delete-never-forgets). **Empty Trash…** (⇧⌘⌫) confirms everywhere and purges the whole `.trash/`, search-independent, the confirmation naming the card count ("Permanently delete 41 cards" — 06-history-undo.md's plural folding); menu validation's "non-empty" reads `.trash/`, not the filtered view.
- Every trash operation is an ordinary file operation — auto-committed and undoable on git boards; native undo restores a permanent delete in-session on any board (a trash move undoes as a move back — durable across relaunch, since it is just a move).
- **Naming constraint** (carried over): attachment Remove moves files to the *system* Trash (05-card-window.md); board deletion says "Delete" and this container is "Trash" / "Show Trash" — Finder's "Move to Trash" phrasing stays reserved for the system Trash.
- **Materialized reserved lanes are a pattern, not a one-off**: `.trash` is the first; an **archive lane** (`<root>/.archive`, intake criteria to be designed) is the anticipated second (WISHLIST) — same mechanics, ordinary cards in a reserved dot-named container, different entry semantics. Nothing beyond `.trash` is committed yet.
## Welcome screen & templates
@@ -78,7 +77,7 @@ The welcome window carries over from the pathfinder unchanged — confirmed, it
- Welcome: resizable, no title bar (background drag); recents list with board icon, name, location, counts; single click selects, double click opens; context menu Open / Reveal in Finder / Forget.
- **Templates**: New Board (⌥⌘N — ⌘N is new *card*; 11-command-nexus.md) opens a Pages-style chooser with a mini per-lane preview per template. Inventory and definition format: 09-templates.md.
- File menu: Open Recent (with Clear Menu; available everywhere), and Duplicate (⇧⌘S) — **board window only** (11-command-nexus.md), duplicating the frontmost open board to a Finder-style "copy" sibling; it never acts on a welcome-selected recent. **A sandbox refusal of the sibling write falls back to a save panel** (settled — the board's security-scoped bookmark grants its subtree, not its parent, so the sibling destination may be unwritable): the silent Finder-style sibling is attempted first; on a permission refusal a save panel opens pre-filled with the parent folder and the "copy" name — the panel's grant is the sandbox's own answer, and it doubles as a choose-another-location affordance. Cancelling the panel cancels the duplicate quietly (no banner — the user declined, nothing failed); non-permission failures (disk full, …) keep the ordinary one-shot banner. **The copy itself is cancellable** (settled — 02-architecture.md's in-progress banner promises Cancel on copy-shaped work, and Duplicate honors it): the copy runs as a per-item file walk that checks cancellation between items — never one monolithic `copyItem` — and Cancel removes the partial sibling before dismissing the banner (the attachment partial-cleanup precedent): a cancelled duplicate never happened. The copy is preceded by the close flush (02-architecture.md ▸ Windows; the rule and its Edit-session exception are stated at 09-templates.md ▸ Save as Template), so neither the tree nor the copied history misses pending work; under the read-only lock Duplicate disables in every state (02-architecture.md — the flush can't run and the sibling destination shares the board's fate). The duplicate **opens in its own board window** once copied — macOS Duplicate convention; the original stays open too. On a git board, the duplicate **keeps `.git` but has its remote configuration stripped** — remotes only: the repo-local `user.name`/`user.email` (06-history-undo.md's identity home) survives, so the fork keeps its commit identity. The copy keeps every GUID — a whole-board copy is 01-storage-format.md's explicit carve-out from the copies-remint rule (a new identity namespace, no collision possible), and keeping them is what keeps the copied history true: its commits name paths that still exist. **Tombstoned items are carried too** (settled): Duplicate is a full fork, trash included — dropping them would leave the copy's working tree disagreeing with its own copied HEAD (the tombstone folders are tracked), where keeping them means the duplicate is born exactly matching its history; Empty Trash in the copy is one command away. Save as Template makes the opposite choice — a template isn't a fork (09-templates.md). A fork of the board keeps its history (undo trail, delete-never-forgets — and it opens straight in git mode via 06-history-undo.md's adoption rule), but it must not silently push into the original's remote — sharing stays a deliberate per-board opt-in. (Push-on-commit lives app-side in the board registry and never carries to a new board path anyway.)
- File menu: Open Recent (with Clear Menu; available everywhere), and Duplicate (⇧⌘S) — **board window only** (11-command-nexus.md), duplicating the frontmost open board to a Finder-style "copy" sibling; it never acts on a welcome-selected recent. **A sandbox refusal of the sibling write falls back to a save panel** (settled — the board's security-scoped bookmark grants its subtree, not its parent, so the sibling destination may be unwritable): the silent Finder-style sibling is attempted first; on a permission refusal a save panel opens pre-filled with the parent folder and the "copy" name — the panel's grant is the sandbox's own answer, and it doubles as a choose-another-location affordance. Cancelling the panel cancels the duplicate quietly (no banner — the user declined, nothing failed); non-permission failures (disk full, …) keep the ordinary one-shot banner. **The copy itself is cancellable** (settled — 02-architecture.md's in-progress banner promises Cancel on copy-shaped work, and Duplicate honors it): the copy runs as a per-item file walk that checks cancellation between items — never one monolithic `copyItem` — and Cancel removes the partial sibling before dismissing the banner (the attachment partial-cleanup precedent): a cancelled duplicate never happened. The copy is preceded by the close flush (02-architecture.md ▸ Windows; the rule and its Edit-session exception are stated at 09-templates.md ▸ Save as Template), so neither the tree nor the copied history misses pending work; under the read-only lock Duplicate disables in every state (02-architecture.md — the flush can't run and the sibling destination shares the board's fate). The duplicate **opens in its own board window** once copied — macOS Duplicate convention; the original stays open too. On a git board, the duplicate **keeps `.git` but has its remote configuration stripped** — remotes only: the repo-local `user.name`/`user.email` (06-history-undo.md's identity home) survives, so the fork keeps its commit identity. The copy keeps every GUID — a whole-board copy is 01-storage-format.md's explicit carve-out from the copies-remint rule (a new identity namespace, no collision possible), and keeping them is what keeps the copied history true: its commits name paths that still exist. **The trash is carried too** (settled, re-grounded 2026-07-28): Duplicate is a full fork, `.trash/` included — dropping it would leave the copy's working tree disagreeing with its own copied HEAD (the trash folders are tracked), where keeping it means the duplicate is born exactly matching its history; Empty Trash in the copy is one command away. Save as Template makes the opposite choice — a template isn't a fork (09-templates.md). A fork of the board keeps its history (undo trail, delete-never-forgets — and it opens straight in git mode via 06-history-undo.md's adoption rule), but it must not silently push into the original's remote — sharing stays a deliberate per-board opt-in. (Push-on-commit lives app-side in the board registry and never carries to a new board path anyway.)
## Editing surfaces summary
@@ -101,7 +100,7 @@ The pathfinder's animation behavior carries over as the committed motion languag
- **Equivalent operations share one dialect.** Paste animates exactly like a drop commit (same curve, same duration) so the clipboard's move story *feels* like drag landing; keyboard one-slot moves slide for the same reason a drop does — an item that teleports is harder to follow than one that slides; cut dims the card in place, Finder-style, until paste moves it (04-interactions.md).
- **Appear/disappear is scale + fade** (cards scale from ~0.8, lanes ~0.9, combined with opacity). A restore that moves a card across lanes flies it from old frame to new via matched geometry. Search-hiding rides the same structural transition — hiding is removal, not a special fade.
- **Some things deliberately never animate**: the rubber-band marquee tracks the cursor 1:1 (an eased band visibly lags the mouse), and the selection highlight rides whatever transaction is active rather than easing on its own.
- **Animated transactions are keyed narrowly** — on the search query (filter reflow) and on the drag's **drop proposal** (the reflow-to-make-room above animates under it, ~0.18 s) — never on broad state like the selection set (selection changes styling only, never geometry — Card face above, the no-carousel resettlement). What stays animation-free by construction rather than by suppression: the drag replica's tracking and the marquee rectangle (1:1 cursor following — animating input echo would be lag), and multi-select churn. **The replica's bracketing transitions do animate** (settled): the pickup lift (scale + shadow as it detaches from the card) and the cancel fly-back are the system drag session's own behaviors and match the spec verbatim; only the tracking between them is verbatim input echo. **The drop settle is the board's, not the replica's** (resettled 2026-07-28 — drags are system `NSItemProvider` sessions, required for cross-board transfer and the copy badge, and a successful drop's drag image has no fly-to-slot hook, only AppKit's brief fade): at release the held overlay (below) renders the dropped arrangement instantly while the system fade dissolves the drag image over it — the item is in its slot the moment the mouse releases, which is the promise that matters. A custom fly-to-slot animator (shadow-window replica, masked system fade) remains a deliberate later upgrade, not a commitment (WISHLIST). **The settle holds the drop proposal until the echo lands** (settled — the one-way flow means the write is still in flight at release, and a snapshot-order re-render would glide the dragged item back before the reload animates it forward again): the proposal survives release as overlay state in the app-wide DragSession (02-architecture.md — the placeholder's kin in semantics; app-wide in home because a drag crosses boards), the board keeps rendering the proposed arrangement under the system fade (the drop settle above) — and **rendering the arrangement means rendering the card**: at release the shadow is replaced by the dropped card(s) drawn in place immediately, the appear never waiting for the echo (a lingering shadow over a hidden card is the hold failing its one job) — and the proposal discards itself when the bracket's echo reload lands — positions already match, so the handoff moves nothing. A **failed write discards the proposal** and the board animates back to snapshot order with the ordinary one-shot banner — the width-drag rollback posture (the action visibly doesn't happen); a foreign reload that vanishes the dragged item discards it too (02-architecture.md's constraint rule).
- **Animated transactions are keyed narrowly** — on the search query (filter reflow) and on the drag's **drop proposal** (the reflow-to-make-room above animates under it, ~0.18 s) — never on broad state like the selection set (selection changes styling only, never geometry — Card face above, the no-carousel resettlement). What stays animation-free by construction rather than by suppression: the drag replica's tracking and the marquee rectangle (1:1 cursor following — animating input echo would be lag), and multi-select churn. **The replica's bracketing transitions do animate** (settled): the pickup lift (scale + shadow as it detaches from the card) and the cancel fly-back are the system drag session's own behaviors and match the spec verbatim; only the tracking between them is verbatim input echo. **The drop settle is the board's, not the replica's** (resettled 2026-07-28 — drags are system `NSItemProvider` sessions, required for cross-board transfer and the copy badge, and a successful drop's drag image has no fly-to-slot hook, only AppKit's brief fade): at release the held overlay (below) renders the dropped arrangement instantly while the system fade dissolves the drag image over it — the item is in its slot the moment the mouse releases, which is the promise that matters. A custom fly-to-slot animator (shadow-window replica, masked system fade) remains a deliberate later upgrade, not a commitment (WISHLIST). **The settle holds the drop proposal until the echo lands** (settled — the one-way flow means the write is still in flight at release, and a snapshot-order re-render would glide the dragged item back before the reload animates it forward again): the proposal survives release as overlay state in the app-wide DragSession (02-architecture.md — the placeholder's kin in semantics; app-wide in home because a drag crosses boards), the board keeps rendering the proposed arrangement under the system fade (the drop settle above) — **the release presentation is an open question** (reopened 2026-07-28): the first treatment — swapping the shadow for the dropped card(s) drawn in place immediately at release — was implemented and backed out on user review; the pause between release and the card's appearance still wants a designed answer, revisited separately (Redesign board ▸ Issues to Resolve). Until then the shadow holds through the gap and the card appears at the echo — and the proposal discards itself when the bracket's echo reload lands — positions already match, so the handoff moves nothing. A **failed write discards the proposal** and the board animates back to snapshot order with the ordinary one-shot banner — the width-drag rollback posture (the action visibly doesn't happen); a foreign reload that vanishes the dragged item discards it too (02-architecture.md's constraint rule).
- **Motion never feeds back into logic** (the pathfinder's animation-proof-inputs rule, kept as a hard constraint): drop-proposal math reads analytically computed resting zones, the physical mouse position, and item sizes frozen at drag start — never mid-flight measured frames, which are garbage precisely during the ~0.2 s reflow they trigger.
- **Reduce Motion is a rewrite obligation, not an inheritance**: the pathfinder ships zero reduced variants; 10-accessibility.md's commitments (crossfade or instant for reflow, search animate-out, the drag replica's lift and settle, the lane-resize rubber-band feedback, trash) are new work.
+21 -22
View File
@@ -5,7 +5,7 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
## Selection
- Cards: click selects; ⌘-click toggles; ⇧-click range-extends; click-drag rubber-bands across lanes. Lanes: ⌘/⇧-click multi-select.
- **The range anchor** (settled — standard macOS list semantics): the anchor is the last plain- or ⌘-clicked item, per board window, transient — never persisted. ⇧-click ranges from anchor to target in the flatten order (cards), lane order (lanes), or the trash's sort filtered to kind (its skipping rule below), replacing the selection and leaving the anchor in place. A marquee and wholesale selections (Select All) set no anchor, so a following ⇧-click acts as a plain click; a reload that drops or liveness-flips the anchor clears it.
- **The range anchor** (settled — standard macOS list semantics): the anchor is the last plain- or ⌘-clicked item, per board window, transient — never persisted. ⇧-click ranges from anchor to target in the flatten order (cards), lane order (lanes), or the trash's own order (no kinds there — cards only, The trash below), replacing the selection and leaving the anchor in place. A marquee and wholesale selections (Select All) set no anchor, so a following ⇧-click acts as a plain click; a reload that drops or liveness-flips the anchor clears it.
- Selection is **homogeneous**: cards XOR lanes.
- **Board background** — the margins around and between lanes, and below short content (settled): a plain click clears the selection — the pointer twin of Escape's deselect, Finder's behavior; modified clicks (⇧/⌘) are no-ops there — extension needs an item to extend to; the background is also a rubber-band origin surface on the live side, alongside lane empty space (live) and the trash column's empty space (trashed).
- Lane empty-space: single click selects the lane (click again to unselect); double click creates a card at the bottom, title editor focused. **The lane header is click-to-select too** (settled — a full lane has no empty space left): a plain click on the title bar selects the lane — **and toggles like empty space** (settled): a click on the already-selected lane's header unselects, one lane-click behavior everywhere, so a full lane keeps a pointer path out of selection; the drag surface (03-board-ui.md ▸ Lane) engages only on movement — the click-vs-drag split cards already have.
@@ -18,17 +18,17 @@ 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 copy **strips tombstoned cards**: the copy transfers content, and trash isn't content (09-templates.md's instantiation precedent — a board isn't born with trash); the tombstoned originals stay recoverable in the source board. A ⌘-drag *move* carries them whole — the folder moves as-is, and they land in the destination's trash.
- **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 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 was tombstoned or vanished in the reload is invalidated (tombstoned lanes are never drop targets — The trash below); 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 `deleted:` 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.
- **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.
## Clipboard
- ⌘X/⌘C/⌘V on cards **and lanes** (resettled — lanes joined the clipboard so cross-board structure transfer has a keyboard path under the every-function contract; the cards-XOR-lanes selection rule means the clipboard holds cards or lanes, never both). Hybrid clipboard: pasteboard carries a JSON manifest + plain text; full folder snapshots staged in Application Support so paste reproduces the item byte-for-byte — cards, attachments and all — across boards. Each manifest entry embeds the full `index.md` as a staging-less fallback (a lane entry embeds its cards' too, attachment-less). **Staging lifecycle** (settled): snapshots are staged **eagerly at ⌘C/⌘X time** — copy captures the source as it is at the gesture, immune to later deletion or unmount — and the store holds at most the *current* copy: a new Lanework copy replaces the previous snapshot, and a sweep at launch and on each copy purges entries the pasteboard no longer references (another app taking the pasteboard orphans the snapshot; the next sweep collects it). The snapshot survives relaunch exactly as long as the pasteboard still points at it — a copy made before quitting pastes whole after restart. **A paste is an import boundary, so normalization applies** (settled 2026-07-28 — 01-storage-format.md's loose-file rule): loose files the staged snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`, Finder-renamed on collision — nothing the snapshot preserved is dropped on arrival. **A degraded paste is loud, never silent** (the banner vocabulary — 02-architecture.md): if the staged snapshot is missing or unreadable at paste time, paste falls back to the embedded `index.md` — content intact, files absent — and a **loss row** (warning tone, user-dismissed) names what was lost with true totals, attachments and loose files alike, folding plurals ("Pasted 'Fix login' without its 3 attachments"; "Pasted 2 items without their 5 files" — BannerCenter owns the phrasing, the manifest records each entry's staged file count so the totals stay honest); the user never discovers an empty `attachments/` later. **The pasteboard is re-read lazily, and the brief lie is accepted** (settled): changeCount is checked on activation, on menu validation, and before paste — no timers; a background app taking the pasteboard while Lanework stays frontmost can leave Edit ▸ Paste enabled until the next check, and the paste itself re-validates and no-ops — nothing stale ever lands, which is the guarantee that matters.
- **Cut is Finder-style deferred**: cut items dim in place until paste moves them; voided if another app takes the pasteboard or the source board closes; second paste materializes copies. **Deletion voids per item**: a cut item that is tombstoned or vanishes externally before paste drops out of the pending cut — 02-architecture.md's UUID-set rule; transient state never resurrects what's gone — so paste moves only the survivors, and a cut voided down to nothing is simply void (paste disabled, no error).
- Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at its last member in flatten order — the ⌘N target rule's shared anchor (The map below). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`. **A tombstoned selection never anchors paste** (settled — the ⌘N target rule's own wording, returned to the precedent it cites): ⌘V stays enabled and behaves exactly as with nothing selected — a card payload appends to the last-active lane, a lane payload lands at the board's right end; the trash is never the destination (▸ The trash), and a trashed card's live disk-lane never leaks in as "the selected card's lane".
- **Lane paste** lands after the anchor lane — the selected lane, or the selected card's lane (several selected: the last, per the shared anchor rule); nothing selected = the board's right end. Semantics mirror the drag pair above exactly: a pasted *copy* takes fresh GUIDs throughout and **strips tombstoned cards**; a cut-paste is the ⌘-drag move — the folder moves whole, tombstoned cards landing in the destination's trash. **Pasting into the source board is supported and is the within-board lane duplicate** (settled): fresh GUIDs and tombstone-stripping apply as anywhere else, no menu-validation special case — the drag path deliberately lacks this operation (⌥ ignored on lane drags, above), the clipboard is its one home.
- **Cut is Finder-style deferred**: cut items dim in place until paste moves them; voided if another app takes the pasteboard or the source board closes; second paste materializes copies. **Deletion voids per item**: a cut item that is deleted (moved to the trash or destroyed) or vanishes externally before paste drops out of the pending cut — 02-architecture.md's UUID-set rule; transient state never resurrects what's gone — so paste moves only the survivors, and a cut voided down to nothing is simply void (paste disabled, no error).
- Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at its last member in flatten order — the ⌘N target rule's shared anchor (The map below). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`. **A trash selection never anchors paste** (settled — the ⌘N target rule's own wording, returned to the precedent it cites): ⌘V stays enabled and behaves exactly as with nothing selected — a card payload appends to the last-active lane, a lane payload lands at the board's right end; the trash is never the destination (▸ The trash), and a trashed card's live disk-lane never leaks in as "the selected card's lane".
- **Lane paste** lands after the anchor lane — the selected lane, or the selected card's lane (several selected: the last, per the shared anchor rule); nothing selected = the board's right end. Semantics mirror the drag pair above exactly: a pasted *copy* takes fresh GUIDs throughout; a cut-paste is the ⌘-drag move — the folder moves whole (nothing lane-nested to strip or carry — the trash is board-level, resettled 2026-07-28). **Pasting into the source board is supported and is the within-board lane duplicate** (settled): fresh GUIDs apply as anywhere else, no menu-validation special case — the drag path deliberately lacks this operation (⌥ ignored on lane drags, above), the clipboard is its one home.
## Keyboard
@@ -39,11 +39,11 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
- **Arrows**: spatial card navigation (nearest card in the direction, across interior grid columns and lanes); with a lane selected, ←/→ move lane selection; ⇧-arrow extends — except **⇧↑/⇧↓ in the lane domain, which are inert** (settled: there is nothing above the lane domain and no vertical range within it); selection scrolls into view; all grammar keys inert while a title editor is focused, and menu dispatch narrows to the text domain (focused-editor rule below).
- **⌥-arrows jump**: ⌥↑/⌥↓ to the current lane's first/last card; ⌥←/⌥→ to the first/last lane. **The horizontal jumps land on a card** (settled — ⌥↑ is the keyboard's one entry to lane selection, so ⌥←/⌥→ never select the lane itself): the first card of the first/last *non-empty* lane, scanning inward past empty lanes; ⌥→ prefers the shown non-empty trash — its first entry — per the last-container rule (The trash below). **⌥↑ escalates into the lane domain** (settled — the keyboard's one entry to lane selection): with the lane's first card already selected, ⌥↑ selects the *lane* itself — up in the hierarchy sense, the same key one press deeper; with a lane selected, ↓ (or ⌥↓) descends back into its cards at the first (last) card, and ⌥↑ is inert. **An empty selection seeds at the first lane's first card** on any plain arrow (deterministic origin; the ⌥-jumps behave as specified regardless) — two ⌥↑ presses from nothing reach the lane domain.
- **Return** on a selected lane: creates a card at its bottom, editor focused; Return commits and re-selects the lane (next Return = next card); ⌘↩ commits and opens the card window. Abandoned placeholders (Escape, empty commit, click-away) are discarded — creating-then-abandoning never leaves an empty card behind (untitled cards exist only when made deliberately, e.g. by an external writer or by clearing an existing title). The placeholder is store-transient overlay state — the named exception to 02-architecture.md's one-way flow; nothing exists on disk until the title commits.
- **Inline rename tracks its target by UUID, and vanishing discards it** (the placeholder and card-window kin rules — 02-architecture.md — applied to the third inline editor): a foreign *move* mid-rename is invisible — the editor follows the UUID and the commit writes the title wherever the card now lives; a target that is tombstoned, deleted, or gone at commit time discards the editor and its keystrokes silently (a liveness flip is a vanish; nothing is ever written into a vanished folder, and no partial `index.md` can resurrect deleted data). A write that fails *after* a valid commit is the ordinary one-shot write-failure banner. VoiceOver announces the vanished target per 10-accessibility.md's recovery rule.
- **Inline rename tracks its target by UUID, and vanishing discards it** (the placeholder and card-window kin rules — 02-architecture.md — applied to the third inline editor): a foreign *move* mid-rename is invisible — the editor follows the UUID and the commit writes the title wherever the card now lives; a target that is trashed, deleted, or gone at commit time discards the editor and its keystrokes silently (entering the trash is a vanish from the board; nothing is ever written into a vanished folder). A write that fails *after* a valid commit is the ordinary one-shot write-failure banner. VoiceOver announces the vanished target per 10-accessibility.md's recovery rule.
- **Return** on a sole selected **card**: inline rename. Return disambiguates on card selection — sole card = rename, lane = create (above) — and is **inert on a multi-card selection**; a lane's rename path is Board ▸ Rename. **Escape** steps outward one layer per press: abandons an open editor; else clears search, returning focus to the board (Search below); else **clears the selection** — the keyboard deselect.
- **Focused editor = text domain** (settled): while an inline title editor — rename or the new-card placeholder — is focused, board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu validation; text-domain chords route to the field as standard text ops — ⌘Z/⇧⌘Z are the editor's text undo (06-history-undo.md ▸ Undo routing), ⌘X/⌘C/⌘V/⌘A act on the text. The one board-command carve-out is **Open Card ⌘↩**, which stays enabled to commit the edit — placeholder or rename — and open the card window. Exits are otherwise unchanged: Return commits, Escape abandons; click-away splits by editor kind — a **rename commits** (focus loss = commit, matching the card window's title field in 05-card-window.md and the branch-switch parenthetical in 06-history-undo.md), while the **placeholder discards** per its rule above, the deliberate exception because nothing exists on disk yet.
- **Caret chords yield to any focused text control** (settled): Board ▸ Move Left/Move Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable via menu validation whenever *any* text control has keyboard focus — inline title editors, the board search field, board-popover fields (rename, git identity, remote), and card-window fields — because an enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the standard line-start/end caret chords. Caret motion always wins in text (the Safari pattern: ⌘← is Back, yet moves the caret while a field is focused); the lane commands re-enable the moment focus returns to the board. This is a narrow, per-command broadening of the focused-editor rule, not a general one: board commands whose chords carry no text meaning keep their surface-specific dispatch — in particular the search field's board-commands-stay-enabled rule (Search below) — and the search field's explicitly ruled ⌘⌫ steal (File ▸ Delete, not delete-to-line-start) stands.
- **⌫** on a live selection: delete (tombstone) — the plain-key synonym for File ▸ Delete ⌘⌫ (see The map). Grammar, not a menu item: giving it a menu home would require a second "Delete"-titled item, which would collide for title-matched remapping (Configurable bindings). Inert while a title editor is focused, like every grammar key.
- **⌫** on a selection: delete — the plain-key synonym for File ▸ Delete ⌘⌫, staged by place like the menu item (see The map). Grammar, not a menu item: giving it a menu home would require a second "Delete"-titled item, which would collide for title-matched remapping (Configurable bindings). Inert while a title editor is focused, like every grammar key.
- The card window speaks the same grammar: **Return** in Preview enters Edit, **Escape** returns to Preview (05-card-window.md) — plain keys, not menu items.
- These plain-key behaviors are platform grammar (Finder's own Return/arrows aren't remappable either) and sit below the remapping mechanism, which handles modifier chords on menu items only — see Configurable bindings.
@@ -52,23 +52,22 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
Every command is a menu item. The full inventory — every command and action, its default binding, applicable context, and customizability class — lives in **11-command-nexus.md**, the single source of truth for what the app can do; the command titles there are the stable strings the remapping mechanism keys on (Configurable bindings below). The rules below are the behavior behind those bindings and stay normative here.
- **⌥⌘↑/⌥⌘↓ sort within the lane** (the move-vs-jump question, resettled: *card* moves live on the ⌥⌘ chord, joining ⌥⌘←/⌥⌘→ lane width in a "⌥⌘ modifies" family; plain ⌥-arrows stay jumps; plain ⌘↑/⌘↓ are unassigned): the selected card(s) move one position within the lane — logical `order`, across interior masonry columns (10-accessibility.md's logical-order rule). A non-contiguous multi-selection **gathers on the first press**: the cards collect into a contiguous block anchored at the first selected card (first = lowest logical order; the rest follow in preserved relative order), and subsequent presses move the block one position. **Cards never change lanes by ⌘-arrow** (settled): inter-lane movement is drag or Cut/Paste (the clipboard rules above), so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes and ⌘←/⌘→ are inert on card selections. With a **lane** selected, ⌘←/⌘→ move the lane one slot — closing 10-accessibility.md's lane-move defect — and ⌥⌘↑/⌥⌘↓ are inert.
- **⌫/⌘⌫ delete** (unchanged): tombstone into the trash quasi-lane (03-board-ui.md); lanes included, no dialog. Selection moves to the deleted item's successor sibling, Finder-style (next card in the lane, next lane on the board; the last sibling's predecessor otherwise; empty container = nothing selected) — repeated ⌫ walks down a lane. Deliberate deletes pick a successor; *external* vanishing never does (02-architecture.md's reload-survival rule: the selection just shrinks). On a **tombstoned** selection ⌘⌫ is **Put Back** instead — Finder's exact symmetry (⌘⌫ trashes and un-trashes). The dual role is carried by **twin menu items sharing the chord** — File ▸ Delete ⌘⌫ and File ▸ Put Back ⌘⌫, validation enabling exactly one by selection state; AppKit routes a shared key equivalent to the enabled item (Finder ships this exact pair as Move to Trash/Put Back; ours says Delete per 03-board-ui.md's naming constraint). Both titles stay stable (titles-are-API), and each is independently remappable — remapping one never moves the other's role. Plain ⌫ performs the same tombstone as fixed grammar (see Grammar above) — there is no Edit ▸ Delete item, so the two Delete-titled homes never collide for title-matched remapping.
- **Select All**: all visible cards on the board — filter-respecting, like every surface (Search below). **On the active trash side it selects by kind** (settled): with the trash visible and a non-empty tombstoned selection, Select All selects all visible trash entries of the current selection's kind — the kind-homogeneity invariant decides which "all" is meant; in every other state it selects all visible live cards.
- **⌫/⌘⌫ delete** (resettled 2026-07-28): on cards, a move into the trash (`.trash/`, top position — 03-board-ui.md); on a **trash** selection the same chord deletes **permanently** (one Delete vocabulary, staged by place — confirmation per 03's recoverability rule); on lanes, a real delete — folder and contents, no dialog, native undo the net (03 ▸ Trash). Selection moves to the deleted item's successor sibling, Finder-style (next card in the lane, next lane on the board; the last sibling's predecessor otherwise; empty container = nothing selected) — repeated ⌫ walks down a lane. Deliberate deletes pick a successor; *external* vanishing never does (02-architecture.md's reload-survival rule: the selection just shrinks). **Put Back is retired with the tombstone model** (resettled 2026-07-28): File ▸ Delete is the chord's only owner — no twin menu items, no shared-equivalent routing; restore is drag-out or ⌘X/⌘V (The trash below). Plain ⌫ performs the same delete as fixed grammar (see Grammar above) — there is no Edit ▸ Delete item, so the two Delete-titled homes never collide for title-matched remapping.
- **Select All**: all visible cards on the board — filter-respecting, like every surface (Search below). **On the active trash side it selects the trash** (resettled 2026-07-28): with the trash visible and a non-empty trash selection, Select All selects all visible trash cards; in every other state, all visible live cards — the container boundary decides which "all" is meant (The trash below).
- **The contract's one carve-out is configuration** (settled): form-like git and board setup — add git, add/change remote, branch switching and creation, commit identity, credentials — lives in the board popover only, its committed home; its keyboard path is Board Info (⌘I) plus Tab-reachable controls (10-accessibility.md's Full Keyboard Access). Recurring remote *operations* stay under the contract: Board ▸ Pull and Board ▸ Push are menu items (no default chord, remappable; validation enables them only on remote-backed boards — 07-sync-collab.md).
- **⌘N target rule** (settled): with a card selected, the new card is created in that card's lane, immediately after it (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency); **a multi-selection anchors at its last member in flatten order** (settled — lane `order`, then card `order`, the multi-drag order; the same anchor serves paste): creation follows the last selected card, or appends to the last selected lane; the lane header's new-card button **overrides this rule** — the click names its target lane, selection notwithstanding (11-command-nexus.md ▸ Pointer grammar); with nothing selected — or a **tombstoned** selection, which never anchors creation — the **last-active lane** — the lane that most recently held selection or a creation in this window session — falling back to the first lane. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first.
- **⌘N target rule** (settled): with a card selected, the new card is created in that card's lane, immediately after it (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency); **a multi-selection anchors at its last member in flatten order** (settled — lane `order`, then card `order`, the multi-drag order; the same anchor serves paste): creation follows the last selected card, or appends to the last selected lane; the lane header's new-card button **overrides this rule** — the click names its target lane, selection notwithstanding (11-command-nexus.md ▸ Pointer grammar); with nothing selected — or a **trash** selection, which never anchors creation — the **last-active lane** — the lane that most recently held selection or a creation in this window session — falling back to the first lane. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first.
### The trash, keyboard-first (settled)
### The trash, keyboard-first (resettled 2026-07-28 — the materialized trash)
The trash quasi-lane (03-board-ui.md ▸ Trash) speaks the same keyboard language when shown; hidden, it is invisible to every gesture — and **hiding it clears a tombstoned selection** (settled): nothing invisible stays selected, so the Show Trash toggle-off drops the selection (and with it Put Back / Delete Immediately validation) rather than leave commands enabled against rows nobody can see. Rules:
The trash lane (03-board-ui.md ▸ Trash — cards moved into `<root>/.trash/`, cards only, no lane entries) speaks the board's ordinary keyboard language when shown; hidden, it is invisible to every gesture — and **hiding it clears a selection of trash cards** (nothing invisible stays selected, so the toggle-off drops the selection rather than leave commands enabled against rows nobody can see). Because trash cards are ordinary cards, the old kind- and liveness-boundary machinery is retired: no lane entries, no ancestor walks, no entry-vs-universe split. Rules:
- **Navigation**: the shown trash is the **last container for card navigation** — arrows walk into and out of it, and ⌥→ jumps to it. The quasi-lane itself is never selectable *as a lane* (no lane op applies to it): with a lane selected, ←/→ and ⌥→ stop at the last real lane.
- **Dropping a live card on the shown trash deletes it** (settled 2026-07-28): the drag becomes the pointer's delete gesture — release tombstones the dragged card(s), exactly the ⌫ tombstone. The drop diverges from positional drops in one way: **the shadow always takes the topmost position** which the sort makes honest, not arbitrary: the trash orders by `deleted` newest-first, so a fresh tombstone genuinely lands on top. Lanes are not deliverable this way (a lane drag proposes only lane slots); the trash stays undropppable-into while hidden, like every gesture. Shipped 2026-07-28 (33bf425): cross-board arrivals and ⌥-copies refuse too (a transfer-and-delete compound and a copy-into-the-trash are operations the design doesn't name), and a refusal falls through to the strip retarget rather than cancelling the held drag.
- **Moves are inert across the boundary**: no move or paste ever targets the trash (deleting is ⌫/⌘⌫), and ⌥⌘↑/⌥⌘↓ are inert *on* tombstoned cards (moving out is Put Back or drag-to-restore).
- **Selection is homogeneous by liveness** (extending the homogeneous-selection rule): a selection never mixes live and tombstoned cards. Select All selects visible live cards only; a rubber-band stays on the side of the boundary it started on. **⇧-arrow extension stops at both boundaries — liveness and kind** (settled, the rubber-band's rule applied to the keyboard): a ⇧-arrow whose next step would cross from live cards into the trash (or back), or from card entries onto a lane entry within it, is simply inert — the held range is never silently dropped for a move-and-replace, and the invariants below are unbreakable by keyboard. Plain arrows still walk across (navigation moves, extension stops). Menu validation stays binary — Delete for live selections, Put Back / Delete Immediately for tombstoned ones. External liveness flips can't breach the invariant: a reload that flips `deleted:` on a selected card ejects it from the selection (02-architecture.md's reload-survival rule — a flip is a vanish from its side of the boundary), so validation never sees a mixed selection.
- **Tombstoned lane entries are full keyboard citizens, homogeneous by kind**: arrows walk every trash entry in its sorted order — card and lane entries alike (a lane's single restorable entry, 03-board-ui.md ▸ Trash) — and the board's cards-XOR-lanes rule extends into the trash: a selection never mixes card entries and lane entries (on top of never mixing live and tombstoned). Put Back (⌘⌫) and Delete Immediately (⌥⌘⌫) apply to lane entries exactly as to cards — a put-back lane returns whole, cards and all. A lane entry is not draggable (its entry is a compact row, not the lane); its copy-out is ⌘C only, and its move-out is Put Back. **Pointer ranges keep the kind invariant by skipping, not stopping** (settled): a ⇧-click range collects only entries of the anchor's kind, skipping other-kind rows inside the span — the pointer names an unambiguous same-kind target, so it skips where the keyboard (which must cross row by row) goes inert; a trash-side marquee takes the kind of the topmost intersected entry and selects only that kind. Both keep every selection kind-homogeneous, each in the idiom of its input. **The column's gesture surface is full height** (settled): the trash column carries a full-height backdrop like the board background, so a marquee can arm from the blank area below the last row — the board side has no dead zone and the trash side doesn't either.
- **Clipboard: copy out only.** ⌘C (cards and lane entries), ⌥-drag, and the cross-board drag default (cards) always yield *live* copies — `deleted:` is stripped on paste/duplicate/drop, like copying a file out of Finder's Trash; a lane entry's copy additionally strips its tombstoned interior cards (the lane-copy rule — copies transfer content, and trash isn't content). ⌘X is disabled: the move-out vocabulary is Put Back or drag-to-restore, nothing else. The *cross-board restore-move* (⌘-drag below) needs no command of its own — its keyboard equivalent is the composition Put Back → ⌘X → ⌘V in the destination: same folder, same identity.
- **Everything edit-shaped is disabled** on tombstoned selections — Open Card, Rename, Style… (File ▸ Duplicate is untouched: it duplicates the board, never the selection — 11-command-nexus.md; card copies out of the trash are ⌘C or ⌥-drag, which name a live destination). Finder file drops (attachment import) on tombstoned cards are inert — 03-board-ui.md's no-editing-in-the-trash.
- **Drag-to-restore follows the locality model**: dropping a tombstoned card into one of its own board's lanes restores it at the drop position (`deleted:` removed, `order` set). Dropped on *another* board it follows the copy default — a live copy lands there and the tombstoned original stays in the source trash (copy-out, like ⌘C); ⌘-drag forces the true cross-board restore-move (the tombstone leaves the source board; ordinary cross-board move semantics, `deleted:` cleared at the destination).
- **Navigation**: the shown trash is the **last container for card navigation** — arrows walk into and out of it, and ⌥→ jumps to it. The trash lane itself is never selectable *as a lane* (no lane op applies to it): with a lane selected, ←/→ and ⌥→ stop at the last real lane.
- **Dropping a live card on the shown trash deletes it** (settled): the drag is the pointer's delete gesture — release moves the dragged card(s) into `.trash/`. The drop diverges from positional drops in one way: **the shadow always takes the topmost position** — honest, not arbitrary: every trash arrival mints a rank above the current top (03 ▸ Trash), so a fresh delete genuinely lands on top. Lanes are not deliverable this way (a lane drag proposes only lane slots); the trash takes no drops while hidden, like every gesture. Cross-board arrivals and ⌥-copies refuse too (a transfer-and-delete compound and a copy-into-the-trash are operations the design doesn't name), and a refusal falls through to the strip retarget rather than cancelling the held drag.
- **Selection keeps one container boundary**: a selection never mixes trash cards with board cards — a single container rule replacing the old liveness law, because Delete would otherwise mean two different things in one gesture (move-to-trash vs permanent). The rubber band stays on the side it started on, ⇧-arrow extension stops at the boundary (plain arrows walk across — navigation moves, extension stops), and Select All with a non-empty trash selection selects all visible trash cards, otherwise all visible live cards. Menu validation stays binary by container: Delete = move to trash on board selections, Delete = permanent on trash selections (03 ▸ Trash). An external move observed by reload re-resolves the selection by presence, as everywhere (02-architecture.md).
- **Within-trash moves are inert**: no move or paste ever targets the trash (deleting is ⌫/⌘⌫ or the drag above), and ⌥⌘↑/⌥⌘↓ are inert on trash cards — the trash's order is its arrival order, not a workspace to arrange.
- **Clipboard: the restore path.** ⌘C copies a trash card (a live copy lands wherever pasted — like copying out of Finder's Trash); **⌘X works** (resettled — it was disabled under the tombstone model): cut in the trash, paste into a lane is the keyboard-native restore, an ordinary folder move (10-accessibility.md's drag-free contract). **A card entering the trash voids its pending cut** (the deliberate-removal rule): a cut card that gets deleted drops out of the pending cut, as under the old model.
- **Everything edit-shaped is disabled** on trash selections — Open Card, Rename, Style…; Finder file drops on trash cards are inert (03's no-editing-in-the-trash). Creation never anchors to the trash: ⌘N and paste with a trash selection fall back to their nothing-selected targets.
- **Drag-to-restore follows the locality model**: dropping a trash card into one of its own board's lanes is an ordinary move to the drop position. Dropped on *another* board it follows the copy default — a live copy lands there, the original stays in the source trash; ⌘-drag forces the true cross-board restore-move.
### Configurable bindings (settled)
@@ -77,7 +76,7 @@ Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App
- **Menu item titles are API.** The mechanism matches on exact titles — renaming a menu item orphans users' bindings. Titles change only with the deliberateness of a schema change.
- **Toggles keep one stable title** with a checkmark state — "Show Trash" stays "Show Trash" when checked, never becomes "Hide Trash". (Same for Edit Body and Raw Source.)
- **Undo/Redo are effectively not remappable** — NSUndoManager rewrites their titles dynamically ("Undo Move Card…"), which defeats title matching. Accepted; nobody remaps ⌘Z.
- **Two items may share a default chord when validation is mutually exclusive** (Delete / Put Back on ⌘⌫) — AppKit fires the enabled one. Each keeps its own stable title, so remapping stays per-item. Corollary: no two menu items share a *title* either (titles are the remap key), which is why plain-⌫ delete is grammar rather than a second Delete item.
- **Two items may share a default chord when validation is mutually exclusive** (a pattern currently unused — Put Back's retirement removed its one instance) — AppKit fires the enabled one. Each keeps its own stable title, so remapping stays per-item. Corollary: no two menu items share a *title* either (titles are the remap key), which is why plain-⌫ delete is grammar rather than a second Delete item.
## Accessibility
+2 -2
View File
@@ -79,7 +79,7 @@ The card-level styling home: the **embedded style editor** — background palett
### Actions (bottom)
- **Delete** — tombstones the card (destructive styling; the window then dismisses itself per Deletion & lifecycle below; recoverable from the board's trash quasi-lane).
- **Delete** — moves the card to the trash (destructive styling; the window then dismisses itself per Deletion & lifecycle below; recoverable from the board's trash lane — 03-board-ui.md).
- **Reveal in Finder** — the card's folder.
## Window
@@ -91,7 +91,7 @@ The card-level styling home: the **embedded style editor** — background palett
## Deletion & lifecycle
- The window follows its card across lanes (keyed by board URL + GUID) — *within its board*. A **cross-board move dismisses the window like a delete**: the card left this board — its UUID travels with the move (reminted only on an import-boundary collision, 01-storage-format.md's identity lifecycle), but the window's key is board URL + GUID, and the board half no longer names it.
- Window dismisses itself if the card is deleted — and a **tombstone counts as deleted**: ⌫ on the board closes the card's open window (the card is gone from the board's perspective; Put Back and reopen if it was a slip). **Liveness is effective — ancestor-walked** (settled; 02-architecture.md's rule extended to its last consumer): tombstoning or deleting the card's *lane* dismisses the window too, the card's own flag notwithstanding — the card renders nowhere and the trash shows only the lane's single subsuming entry, so no window may stay open, much less writable, over it. Put Back of the lane reopens nothing — reopening is the user's act, like any open. **Dismissal never eats typed work silently where a save can land** (settled): a dirty Edit buffer flushes into the tombstoned card's folder before the window dismisses — a surgical body write that leaves `deleted:` intact, so the keystrokes survive Put Back and enter history on git boards (the composer reads it as an edit to a deleted card — accurate). An open raw-source buffer discards instead: its Apply writes the *whole* pre-tombstone `index.md` and would silently undelete the card — a foreign delete is never reverted by a stale buffer. A card hard-deleted externally (folder gone) discards both — nowhere left to write, the inline-rename rule. Cards in the shown trash quasi-lane don't open at all — restore first (03-board-ui.md). Reopening a live card focuses the existing window.
- Window dismisses itself if the card is deleted — and **entering the trash counts as deleted** (resettled 2026-07-28, the materialized trash): ⌫ on the board closes the card's open window (the card left the working set; restore and reopen if it was a slip), an external move into `.trash/` observed by reload does the same, and deleting the card's *lane* deletes the card with it — the window dismisses because the card is gone. Restoring reopens nothing — reopening is the user's act, like any open. **Dismissal never eats typed work silently where a save can land** (settled): a dirty Edit buffer flushes into the card's folder at its new `.trash/` location before the window dismisses — a surgical body write, so the keystrokes survive a later restore and enter history on git boards (the composer reads it as an edit to a trashed card — accurate). An open raw-source buffer discards instead: its Apply would write a whole stale `index.md` over the trashed card — a delete is never fought by a stale buffer. A card whose lane was deleted discards both — nowhere left to write. A card hard-deleted externally (folder gone) discards both — nowhere left to write, the inline-rename rule. Cards in the shown trash lane don't open at all — restore first (03-board-ui.md). Reopening a live card focuses the existing window.
## Changes from Kanban
+3 -3
View File
@@ -2,7 +2,7 @@
**Edition scope: Lanework Pro** (12-editions.md). This doc is the git HistoryProvider; base Lanework ships mode:none only, with macOS-native undo (13-native-undo.md) and the inert-`.git` posture (12). The Undo routing section below is edition-independent — both substrates dispatch through it.
Git is the undo substrate — on boards that have git. **Git is opt-in per board (a pivot from the pathfinder, which auto-initialized every board): a board may be created without git, and git can be added later** (via the board popover; see 07-sync-collab.md's mode progression). A board without git has **no undo/redo** (board history, that is — text editors keep their standard typing undo everywhere; see Undo routing below) — consistent with the settled no-undo stance for repo-nested boards; deletes are the exception, recoverable on every board via the tombstone trash (03-board-ui.md). On git-enabled boards, every settled change auto-commits; those mechanics are carried over from the pathfinder with their hard rules intact.
Git is the undo substrate — on boards that have git. **Git is opt-in per board (a pivot from the pathfinder, which auto-initialized every board): a board may be created without git, and git can be added later** (via the board popover; see 07-sync-collab.md's mode progression). A board without git has **no undo/redo** (board history, that is — text editors keep their standard typing undo everywhere; see Undo routing below) — consistent with the settled no-undo stance for repo-nested boards; card deletes are the exception, recoverable on every board via the materialized trash (03-board-ui.md; lane deletes lean on native undo in-session — 13-native-undo.md). On git-enabled boards, every settled change auto-commits; those mechanics are carried over from the pathfinder with their hard rules intact.
## Rules
@@ -30,7 +30,7 @@ Git is the undo substrate — on boards that have git. **Git is opt-in per board
The pathfinder's message engine carries over as the model — it is what earns the "semantic" in semantic commit messages, and it stays a pure, testable function:
- **Pure snapshot diff, no write-site tagging.** Messages compose at commit time from a structural diff of two board snapshots (last-committed vs. current) — never by intercepting operations. Items match by id across the *whole* board, so a lane change is distinguishable from delete+add and a cross-lane move reads as a move. Bookkeeping — `order` changes that preserve sibling sequence (a renumber's rescale — 01-storage-format.md), `modified`/`created` — produces no events: a diff touching only those composes nothing. Sequence is what the diff compares, not raw `order` values: an order change that *repositions* an item among its siblings composes Reorder, so a foreign writer's single-file reorder still reads as one. A midpoint-exhaustion renumber batches with the insert or move that triggered it, so its commit reads as that event.
- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus Attach / Remove for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's banner-gated fix; app-mediated, so the composer names it directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: `deleted:` appearing is Delete (the tombstone), `deleted:` *clearing* is Restore ("Restore card 'X'" — Put Back, drag-to-restore), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — Delete Immediately, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows fall back to "Update board" — always with a bulleted body naming every event, so the oneline log stays scannable and the full message stays complete.
- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus Attach / Remove for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's banner-gated fix; app-mediated, so the composer names it directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: a move into `.trash/` is Delete, a move out of it is Restore ("Restore card 'X'" — drag-to-restore, cut+paste), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — Delete Immediately, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows fall back to "Update board" — always with a bulleted body naming every event, so the oneline log stays scannable and the full message stays complete.
- **Implied events don't steal the subject**: deleting a lane with five cards reads "Delete lane 'X'" with the card deletions as body bullets — not "Update board".
- **Non-snapshot files commit too** (settled — the repo tracks more than the model: the agent guide, `CLAUDE.user.md`, the seeded `.gitignore`, and strays at every level): the committer **stages the whole board root** — whatever `git status` shows, `.gitignore` respected, **open Edit sessions still staged around** (settled): the session card's folder stays excluded exactly as in Rules ▸ Auto-commit, whole-root staging widening *what* commits, never overriding the exclusion — and its commit condition is the *tree*, not the snapshot diff, so a stray-only window commits rather than leaving the tree dirty (firing mid-session, it commits the strays and leaves the session folder untouched) (a permanently dirty stray would break branch switch's cannot-fail-dirty guarantee and void flush-before-overwrite for every file the model can't see). The composer's input extends accordingly: beside the snapshot diff it receives the changed-path list, and non-snapshot paths compose **path-shaped events**`CLAUDE.md` composes "Update agent guide (vN)", the version read from the guide's marker first line (a pure function of file content, *not* write-site tagging — the no-interception rule stands); any other non-snapshot path composes "Update '⟨path⟩'", folding plural ("Update 3 files"). Model events keep the subject when present; non-snapshot changes then ride as body bullets — recorded, never silently absorbed under an unrelated subject. Attribution needs no new rule: the EchoLedger (02-architecture.md) is path-keyed, so the app's guide write classifies app-mediated by its receipt (the mechanism behind the guide-attribution exception below) and a stray edit classifies foreign, the two-commit split applying per file as everywhere else.
- **Titles truncate in subjects only** (~40 chars, keeping `git log --oneline` sane); body lines carry full titles. An untitled item reads "(untitled)" — never a bare `""` (a pathfinder edge fixed, not carried). Undo/redo restores commit as "Undo: ⟨subject⟩" / "Redo: ⟨subject⟩"; the undo-menu labels are the *crossed* commit's subject, so labels never nest.
@@ -70,7 +70,7 @@ Agent and hand edits arrive through the watcher like any change and get auto-com
- **`.gitignore` seeded at init, never touched after.** Adding git to a board writes a minimal `.gitignore` (`.DS_Store`) if none exists; the app never edits an existing one and never manages the file afterward — it's the user's from then on. (Repo-nested boards have no app-managed git, so no app `.gitignore` either.)
- **Repo growth is accepted.** Unbounded history is the price of never-rewrite, and every attachment version lives in the repo forever. The app may run safe libgit2 housekeeping (repacking loose objects) periodically — it rewrites nothing. Content-removing compaction is explicitly out (it would rewrite history); size tooling joins the wishlist if growth ever bites in practice.
- **Deleting never forgets.** On a git board, deleting a card removes it from the board but never from history — every version of its content and attachments stays reachable in any git client, and even the future tombstone purge (01-storage-format.md) only cleans the working tree. This is part of the design; users should learn it here, not from a repo browser.
- **Deleting never forgets.** On a git board, deleting a card removes it from the board but never from history — every version of its content and attachments stays reachable in any git client, and even a trash purge (Empty Trash, or the future age-based auto-purge 01-storage-format.md) only cleans the working tree. This is part of the design; users should learn it here, not from a repo browser.
## Changes from Kanban
+1 -1
View File
@@ -14,7 +14,7 @@ A board may be created plain — **without any git repository** (a pivot from th
## Mode: none (local-only)
Plain folders on local disk. **No git repository at all** — and therefore, since git is the undo substrate, no undo/redo (06-history-undo.md). FSEvents live-reload works as on any board. Adding git later initializes the repo and moves the board to git mode. Honest caveat: without git there is no commit-before-overwrite protection, so on a no-git board **real data loss is possible** (e.g. concurrent or external overwrites) — accepted; adding git is the remedy. Repo-nested boards share this caveat: a repo exists, but the app manages no git there (06-history-undo.md), so its protections never run — committing is the user's own workflow. Deletion is the exception: the tombstone trash (03-board-ui.md) makes deletes recoverable even without git — overwrites are the lossy case, and Empty Trash is deliberate.
Plain folders on local disk. **No git repository at all** — and therefore, since git is the undo substrate, no undo/redo (06-history-undo.md). FSEvents live-reload works as on any board. Adding git later initializes the repo and moves the board to git mode. Honest caveat: without git there is no commit-before-overwrite protection, so on a no-git board **real data loss is possible** (e.g. concurrent or external overwrites) — accepted; adding git is the remedy. Repo-nested boards share this caveat: a repo exists, but the app manages no git there (06-history-undo.md), so its protections never run — committing is the user's own workflow. Deletion is the exception: the materialized trash (03-board-ui.md) makes card deletes recoverable even without git — overwrites are the lossy case, and Empty Trash is deliberate.
## Mode: git
+1 -1
View File
@@ -17,7 +17,7 @@ The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-f
- Creating cards (mkdir UUID, write `index.md`, ordering rules) — plain UTF-8, no BOM, preserving each file's existing line endings (01-storage-format.md's encoding contract).
- Moving between lanes (folder move), reordering (gapped ranks, only touch the moved item).
- Tombstone deletes, colors/icons.
- Deletes — move the card folder into `<root>/.trash/` (top position; never delete a card folder outright unless permanence is meant); colors/icons.
- New in the rewrite: **attachments** (the `attachments/` convention, importing files) — including the rule that **card files belong in `attachments/`**: a loose file written beside `index.md` will be relocated there by the app with a notice (01-storage-format.md's loose-file carve-out), so agents should put it there in the first place.
- New in the rewrite: **`modified-by` self-stamping** — stamp files you write; re-stamp every write *and every move* (the app clears it on its own writes; a bare folder move leaves the card unstamped); self-commit instead when you need exact authorship.
- New in the rewrite: a pointer to the optional **`CLAUDE.user.md`** (see below), instructing agents to read it when present.
+3 -3
View File
@@ -27,7 +27,7 @@ All ten pathfinder templates carry over: Basic, Classic Kanban, Software Project
### Instantiation
Creating a board from a template: copy the tree — **skipping tombstoned items** (Save as Template already strips them, but hand-dropped user templates can carry them; a new board isn't born with trash) — **mint fresh GUIDs** for every lane/card folder, stamp `created`/`modified` fresh (the stated exception to 01-storage-format.md's copies-keep-`created` rule — a new board is born today, not forked from the template), seed the save panel's suggested name from the template title, and **set the new board's `title` to the user-chosen document name** (per 01-storage-format.md's board-naming rule, so display name and folder name start out matching). The `template:` key is kept — inert on an ordinary board. **`.git` is never copied** — a template is content, not history, and a hand-dropped user template that carries one must not produce boards that are silently in git mode (06-history-undo.md's no-silent-auto-init — the principle is *never give the user a repo they didn't ask for*, and it scopes to instantiation: File ▸ Duplicate deliberately carries `.git`, because a duplicate of a git board is a fork of its history — 03-board-ui.md): an instantiated board is never in *git* mode — its actual mode follows 06's nearest-`.git`-wins detection at the destination the save panel chose: mode `none` in a plain folder, repo-nested when saved inside an existing repository (no undo, no add-git — the popover explains, 06-history-undo.md). Beyond the `template:` residue, the result is indistinguishable from a hand-built board.
Creating a board from a template: copy the tree — **skipping `.trash/`** (Save as Template already strips it, but hand-dropped user templates can carry one; a new board isn't born with trash) — **mint fresh GUIDs** for every lane/card folder, stamp `created`/`modified` fresh (the stated exception to 01-storage-format.md's copies-keep-`created` rule — a new board is born today, not forked from the template), seed the save panel's suggested name from the template title, and **set the new board's `title` to the user-chosen document name** (per 01-storage-format.md's board-naming rule, so display name and folder name start out matching). The `template:` key is kept — inert on an ordinary board. **`.git` is never copied** — a template is content, not history, and a hand-dropped user template that carries one must not produce boards that are silently in git mode (06-history-undo.md's no-silent-auto-init — the principle is *never give the user a repo they didn't ask for*, and it scopes to instantiation: File ▸ Duplicate deliberately carries `.git`, because a duplicate of a git board is a fork of its history — 03-board-ui.md): an instantiated board is never in *git* mode — its actual mode follows 06's nearest-`.git`-wins detection at the destination the save panel chose: mode `none` in a plain folder, repo-nested when saved inside an existing repository (no undo, no add-git — the popover explains, 06-history-undo.md). Beyond the `template:` residue, the result is indistinguishable from a hand-built board.
### Why this format
@@ -38,12 +38,12 @@ Creating a board from a template: copy the tree — **skipping tombstoned items*
## Save as Template
A "Save as Template" function copies the current board into the user templates store; the chooser lists user templates after the bundled ones. **The copy is preceded by the close flush** (02-architecture.md ▸ Windows: editor saves, then the pending auto-commit — with the pull-style mechanical exception committing an open Edit session's on-disk saves as-is, sessions staying open), so the template never misses the last keystrokes; the same rule covers File ▸ Duplicate (03-board-ui.md), where the flush also keeps the copied `.git`'s history from lagging its tree. Under the read-only lock, Save as Template disables with one exception — the unwritable-location state, where it stays live unless an open Edit or raw-source session holds unsaved content the suspended saves can't flush (reads the board, writes Application Support; 02-architecture.md ▸ Live-reload resilience has the settled scoping). Because a template *is* a board, the copy is nearly literal: **`.git` is not copied** (a template is content, not history — copying it would embed the board's full repo, every attachment version included, in the template store; see 06-history-undo.md's repo-growth note), tombstoned items are dropped, a `template:` key is added — or, when the board already carries one (e.g. it was itself instantiated from a template), its stale `order` is overwritten — with an order appended after existing user templates; GUIDs are left as-is (instantiation mints fresh ones anyway), and timestamps are kept per 01-storage-format.md's copies-keep-`created` rule (equally inert — instantiation restamps them).
A "Save as Template" function copies the current board into the user templates store; the chooser lists user templates after the bundled ones. **The copy is preceded by the close flush** (02-architecture.md ▸ Windows: editor saves, then the pending auto-commit — with the pull-style mechanical exception committing an open Edit session's on-disk saves as-is, sessions staying open), so the template never misses the last keystrokes; the same rule covers File ▸ Duplicate (03-board-ui.md), where the flush also keeps the copied `.git`'s history from lagging its tree. Under the read-only lock, Save as Template disables with one exception — the unwritable-location state, where it stays live unless an open Edit or raw-source session holds unsaved content the suspended saves can't flush (reads the board, writes Application Support; 02-architecture.md ▸ Live-reload resilience has the settled scoping). Because a template *is* a board, the copy is nearly literal: **`.git` is not copied** (a template is content, not history — copying it would embed the board's full repo, every attachment version included, in the template store; see 06-history-undo.md's repo-growth note), `.trash/` is dropped, a `template:` key is added — or, when the board already carries one (e.g. it was itself instantiated from a template), its stale `order` is overwritten — with an order appended after existing user templates; GUIDs are left as-is (instantiation mints fresh ones anyway), and timestamps are kept per 01-storage-format.md's copies-keep-`created` rule (equally inert — instantiation restamps them).
Two edges, settled:
- **Store collisions auto-rename, Finder-style** (`Board.kanban``Board 2.kanban`) — the 01 import precedent: saving never overwrites an existing template and never refuses.
- **Strays copy through.** The copy is literal apart from the stated exclusions (`.git`, tombstoned items) — `CLAUDE.user.md`, a seeded `.gitignore`, and other non-schema files carry through Save as Template *and* instantiation alike. Deliberate: a template is the folder, and `CLAUDE.user.md` carrying a board's custom agent instructions into boards born from it is a feature. The app-owned `CLAUDE.md` copies inertly and self-heals to the current guide version when the new board is opened (08-agent-integration.md).
- **Strays copy through.** The copy is literal apart from the stated exclusions (`.git`, `.trash/`) — `CLAUDE.user.md`, a seeded `.gitignore`, and other non-schema files carry through Save as Template *and* instantiation alike. Deliberate: a template is the folder, and `CLAUDE.user.md` carrying a board's custom agent instructions into boards born from it is a feature. The app-owned `CLAUDE.md` copies inertly and self-heals to the current guide version when the new board is opened (08-agent-integration.md).
**Storage (settled): Application Support** (`…/Lanework/Templates/`, inside the app container) as the canonical store — friction-free sandbox writes, no location ceremony — kept honest by a **Reveal in Finder** affordance in the template chooser: revealed, it's plain board folders, hand-editable and agent-writable, and a board folder dropped in becomes a template — **no `template:` key required**. Chooser order: bundled templates by `template.order`, then keyed user templates by `template.order`, then keyless user boards last, sorted by display name (`title` ?? folder name — 01-storage-format.md's board naming). An unloadable user template sorts with the keyless tier, by folder name — the failed load can supply neither `template.order` nor `title`, so the folder name is the only identity it has (and the one its unloadable row already shows). The app never stamps a key into store files it didn't write itself — a hand-dropped board is never touched, and adding a key by hand is how its user picks a position; the one writer of keyed files is Save as Template, whose own copies arrive keyed (above). A user-visible or user-configurable location was considered and set aside as ceremony disproportionate to a secondary feature; revisit if template sharing becomes a real workflow.
+4 -4
View File
@@ -13,9 +13,9 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
- **Tree shape**: window → lanes (accessibility containers, in lane `order`) → cards (leaf elements, in card `order`). A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like the visible badge (04-interactions.md). The lane header's new-card button is a labeled child ("New card in ⟨lane⟩"). A card is **one flattened element**: label = title (or the untitled placeholder), value carries the attachment count when present, selected state via trait. Face icon and chips are decorative — folded into the element, never separately focusable: the flattened element carries the attachment count in its value, and the accessible attachment surface is the card window's keyboard-native section (below); the face itself has no media presentation (03-board-ui.md's no-carousel resettlement).
- **Logical order, not masonry position** (decided): within a wide lane, VoiceOver reads cards by `order` — the interior grid columns are presentation only. This deliberately diverges from on-screen geometry; the spatial arrow-key model (04-interactions.md) remains available alongside, since board keyboard navigation keeps working with VoiceOver running.
- **VO cursor and app selection are independent** (Finder-style): moving the VoiceOver cursor never mutates selection. VO-Space on a card toggles its selection (the ⌘-click analogue — a toggle, never plain click's replace, 04-interactions.md ▸ Selection); ⌘↩ opens the card window; arrow keys and ⇧-arrows drive selection exactly as without VoiceOver. Selection state is always readable from the element (trait), and cut cards expose their dimmed pending state in the value ("cut, pending paste").
- **Actions come from the context menu.** Context menus are the single inventory of per-item actions (Open, Rename, Delete, Put Back, Delete Immediately, width stepper, …), reachable the standard way (VO-⇧-M); where SwiftUI additionally surfaces menu items as custom accessibility actions, that's free improvement, not a separate design surface.
- **Actions come from the context menu.** Context menus are the single inventory of per-item actions (Open, Rename, Delete, Delete Immediately, width stepper, …), reachable the standard way (VO-⇧-M); where SwiftUI additionally surfaces menu items as custom accessibility actions, that's free improvement, not a separate design surface.
- **Rotor**: lane titles are headings, so the headings rotor jumps lane-to-lane — on a one-dimensional board that *is* structural navigation; no custom rotors unless practice shows the need.
- **Trash quasi-lane**: when shown (View ▸ Show Trash), it is the last container, labeled as Trash with its count; toggling visibility is announced. Tombstoned cards read their deletion state and expose Put Back / Delete Immediately via the context menu; there is no Open (03-board-ui.md's no-editing-in-the-trash). A tombstoned lane's single entry reads "⟨title⟩, deleted lane, N cards" and exposes the same actions; keyboard reachability follows 04-interactions.md's homogeneous-by-kind trash rules.
- **Trash lane**: when shown (View ▸ Show Trash), it is the last container, labeled as Trash with its count; toggling visibility is announced. Its cards are ordinary card elements (resettled 2026-07-28 — the materialized trash: no lane entries, no special states beyond the container) exposing Delete and Reveal in Finder via the context menu; there is no Open (03-board-ui.md's no-editing-in-the-trash), and restore is drag-out or the ⌘X/⌘V keyboard path; keyboard reachability follows 04-interactions.md ▸ The trash.
- **Search**: filtered-out cards leave layout and the accessibility tree together — the filter is the single source of truth for "what's on the board" (04-interactions.md), and the tree is one of its readers. Lane counts announced reflect the filter.
## Moving without dragging
@@ -28,7 +28,7 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
## Live board announcements
- **Foreign changes announce, app-mediated echoes never do.** The auto-committer already classifies every observed change as app-mediated or foreign (the EchoLedger — 02-architecture.md ▸ Components) and synthesizes diff summaries for commit messages (06-history-undo.md); announcements reuse that summarizer — one polite (non-interrupting) digest per reload debounce ("Board changed: 2 cards edited, 1 card added"), never per-file chatter. On no-git boards the same classifier runs without the committer — announcements don't depend on git mode.
- **A vanishing focus is called out specifically.** If the selected or VO-focused card disappears in a reload (deleted externally, or hidden by a lane tombstone), the announcement names it ("Card 'Fix login' was deleted externally") and focus recovers to the card's lane (mirroring selection's reload-survival rules, 02-architecture.md). **When the lane itself vanished, recovery walks up then sideways** (settled): focus lands on the lane now occupying the vanished lane's position — the next lane by `order`, else the previous one — and on the board container only when no lanes remain (the ⌫-successor pattern, 04-interactions.md, applied to external change; never into the trash, which stays hidden — no layout side effects from a foreign edit). The announcement then names the *lane*, not the card ("Lane 'Doing' was deleted externally, with 5 cards") — the implied-events-don't-steal-the-subject discipline of 06-history-undo.md's composer, applied to speech.
- **A vanishing focus is called out specifically.** If the selected or VO-focused card disappears in a reload (deleted externally, or gone with its deleted lane), the announcement names it ("Card 'Fix login' was deleted externally") and focus recovers to the card's lane (mirroring selection's reload-survival rules, 02-architecture.md). **When the lane itself vanished, recovery walks up then sideways** (settled): focus lands on the lane now occupying the vanished lane's position — the next lane by `order`, else the previous one — and on the board container only when no lanes remain (the ⌫-successor pattern, 04-interactions.md, applied to external change; never into the trash, which stays hidden — no layout side effects from a foreign edit). The announcement then names the *lane*, not the card ("Lane 'Doing' was deleted externally, with 5 cards") — the implied-events-don't-steal-the-subject discipline of 06-history-undo.md's composer, applied to speech.
- **Bracketed operations announce once, at completion** ("Pulled 3 commits", "Switched to branch 'redesign'") — never their internal churn (02-architecture.md's bracketing). The live-reload-resilience banner (02-architecture.md) is an accessibility element and is announced when it appears and when it clears — including the read-only lock after a failed bracketed reload.
## Card window, welcome, template chooser, popover
@@ -50,7 +50,7 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
## Verification
- **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`) runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome, template chooser, board popover.
- **A manual VoiceOver smoke script** lives with the test plan: create lane → create card → rename → cut/paste to another lane → external edit lands (announcement heard) → delete → Put Back → Empty Trash. Run per release; it is the canonical "does the board actually work blind" check.
- **A manual VoiceOver smoke script** lives with the test plan: create lane → create card → rename → cut/paste to another lane → external edit lands (announcement heard) → delete → restore from the trash (⌘X/⌘V) → Empty Trash. Run per release; it is the canonical "does the board actually work blind" check.
## Changes from Kanban
+5 -6
View File
@@ -29,20 +29,19 @@ The single source of truth for **every command and action the app can perform**
| File | Save as Template | — (no default) | Board window; 09-templates.md |
| File | Reveal in Finder | — (no default) | Board window: the selection's folder(s), or the board root with nothing selected; card window: the card's folder — the selected attachment's file instead when the attachments section is focused; welcome: the selected recent's folder (disabled on unavailable rows) — the context-menu entry's required twin |
| File | Add Attachment… | ⇧⌘A | Card window |
| File | Delete | ⌘⌫ | Board window, live selection (chord twin of Put Back — validation enables exactly one). Deliberately **not** extended to the card window: an enabled ⌘⌫ key equivalent would steal delete-to-line-start from the window's text surfaces, so there the card's delete is the sidebar Actions button (05) |
| File | Put Back | ⌘⌫ | Board window, tombstoned selection (chord twin of Delete) |
| File | Delete Immediately | ⌥⌘⌫ | Board window, tombstoned selection; confirmed on boards without git history (mode none / repo-nested), immediate on git boards — 03 ▸ Trash |
| File | Delete | ⌘⌫ | Board window, any card or lane selection — staged by place (resettled 2026-07-28): board cards move to `.trash/`, trash cards delete permanently (03's recoverability confirm), lanes delete physically. Deliberately **not** extended to the card window: an enabled ⌘⌫ key equivalent would steal delete-to-line-start from the window's text surfaces, so there the card's delete is the sidebar Actions button (05) |
| File | Delete Immediately | ⌘⌫ | Board window, card selection — skips the trash from anywhere; confirmed on boards without git history (mode none / repo-nested), immediate on git boards — 03 ▸ Trash |
| File | Empty Trash… (confirmed) | ⇧⌘⌫ | Board window, trash shown and non-empty (whole-trash scope, search-independent — 03 ▸ Trash) |
| File | Close | ⌘W | Any window; flushes per 02 ▸ Windows |
| Edit | Undo / Redo (M) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards, during 06's abnormal-state pause (detached HEAD, in-progress merge/rebase), and under the read-only lock (02) |
| Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copy-out only (card and lane entries), ⌘X disabled (04 ▸ The trash); text editors: standard text clipboard |
| Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copies out and ⌘X/⌘V is the keyboard restore path (resettled 2026-07-28 — 04 ▸ The trash); paste never targets the trash; text editors: standard text clipboard |
| Edit | Select All | ⌘A | Board: all visible live cards (filter-respecting); text editors: the text |
| Edit | Find | ⌘F | Board window: board search (04 ▸ Search); card window: find-in-text (05) |
| Edit | Find Next / Find Previous | ⌘G / ⇧⌘G | Card window: the find bar's stepping; disabled in the board window — board search is a live filter, not a cursor. **Use Selection for Find (⌘E) is deliberately absent**: the chord belongs to View ▸ Edit Body, which outranks the text view's binding; a user who wants it back remaps Edit Body system-natively |
| Board | Open Card | ⌘↩ | Board window, sole selected live card; during an inline title edit (placeholder or rename), commits it and opens — the one board command enabled mid-edit (04 ▸ Grammar) |
| Board | Rename | — (cards: Return in place) | Board window, sole selected card/lane; a lane's only rename path (Return on a lane creates); exists for completeness and remapping |
| Board | Style… (the style editor; selection-aware) | ⌥⌘S | Board window: selected cards or lane; nothing selected = the board |
| Board | Move Up / Move Down | ⌥⌘↑ / ⌥⌘↓ | Card selection within one lane (within-lane sort, logical order; non-contiguous selections gather behind their first card on the first press); disabled when the selection spans lanes; inert on lanes and on tombstoned cards |
| Board | Move Up / Move Down | ⌥⌘↑ / ⌥⌘↓ | Card selection within one lane (within-lane sort, logical order; non-contiguous selections gather behind their first card on the first press); disabled when the selection spans lanes; inert on lanes and on trash cards |
| Board | Move Left / Move Right | ⌘← / ⌘→ | Lane selection only (one slot; never into the trash) — cards cross lanes by drag or Cut/Paste, not ⌘-arrows; disabled while any text control is focused (04 ▸ Grammar, caret-chords rule) |
| Board | Increase Lane Width / Decrease Lane Width (the stepper's re-divide semantics, never the window's size — 03 ▸ Lane) | ⌥⌘→ / ⌥⌘← | Selected lane(s) — batches over a multi-lane selection like style (03 ▸ Lane); disabled while any text control is focused (04 ▸ Grammar, caret-chords rule) |
| Board | Pull / Push | — (no default) | Remote-backed boards only (07); disabled during 06's abnormal-state pause (the whole git surface holds) and on an unresolvable remote (07's one-time remote picker case); popover twins exist |
@@ -89,7 +88,7 @@ Context menus are the per-item action inventory VoiceOver reads (10 ▸ The boar
|---|---|
| Card | Open, Rename, Style…, quick-style recents row (03), Delete |
| Lane | One menu, invoked on the header or lane empty space (settled — a full lane still has its header): Rename, Style…, quick-style recents row (03), Width control (stepper — menu twins Increase/Decrease Lane Width), Delete |
| Trash entries | Put Back, Delete Immediately, Reveal in Finder (inspection before a purge; twin of File ▸ Reveal in Finder, which is not edit-shaped and stays enabled on tombstoned selections — 04 ▸ The trash) |
| Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder (inspection before a purge; twin of File ▸ Reveal in Finder, not edit-shaped, enabled on trash selections — 04 ▸ The trash) |
| Attachment row | Open, Remove (system Trash) — twins of the focused section's grammar keys (Return / ⌫ — 05); Reveal in Finder — twin of File ▸ Reveal in Finder in its attachments-focused context |
| Welcome recent | Open, Reveal in Finder, Forget (C — registry management, welcome-scoped) |
+2 -2
View File
@@ -5,7 +5,7 @@ The undo/redo substrate for base Lanework (12-editions.md), filling the one gap
## Rules
- **One stack per board, owned by the board session.** Not per-window: every window over a board (board window, its card windows) shares the store and shares the stack. `window.undoManager` for board surfaces returns the session's manager; 06 ▸ Undo routing applies unchanged — text-editing surfaces get their session-scoped text undo, everywhere else ⌘Z/⇧⌘Z hit the board stack. Undo is board-local, exactly as git undo was.
- **Registration at the Writer boundary.** Every app-mediated mutation already passes through the Writer as a `WriteOperation` (02-architecture.md) — that closed enum is the exact inventory of undoable operations. Each Writer call site registers the inverse operation, computed from the pre-write snapshot the store already holds: move → move back (original lane, original `order`); reorder → restore original `order`; rename → restore title; restyle → restore prior style; resize → restore prior width; Edit-session body save → restore prior body bytes; tombstone (⌫) → restore; restore (Put Back) → tombstone; create → remove the created folder.
- **Registration at the Writer boundary.** Every app-mediated mutation already passes through the Writer as a `WriteOperation` (02-architecture.md) — that closed enum is the exact inventory of undoable operations. Each Writer call site registers the inverse operation, computed from the pre-write snapshot the store already holds: move → move back (original lane, original `order`); reorder → restore original `order`; rename → restore title; restyle → restore prior style; resize → restore prior width; Edit-session body save → restore prior body bytes; card delete (⌫) → move back out of `.trash/`; restore-by-move → move back in; lane delete → recreate the folder from the registered inverse; create → remove the created folder.
- **What is not undoable** (settled): **Permanently delete** (Delete Immediately, Empty Trash) — `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule (03-board-ui.md) already fires on all base boards, since none have git history: the confirm *is* the safety. **Repair** (01-storage-format.md's duplicate-id remint) — undoing a remint would recreate the duplicate id the operation exists to remove. Both match their existing "destructive, confirmed, final" posture.
- **Coalescing follows commit granularity** (settled): one gesture, one undo step — a multi-card move is one step with a plural title; an Edit session is one step, registered at the Edit→Preview flip (the effective Save — 05-card-window.md); a styling batch is one step (03's one-gesture-one-commit rule, substrate swapped). The 06 vocabulary supplies menu titles ("Undo Move 3 Cards"), via NSUndoManager's dynamic retitling — the same naming machinery both editions use.
- **Session-only persistence** (settled): the stack lives with the board session and dies at close/quit — standard macOS behavior. Git undo's survive-relaunch property is a Pro difference, stated honestly (12's matrix).
@@ -15,7 +15,7 @@ The undo/redo substrate for base Lanework (12-editions.md), filling the one gap
## Interaction with the trash
⌫'s undo is restore and Put Back's undo is tombstone — the stack and the trash are two doors to the same tombstone state, never in conflict: undo of a delete is *identical* in effect to Put Back (position-preserving, 01's deletion bullet), just addressed by recency instead of by selection.
⌫'s undo is the move back — a card delete is a move into `.trash/` (resettled 2026-07-28), so its undo is the ordinary inverse move, returning the card to its source lane and rank; a restore-by-move undoes the same way in reverse. The stack and the trash never conflict — they are the same folder moves addressed by recency instead of by selection. A **lane delete's undo recreates the folder** from the registered inverse (the one destructive delete — in-session only, which is the accepted net; git boards additionally keep it in history), and a permanent card delete undoes the same way.
## Out of scope
+1 -1
View File
@@ -13,7 +13,7 @@ Each document covers one aspect of the design. Within each:
| Doc | Aspect |
|---|---|
| [00-vision.md](00-vision.md) | What Lanework is, who it's for, goals and non-goals |
| [01-storage-format.md](01-storage-format.md) | On-disk contract: folders, frontmatter, ordering, tombstones |
| [01-storage-format.md](01-storage-format.md) | On-disk contract: folders, frontmatter, ordering, the materialized trash |
| [02-architecture.md](02-architecture.md) | App structure: source of truth, stores, watchers, concurrency |
| [03-board-ui.md](03-board-ui.md) | Board window: layout, lanes, cards, styling, templates |
| [04-interactions.md](04-interactions.md) | Selection, drag & drop, keyboard, clipboard, search |
+4
View File
@@ -31,3 +31,7 @@ Narrow base Lanework's honest gap vs Pro — foreign/agent edits aren't ⌘Z-abl
### 7. Custom fly-to-slot drop animation
Drags run as system `NSItemProvider` sessions (cross-board transfer, the copy badge), so a successful drop's replica gets AppKit's brief fade — not the fly-to-slot settle DESIGN/03 originally imagined; 03 resettled the drop as the board's (the held overlay renders the arrangement instantly at release). Reclaiming the literal fly-to-slot needs an `NSDraggingSession` bridge: mask the system fade, fly a shadow-window replica to the slot frame, hide the landed item during flight — a compositing dance that must stay correct across masonry reflows and multi-item drags. Fund only if the system fade ever reads as cheap in practice.
### 8. Archive lane (`.archive` — the second materialized reserved lane)
The materialized `.trash/` (DESIGN/03 ▸ Trash, the 2026-07-28 pivot) establishes the pattern: a reserved dot-named container at board root holding ordinary card folders, special only in how cards enter it. An archive lane — `<root>/.archive`, receiving cards that meet a criterion to be designed (age? a Done-lane dwell time? an explicit Archive command?) — is the anticipated second instance. Design open: intake criteria, visibility (a Show Archive toggle like the trash's?), search participation, whether archived cards count anywhere. Nothing committed; the trash proves the mechanism first.