Compare commits

..
10 Commits
Author SHA1 Message Date
rzen a131399c02 Minor doc fixes across the corpus (ten review findings)
- 10 cites 11 for Add Attachment and the lane-width menu items
- 04 uses ⌘/⇧ symbols for click modifiers; card moves scoped to the
  ⌥⌘ chord; ../WISHLIST.md path fixed
- 03 says stepper (plus the menu pair) in the editing-surfaces table
- 06 says one commit per debounce window
- 03/10 Reduce Motion lists aligned at five items; rubber-band
  disambiguated as the lane-resize feedback
- 11 requires the trash shown for Empty Trash
- 02 rewords and-peers

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 16:30:58 -04:00
rzen 1b530e1b9a Unwritable board locations enter the read-only lock at open
Fills the gap: open-time writability probe, cause-naming banner, guide
write skipped-with-log, re-probe on reconciling reloads clears the
lock. Fail loudly, specifically, once.

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 16:29:18 -04:00
rzen 092d226df6 Inline rename: UUID tracking, vanish discards, live filter uncarved
Fills the gap: the rename editor follows its card by UUID through
foreign moves, discards silently when the target vanishes or flips
liveness, and a mid-search rename re-runs the predicate like any edit
— creation stays the search filter's only carve-out.

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 16:27:51 -04:00
rzen 7ef3a64a49 Watcher recovery: reconciling reloads close every blind window
Fills the gap: wake/activation reconciling reloads, MustScanSubDirs and
overflow degrade to a full reload, dead streams are recreated at the
re-resolved root. Stale boards structurally excluded.

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 16:26:58 -04:00
rzen 06d172a804 Clipboard staging lifecycle and loud degraded pastes
Fills the gap: eager staging at copy time, at most the current copy
kept (sweep at launch and each copy against pasteboard references,
surviving relaunch), and attachment-less fallback pastes surface a
one-shot banner naming the loss.

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 16:25:27 -04:00
rzen 5fff6e8244 State the encoding and line-ending contract for index.md
Fills the gap, matching the built engine: strict UTF-8 no-BOM reads
(undecodable or BOM'd files fail fast loudly), BOM-less writes,
per-line ending preservation (CRLF stays CRLF), final newline
preserved, app-created files LF. Agent guide teaches the contract.

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 16:24:41 -04:00
rzen 3d7457d80f Drop semantics under mid-drag foreign reloads
Fills the gap: resting zones recompute per snapshot (frozen inputs are
the dragged items' sizes and the pointer); proposals re-validate by
liveness with release-cancels-when-invalid; an emptied drag cancels
itself, partial vanishing drops survivors.

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 16:23:03 -04:00
rzen 498d23e15e Renamed board root re-resolves by file identity mid-session
Fills the gap: on root-gone signals the bookmark re-resolves first —
success absorbs the rename transparently (watcher re-attach, URL and
window-key re-derive, full reload); only an unresolvable bookmark is
the vanished-root lock.

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 16:22:12 -04:00
rzen 9fb5845a5b Raw-source buffers join the branch-switch settle step
Fills the gap: Save All applies each raw buffer (a validation failure
cancels the switch, focus on the offender), Discard exits without
writing, Cancel keeps everything. External checkouts remain accepted
last-writer-wins per the dirty-buffer rule.

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 16:21:26 -04:00
rzen 7470255886 Write the loader fixture suite, valid and malformed
18 golden fixture boards as real on-disk trees under Fixtures/ — ten
valid/tolerated cases (rich board, interrupted create, non-UUID strays,
stray files, tombstones, tie-breaks, unknown-key order, coercion,
last-wins duplicates, board-level deleted) and eight fail-fast cases,
each pinned to its exact error reason and path; the rich board's whole
tree round-trips byte-identically. Pins two contract subtleties now
recorded in the design: tombstone flags are not inherited (renderers
walk ancestors) and last-wins resolves before order strictness. README
gains the storage-foundation feature entry. 146 tests total.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 16:20:47 -04:00
80 changed files with 922 additions and 20 deletions
+3 -2
View File
@@ -35,6 +35,7 @@ MyBoard.kanban/ ← board = the document
- **Atomic writes**: temp file, rename over `index.md`. - **Atomic writes**: temp file, rename over `index.md`.
- **Unknown frontmatter keys and their order are preserved verbatim** on every rewrite. This is load-bearing for agent overlays (`project:`, `sphere:`, custom tags) — see 08-agent-integration.md. - **Unknown frontmatter keys and their order are preserved verbatim** on every rewrite. This is load-bearing for agent overlays (`project:`, `sphere:`, custom tags) — see 08-agent-integration.md.
- **The app never reformats a body it didn't change** (byte-identical round-trip; plain string comparison, no re-serialization). - **The app never reformats a body it didn't change** (byte-identical round-trip; plain string comparison, no re-serialization).
- **Encoding and line endings** (settled — the contract underneath "byte-identical"): `index.md` is **UTF-8, no BOM**. Reads are strict — a file that doesn't decode as UTF-8 is the loud malformed-input rejection, offending path named; a BOM'd file fails the frontmatter delimiter and is rejected the same way, deliberately (external writers must produce plain UTF-8, which the agent guide teaches). App writes are BOM-less UTF-8. **Line endings are preserved per line, never normalized**: LF and CRLF both parse; a rewritten frontmatter line keeps the ending it had (a CRLF file stays CRLF); untouched bytes — the body and every unedited line — round-trip byte-identically; and the file's final newline, present or absent, survives rewrites. One card edit can never become a whole-file line-ending diff poisoning the semantic-commit story (06-history-undo.md). Files the app *creates* are LF.
## Frontmatter ## Frontmatter
@@ -81,7 +82,7 @@ Body: lane description / WIP policy / notes.
Body: the card's content — the whole point. 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. 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. 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. 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 — 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. 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. 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 — 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) ## Enhanced schema (reserved, out of scope)
@@ -108,7 +109,7 @@ Carried over unchanged — gapped fractional ranks:
## Deletion — tombstones ## Deletion — tombstones
- Deleting writes `deleted: <ISO-8601>` into frontmatter; tombstoned items (and everything beneath) are hidden from the board 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. - 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. - 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 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). - 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).
+5 -2
View File
@@ -40,6 +40,7 @@ The **one named exception** is transient UI state rendering things that don't ex
### Live-reload resilience ### Live-reload resilience
- **A failed reload never replaces a good snapshot.** Fail-fast (01-storage-format.md) is the *initial-load* contract, where there is nothing to fall back on. Once a board is open, a watcher-triggered reload that fails (unparseable YAML, missing required fields — typically a non-atomic external write caught mid-flight) keeps the last good snapshot on screen and raises a **non-modal banner** carrying fail-fast's specifics (offending path + what's wrong). The watcher keeps watching; the next successful reload clears the banner automatically — transient breakage self-heals without the user losing the board, persistent breakage stays loudly visible. Editing is not locked out: writes go through the Writer as usual (the breakage is per-file and localized), and the reload debounce already absorbs most momentary invalid states before they surface. - **A failed reload never replaces a good snapshot.** Fail-fast (01-storage-format.md) is the *initial-load* contract, where there is nothing to fall back on. Once a board is open, a watcher-triggered reload that fails (unparseable YAML, missing required fields — typically a non-atomic external write caught mid-flight) keeps the last good snapshot on screen and raises a **non-modal banner** carrying fail-fast's specifics (offending path + what's wrong). The watcher keeps watching; the next successful reload clears the banner automatically — transient breakage self-heals without the user losing the board, persistent breakage stays loudly visible. Editing is not locked out: writes go through the Writer as usual (the breakage is per-file and localized), and the reload debounce already absorbs most momentary invalid states before they surface.
- **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.
- **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. - **App-initiated git churn is bracketed.** Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. **The bracket also locks writes** (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted.
- **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A liveness flip is a vanish for this purpose**: re-resolution matches UUID *and* liveness side, so a foreign edit that tombstones a selected live card — or restores a selected tombstoned one — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's homogeneous-by-liveness invariant true across reloads. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map). - **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A liveness flip is a vanish for this purpose**: re-resolution matches UUID *and* liveness side, so a foreign edit that tombstones a selected live card — or restores a selected tombstoned one — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's homogeneous-by-liveness invariant true across reloads. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map).
- **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The lock's scope** (shared with the vanished-root case below) 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 — and the board refuses drops, including drags arriving from another board's window. 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). - **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The lock's scope** (shared with the vanished-root case below) 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 — and the board refuses drops, including drags arriving from another board's window. 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).
@@ -50,7 +51,9 @@ The read-side rules above have a write-side mirror — one banner vocabulary for
- **The one-way flow makes write failures honest by construction.** Views render only what is on disk, so a failed Writer operation (disk full, permissions, volume error) never shows phantom state — the action visibly doesn't happen. The failure surfaces in the same non-modal banner as read-side breakage, naming the operation and the cause ("Couldn't move 'Fix login' — disk full"). One-shot actions (move, tombstone, style, create) fail once and wait for the user to act again; nothing is queued behind their back. - **The one-way flow makes write failures honest by construction.** Views render only what is on disk, so a failed Writer operation (disk full, permissions, volume error) never shows phantom state — the action visibly doesn't happen. The failure surfaces in the same non-modal banner as read-side breakage, naming the operation and the cause ("Couldn't move 'Fix login' — disk full"). One-shot actions (move, tombstone, style, create) fail once and wait for the user to act again; nothing is queued behind their back.
- **The debounced body save retries on its own cadence** — keystrokes stay in the dirty buffer, so nothing is lost while the window stays open; the banner stands until a save lands. The **one modal moment on the write-failure path**: closing a window (or the board, or quitting) with a dirty buffer that cannot be written — the only state that exists nowhere but memory — raises an alert (retry / save a copy elsewhere / discard) instead of failing silently. Everything else on this path stays non-modal. (Deliberate confirmations elsewhere are their own stories: Empty Trash… and Delete Immediately on boards without git history — 03-board-ui.md, the branch-switch save-or-discard step — 06-history-undo.md, machine-key regeneration — 07-sync-collab.md, the raw-source Apply validation alert — 05-card-window.md, the once-per-board iCloud/network-volume warning on open/create — 07, and the SSH trust-on-first-use fingerprint confirmation with its mismatch hard-block — 07.) - **The debounced body save retries on its own cadence** — keystrokes stay in the dirty buffer, so nothing is lost while the window stays open; the banner stands until a save lands. The **one modal moment on the write-failure path**: closing a window (or the board, or quitting) with a dirty buffer that cannot be written — the only state that exists nowhere but memory — raises an alert (retry / save a copy elsewhere / discard) instead of failing silently. Everything else on this path stays non-modal. (Deliberate confirmations elsewhere are their own stories: Empty Trash… and Delete Immediately on boards without git history — 03-board-ui.md, the branch-switch save-or-discard step — 06-history-undo.md, machine-key regeneration — 07-sync-collab.md, the raw-source Apply validation alert — 05-card-window.md, the once-per-board iCloud/network-volume warning on open/create — 07, and the SSH trust-on-first-use fingerprint confirmation with its mismatch hard-block — 07.)
- **A vanished board root locks the board read-only** — the bracketed-reload vocabulary applied to a root that is gone (volume unmounted, folder Finder-deleted while open): every write would land nowhere, so the last-good snapshot stays on screen, read-only, banner up. The watcher keeps watching; if the root returns (remount, Finder undo), the next successful reload clears the lock and pending dirty buffers save normally. - **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).
- **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.
- **An unwritable board location enters the read-only lock at open** (settled): opening probes the root's writability — a read-only volume (DMG, snapshot, read-only share) or a permission-denied folder opens straight into the read-only lock, banner naming the cause ("this board's volume is read-only"), rather than letting every gesture fail one at a time — fail loudly, specifically, *once*. The open-time agent-guide write (08-agent-integration.md) is skipped-with-log, the `CLAUDE.user.md`-taken precedent. Writability re-probes on every reconciling reload (wake, activation — above), so a fixed permission or rewritable remount clears the lock without ceremony. The lock's read affordances stay live as always — inspecting an archived board on a DMG is a legitimate errand, and viewing-first is the point.
- **Auto-commit failures beyond `index.lock` contention** (06-history-undo.md covers the lock) — disk full mid-commit, repo corruption: the files are safely on disk but history stops advancing, which quietly suspends the undo trail and the flush-before-overwrite guarantee. That degradation is surfaced, not hidden: the banner states that changes aren't being recorded to history; the committer retries on the next debounce and the banner clears on the first successful commit. - **Auto-commit failures beyond `index.lock` contention** (06-history-undo.md covers the lock) — disk full mid-commit, repo corruption: the files are safely on disk but history stops advancing, which quietly suspends the undo trail and the flush-before-overwrite guarantee. That degradation is surfaced, not hidden: the banner states that changes aren't being recorded to history; the committer retries on the next debounce and the banner clears on the first successful commit.
- **Attachment import copy failures** (source unreadable, destination full): the drop was accepted — "never refuses the drop" (01-storage-format.md ▸ Attachments) is policy, not an I/O guarantee — so a failed copy surfaces in the banner with the filename, and any partial file is removed; no half-copied attachment is ever left in `attachments/`. - **Attachment import copy failures** (source unreadable, destination full): the drop was accepted — "never refuses the drop" (01-storage-format.md ▸ Attachments) is policy, not an I/O guarantee — so a failed copy surfaces in the banner with the filename, and any partial file is removed; no half-copied attachment is ever left in `attachments/`.
@@ -87,7 +90,7 @@ State that belongs to the app, not the user's files — the recents list, per-bo
- **Recents counts are registry-cached.** The lane/card counts in the welcome window come from the record, stamped at last close — no directory scan at welcome time (which would be slow or hang on big/unavailable boards). Staleness until the next open is accepted. Records that can't be counted show without counts: unavailable boards per Graceful orphaning below; a board that fails to load just fails on open, fail-fast — the welcome row doesn't pre-detect it. - **Recents counts are registry-cached.** The lane/card counts in the welcome window come from the record, stamped at last close — no directory scan at welcome time (which would be slow or hang on big/unavailable boards). Staleness until the next open is accepted. Records that can't be counted show without counts: unavailable boards per Graceful orphaning below; a board that fails to load just fails on open, fail-fast — the welcome row doesn't pre-detect it.
- **Files-first stays absolute**: nothing app-private is ever written into the board folder — no frontmatter keys, no sidecar files, no xattrs. Two machines sharing a board via a remote each keep their own record (push-on-commit and window frames are genuinely per-machine choices). - **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). - **Graceful orphaning**: a record whose bookmark no longer resolves (board deleted, or moved across volumes where bookmarks can't follow) is orphaned — recents surface it as unavailable with Forget; its settings are conveniences and die with it (accepted).
- **App-wide state has the same home.** Not everything app-side is board-scoped: quick-style recents (03-board-ui.md), the SSH host-key assignment table and TOFU fingerprint store (07-sync-collab.md — host-scoped), the last-used card-window size (05-card-window.md), and peers live beside the registry in Application Support (or `UserDefaults` where a scalar fits) — no per-board record involved. Secrets are the named exception: Keychain only, never here (07). - **App-wide state has the same home.** Not everything app-side is board-scoped: quick-style recents (03-board-ui.md), the SSH host-key assignment table and TOFU fingerprint store (07-sync-collab.md — host-scoped), the last-used card-window size (05-card-window.md), and their peers live beside the registry in Application Support (or `UserDefaults` where a scalar fits) — no per-board record involved. Secrets are the named exception: Keychain only, never here (07).
## Caching and search ## Caching and search
+2 -2
View File
@@ -87,7 +87,7 @@ The welcome window carries over from the pathfinder unchanged — confirmed, it
| Card body | Card window (05-card-window.md) | | Card body | Card window (05-card-window.md) |
| Lane title | Inline rename on the header | | Lane title | Inline rename on the header |
| Colors / icons | The style editor — card sidebar Style section (05-card-window.md), board popover, or Style… (context menu / Board ▸ Style…) | | Colors / icons | The style editor — card sidebar Style section (05-card-window.md), board popover, or Style… (context menu / Board ▸ Style…) |
| Lane width | Edge drag + header context-menu picker | | Lane width | Edge drag + header context-menu stepper + Increase/Decrease Lane Width |
| Board title, board styling | Board popover | | Board title, board styling | Board popover |
| Board/lane descriptions (bodies) | File-only — hand-edit `index.md`; live-reload reflects it | | Board/lane descriptions (bodies) | File-only — hand-edit `index.md`; live-reload reflects it |
@@ -102,7 +102,7 @@ The pathfinder's animation behavior carries over as the committed motion languag
- **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. - **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 sole-selected card (carousel expansion), on the search query (filter reflow), 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. What stays animation-free by construction rather than by suppression: the drag replica and the marquee rectangle (1:1 cursor tracking — animating input echo would be lag), and multi-select churn. - **Animated transactions are keyed narrowly** — on the sole-selected card (carousel expansion), on the search query (filter reflow), 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. What stays animation-free by construction rather than by suppression: the drag replica and the marquee rectangle (1:1 cursor tracking — animating input echo would be lag), and multi-select churn.
- **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. - **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, trash) are new work. - **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, the lane-resize rubber-band feedback, trash) are new work.
## Changes from Kanban ## Changes from Kanban
+7 -5
View File
@@ -4,7 +4,7 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
## Selection ## Selection
- Cards: click selects; cmd-click toggles; shift-click range-extends; click-drag rubber-bands across lanes. Lanes: cmd/shift-click multi-select. - Cards: click selects; -click toggles; -click range-extends; click-drag rubber-bands across lanes. Lanes: ⌘/⇧-click multi-select.
- Selection is **homogeneous**: cards XOR lanes. - Selection is **homogeneous**: cards XOR lanes.
- Lane empty-space: single click selects the lane (click again to unselect); double click creates a card at the bottom, title editor focused. - Lane empty-space: single click selects the lane (click again to unselect); double click creates a card at the bottom, title editor focused.
- **Clicking never edits** (pivot from the pathfinder's Finder-rename two-stage click): one click selects, and that is all a single click ever does — no slow-second-click rename, no timers, no accidental edit on a hesitant click. Inline rename is **Return** on a sole selected card, or Board ▸ Rename — the menu item is a lane's only rename path, since Return on a lane creates a card (Grammar below). A fast double-click opens the card window (⌘↩'s pointer twin). Committing an empty rename on an existing item removes its `title` key (titles are optional; the face shows the untitled placeholder). - **Clicking never edits** (pivot from the pathfinder's Finder-rename two-stage click): one click selects, and that is all a single click ever does — no slow-second-click rename, no timers, no accidental edit on a hesitant click. Inline rename is **Return** on a sole selected card, or Board ▸ Rename — the menu item is a lane's only rename path, since Return on a lane creates a card (Grammar below). A fast double-click opens the card window (⌘↩'s pointer twin). Committing an empty rename on an existing item removes its `title` key (titles are optional; the face shows the untitled placeholder).
@@ -19,10 +19,11 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
- **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 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 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). - **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 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). - **Files from Finder**: dropped on a card → copied into its `attachments/` (any 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).
- **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.
## Clipboard ## 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). - ⌘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 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, attachments absent — and a one-shot banner names exactly what was lost ("Pasted 'Fix login' without its 3 attachments"); the user never discovers an empty `attachments/` later.
- **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). - **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). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`. - Paste lands after the anchor card (or appends to a selected lane). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`.
- **Lane paste** lands after the anchor lane — the selected lane, or the selected card's lane; 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. - **Lane paste** lands after the anchor lane — the selected lane, or the selected card's lane; 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.
@@ -36,6 +37,7 @@ 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; 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**: spatial card navigation (nearest card in the direction, across interior grid columns and lanes); with a lane selected, ←/→ move lane selection; ⇧-arrow extends; 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. - **⌥-arrows jump**: ⌥↑/⌥↓ to the current lane's first/last card; ⌥←/⌥→ to the first/last lane.
- **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. - **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.
- **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. - **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. - **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.
- **⌫** 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 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.
@@ -46,7 +48,7 @@ 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. 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: 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. - **⌥⌘↑/⌥⌘↓ 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. - **⌫/⌘⌫ 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). - **Select All**: all visible cards on the board — filter-respecting, like every surface (Search 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). - **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).
@@ -66,7 +68,7 @@ The trash quasi-lane (03-board-ui.md ▸ Trash) speaks the same keyboard languag
### Configurable bindings (settled) ### Configurable bindings (settled)
Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App Shortcuts mechanism (System Settings ▸ Keyboard ▸ App Shortcuts, stored as `NSUserKeyEquivalents` in the app's defaults) remaps any menu item, and AppKit applies it automatically — menus always display the *effective* binding, so the menu bar is the self-documenting keyboard map. Because every board function is a menu item (the contract above), coverage is complete for all modifier-chord commands; the fixed grammar keys stay fixed by design. An in-app shortcut-recorder pane was considered and set aside as ceremony (WISHLIST.md); the Help content carries one line teaching the System Settings path. Constraints this mechanism imposes, adopted as design rules: Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App Shortcuts mechanism (System Settings ▸ Keyboard ▸ App Shortcuts, stored as `NSUserKeyEquivalents` in the app's defaults) remaps any menu item, and AppKit applies it automatically — menus always display the *effective* binding, so the menu bar is the self-documenting keyboard map. Because every board function is a menu item (the contract above), coverage is complete for all modifier-chord commands; the fixed grammar keys stay fixed by design. An in-app shortcut-recorder pane was considered and set aside as ceremony (../WISHLIST.md); the Help content carries one line teaching the System Settings path. Constraints this mechanism imposes, adopted as design rules:
- **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. - **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.) - **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.)
@@ -80,7 +82,7 @@ Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App
## Search ## Search
- Search field invoked with ⌘F (the board toolbar's sole default item; removed from the toolbar, ⌘F surfaces it transiently — 03-board-ui.md ▸ Toolbar; in the **card window**, Edit ▸ Find is find-in-text instead — 05-card-window.md), live filter: cards whose title *and* body both miss the query animate out; case/diacritic-insensitive substring. Scope is **title + body only** (settled) — attachment filenames are not searched. - Search field invoked with ⌘F (the board toolbar's sole default item; removed from the toolbar, ⌘F surfaces it transiently — 03-board-ui.md ▸ Toolbar; in the **card window**, Edit ▸ Find is find-in-text instead — 05-card-window.md), live filter: cards whose title *and* body both miss the query animate out; case/diacritic-insensitive substring. Scope is **title + body only** (settled) — attachment filenames are not searched.
- The filter is the single source of truth for "what's on the board": layout, drop zones, marquee, ranges, arrow nav, and lane count badges all read it. Hidden cards leave the selection; creating a card clears the search. Escape clears, then returns focus to the board. - The filter is the single source of truth for "what's on the board": layout, drop zones, marquee, ranges, arrow nav, and lane count badges all read it. Hidden cards leave the selection; creating a card clears the search — creation's carve-out exists because a brand-new card must not be born invisible. **Rename deliberately gets no carve-out**: a rename committed during an active search re-runs the predicate like any edit — a title that stops matching animates the card out and drops it from the selection, exactly as an agent's edit would; the filter stays a pure predicate with one exception, not two. Escape clears, then returns focus to the board.
- **Dispatch while the search field is focused** (settled): the field is a *control*, not a content editor — the focused-editor lockdown (Grammar above) does not apply. Text-domain keys route to the field: ⌘A/⌘X/⌘C/⌘V act on the query, plain ⌫ edits the query and never reaches the board, horizontal arrows move the caret. Board menu commands stay enabled and act on the board selection exactly as when the field is unfocused — ⌘N included (creating a card clears the search, above) — and the Delete pair stays unambiguous by construction: plain ⌫ is query editing, ⌘⌫ is File ▸ Delete on the selection. - **Dispatch while the search field is focused** (settled): the field is a *control*, not a content editor — the focused-editor lockdown (Grammar above) does not apply. Text-domain keys route to the field: ⌘A/⌘X/⌘C/⌘V act on the query, plain ⌫ edits the query and never reaches the board, horizontal arrows move the caret. Board menu commands stay enabled and act on the board selection exactly as when the field is unfocused — ⌘N included (creating a card clears the search, above) — and the Delete pair stays unambiguous by construction: plain ⌫ is query editing, ⌘⌫ is File ▸ Delete on the selection.
## Changes from Kanban ## Changes from Kanban
+2 -2
View File
@@ -25,7 +25,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: 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. - **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'"). One commit per 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'"). 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". - **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".
- **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. - **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.
@@ -39,7 +39,7 @@ The pathfinder's message engine carries over as the model — it is what earns t
Switching (or creating-and-switching) a branch from the board popover: Switching (or creating-and-switching) a branch from the board popover:
- **Settle the editors first — explicitly, never silently.** Branch switch neither silently commits nor silently abandons an open card-body Edit session: if any open card window has one (unsaved keystrokes, or on-disk ~700 ms saves the session hasn't committed — the mid-session state Auto-commit above deliberately leaves uncommitted), the switch presents a **save-or-discard step**: **Save All** ends every session with its normal commit (each card's Edit→Preview flip), **Discard** reverts buffers and uncommitted saves to HEAD, **Cancel** keeps the current branch and the sessions. Silently flushing the commit alone would be wrong twice over: the tree can be clean precisely because a save hasn't landed, and a later debounced save would write old-branch text onto the new branch's card. With sessions settled, the pending auto-commit flushes (flush-before-overwrite above) and checkout runs on a truly settled tree: it cannot fail dirty, and no in-flight work is lost or dragged across branches. (Inline title editors need no step of their own: reaching the popover's branch controls commits them — click-away commits, and board-scoped commands disable while one is focused — 04-interactions.md ▸ Grammar.) - **Settle the editors first — explicitly, never silently.** Branch switch neither silently commits nor silently abandons an open card-body Edit session: if any open card window has one (unsaved keystrokes, or on-disk ~700 ms saves the session hasn't committed — the mid-session state Auto-commit above deliberately leaves uncommitted), the switch presents a **save-or-discard step**: **Save All** ends every session with its normal commit (each card's Edit→Preview flip), **Discard** reverts buffers and uncommitted saves to HEAD, **Cancel** keeps the current branch and the sessions. **Open raw-source buffers are settled by the same step** (settled — an unsettled raw buffer is the worse hazard: its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new branch's card): Save All *applies* each raw buffer — and since Apply validates, a buffer that fails validation cancels the whole switch with focus on the offending window, nothing half-switched; Discard exits raw source without writing; Cancel keeps everything. External checkouts the app can't gate are the accepted last-writer-wins case, same as the Edit buffer (05-card-window.md's dirty-buffer rule; on git boards the overwritten version is a commit, one revert away). Silently flushing the commit alone would be wrong twice over: the tree can be clean precisely because a save hasn't landed, and a later debounced save would write old-branch text onto the new branch's card. With sessions settled, the pending auto-commit flushes (flush-before-overwrite above) and checkout runs on a truly settled tree: it cannot fail dirty, and no in-flight work is lost or dragged across branches. (Inline title editors need no step of their own: reaching the popover's branch controls commits them — click-away commits, and board-scoped commands disable while one is focused — 04-interactions.md ▸ Grammar.)
- **The undo/redo stack does not survive a switch.** It is discarded and reseeded from the new HEAD's first-parent ancestry — the relaunch rule applied at switch time; redo starts empty. (Replaying a restore commit from the previous branch onto the new one would be wrong.) - **The undo/redo stack does not survive a switch.** It is discarded and reseeded from the new HEAD's first-parent ancestry — the relaunch rule applied at switch time; redo starts empty. (Replaying a restore commit from the previous branch onto the new one would be wrong.)
- **Everything remote-facing tracks the current branch**: ahead/behind, Pull/Push, and push-on-commit all operate against the current branch's upstream. On a branch with no upstream yet, the first push — manual or push-on-commit — **creates it on the remote quietly** (`push -u` semantics): creating a remote branch is non-destructive, and quiet is consistent with push-failures-never-nag (07-sync-collab.md). Genuine failures queue with the badge as usual. - **Everything remote-facing tracks the current branch**: ahead/behind, Pull/Push, and push-on-commit all operate against the current branch's upstream. On a branch with no upstream yet, the first push — manual or push-on-commit — **creates it on the remote quietly** (`push -u` semantics): creating a remote branch is non-destructive, and quiet is consistent with push-failures-never-nag (07-sync-collab.md). Genuine failures queue with the badge as usual.
- The switch itself is bracketed (02-architecture.md): watcher suspended, one full reload at the end. If that final reload fails, the board locks read-only until a successful reload — see 02's live-reload resilience; the on-screen snapshot is from the previous branch and must not be edited over the new one. - The switch itself is bracketed (02-architecture.md): watcher suspended, one full reload at the end. If that final reload fails, the board locks read-only until a successful reload — see 02's live-reload resilience; the on-screen snapshot is from the previous branch and must not be edited over the new one.
+1 -1
View File
@@ -15,7 +15,7 @@ AI agents are first-class users of Lanework boards — not through an API, but t
The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-facing rendition of the schema teaching any agent how to operate on the board directly: The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-facing rendition of the schema teaching any agent how to operate on the board directly:
- Creating cards (mkdir UUID, write `index.md`, ordering rules). - 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). - Moving between lanes (folder move), reordering (gapped ranks, only touch the moved item).
- Tombstone deletes, colors/icons. - Tombstone deletes, colors/icons.
- New in the rewrite: **attachments** (the `attachments/` convention, importing files). - New in the rewrite: **attachments** (the `attachments/` convention, importing files).
+3 -3
View File
@@ -22,7 +22,7 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
- **Cards**: clipboard. ⌘X the selection, move selection to the destination (arrows), ⌘V — between lanes, within a lane (paste lands after the anchor card), and across boards (04-interactions.md's staged clipboard). This is the committed drag-free move story; it needs no VoiceOver-specific machinery because selection and paste targeting are already keyboard-native. - **Cards**: clipboard. ⌘X the selection, move selection to the destination (arrows), ⌘V — between lanes, within a lane (paste lands after the anchor card), and across boards (04-interactions.md's staged clipboard). This is the committed drag-free move story; it needs no VoiceOver-specific machinery because selection and paste targeting are already keyboard-native.
- **Lanes**: the defect this doc originally named (lanes had no keyboard-move path) is closed by the keyboard map — with a lane selected, ⌘←/⌘→ move it (Board ▸ Move Left / Move Right, 04-interactions.md); cards gain ⌥⌘↑/⌥⌘↓ within-lane sorting, and cross lanes drag-free via cut/paste (04-interactions.md's clipboard rules). Lanes carry the clipboard too (resettled, 04-interactions.md ▸ Clipboard), so cross-board lane copy/move — once drag-only, the contract's last gap — is ⌘C/⌘X, then ⌘V with the destination board frontmost. - **Lanes**: the defect this doc originally named (lanes had no keyboard-move path) is closed by the keyboard map — with a lane selected, ⌘←/⌘→ move it (Board ▸ Move Left / Move Right, 04-interactions.md); cards gain ⌥⌘↑/⌥⌘↓ within-lane sorting, and cross lanes drag-free via cut/paste (04-interactions.md's clipboard rules). Lanes carry the clipboard too (resettled, 04-interactions.md ▸ Clipboard), so cross-board lane copy/move — once drag-only, the contract's last gap — is ⌘C/⌘X, then ⌘V with the destination board frontmost.
- **Lane resize**: the header context menu's width stepper (03-board-ui.md) — and its keyboard face, the Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘←, 04-interactions.md) — is the accessible path; edge drag is enhancement only. - **Lane resize**: the header context menu's width stepper (03-board-ui.md) — and its keyboard face, the Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘←, 11-command-nexus.md) — is the accessible path; edge drag is enhancement only.
- **Attachments**: the card window's sidebar items expose Open / Reveal in Finder / Remove via context menu, and the section is keyboard-navigable outright (arrows, Space-QuickLook, Return, ⌫ — 05-card-window.md); adding files drag-free is File ▸ Add Attachment… (⇧⌘A, 11-command-nexus.md) alongside Finder-drop. - **Attachments**: the card window's sidebar items expose Open / Reveal in Finder / Remove via context menu, and the section is keyboard-navigable outright (arrows, Space-QuickLook, Return, ⌫ — 05-card-window.md); adding files drag-free is File ▸ Add Attachment… (⇧⌘A, 11-command-nexus.md) alongside Finder-drop.
## Live board announcements ## Live board announcements
@@ -44,7 +44,7 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
- **Full relative scaling** (decided): relative text styles everywhere, no fixed point sizes. Card face, lane header, and masonry metrics derive from font metrics, so layout survives the largest system text sizes; the no-horizontal-scroll invariant is untouched (lane count is the user's choice; lanes scroll vertically), and 03-board-ui.md's graceful-truncation rules apply at every scale. - **Full relative scaling** (decided): relative text styles everywhere, no fixed point sizes. Card face, lane header, and masonry metrics derive from font metrics, so layout survives the largest system text sizes; the no-horizontal-scroll invariant is untouched (lane count is the user's choice; lanes scroll vertically), and 03-board-ui.md's graceful-truncation rules apply at every scale.
- **Contrast is pinned to WCAG AA.** Lane and card colors render as edge accents (03-board-ui.md's top-edge band / left-edge stripe), so text never sits on them — they are supplementary decoration, never the sole carrier of information, and carry no text-contrast obligation. The ≥ 4.5:1 automatic-contrast rule binds where text does sit on a user-chosen color: the **board** background (palette pairs verified at design time; arbitrary hex computes its text color at runtime against that threshold). An `#RRGGBBAA` background with alpha computes against the color **composited over its effective backdrop** in the active appearance (the board's over the window background; light and dark resolve differently), recomputed on appearance change. Increase Contrast strengthens borders and the selection indicator. - **Contrast is pinned to WCAG AA.** Lane and card colors render as edge accents (03-board-ui.md's top-edge band / left-edge stripe), so text never sits on them — they are supplementary decoration, never the sole carrier of information, and carry no text-contrast obligation. The ≥ 4.5:1 automatic-contrast rule binds where text does sit on a user-chosen color: the **board** background (palette pairs verified at design time; arbitrary hex computes its text color at runtime against that threshold). An `#RRGGBBAA` background with alpha computes against the color **composited over its effective backdrop** in the active appearance (the board's over the window background; light and dark resolve differently), recomputed on appearance change. Increase Contrast strengthens borders and the selection indicator.
- **State is never color-alone**: selection is a ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled — all already patterned; kept as a rule. - **State is never color-alone**: selection is a ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled — all already patterned; kept as a rule.
- **Reduce Motion**: reflow-on-drag, search animate-out, the drag replica, rubber-band feedback, and trash animations all get reduced variants (crossfade or instant). **Reduce Transparency**: glass underlays (carousel page dots) go solid. - **Reduce Motion**: reflow-on-drag, search animate-out, the drag replica, the lane-resize rubber-band feedback (03-board-ui.md ▸ Lane — the selection marquee never animates and needs no variant), and trash animations all get reduced variants (crossfade or instant). **Reduce Transparency**: glass underlays (carousel page dots) go solid.
- **Full Keyboard Access** (independent of VoiceOver): the board is one tab stop with arrow-key navigation within; every control — lane buttons, popover, card window, welcome — is Tab-reachable. - **Full Keyboard Access** (independent of VoiceOver): the board is one tab stop with arrow-key navigation within; every control — lane buttons, popover, card window, welcome — is Tab-reachable.
## Verification ## Verification
@@ -58,4 +58,4 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
## Open questions ## Open questions
None currently — the lane-move gap and the Add Attachment menu path are both closed by the keyboard map (04-interactions.md). None currently — the lane-move gap and the Add Attachment menu path are both closed by the keyboard map (04-interactions.md's contract, inventoried in 11-command-nexus.md).
+1 -1
View File
@@ -31,7 +31,7 @@ The single source of truth for **every command and action the app can perform**
| 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 | 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 | 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 Immediately | ⌥⌘⌫ | Board window, tombstoned selection; confirmed on boards without git history (mode none / repo-nested), immediate on git boards — 03 ▸ Trash |
| File | Empty Trash… (confirmed) | ⇧⌘⌫ | Board window, trash non-empty | | File | Empty Trash… (confirmed) | ⇧⌘⌫ | Board window, trash shown and non-empty |
| File | Close | ⌘W | Any window; flushes per 02 ▸ Windows | | 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 | | 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 |
| 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 copy-out only (card and lane entries), ⌘X disabled (04 ▸ The trash); text editors: standard text clipboard |
@@ -0,0 +1 @@
there is no board without an index.md
@@ -0,0 +1,7 @@
---
schema: 1
order:
title: Lane With Null Order
---
The hand-editor started the 'order' key and never gave it a value — reads
as missing, not malformed.
@@ -0,0 +1,4 @@
---
schema: 1
title: Board Is Fine
---
@@ -0,0 +1,5 @@
---
schema: 1
title: Card Missing Order
---
No 'order' key — required on every card.
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Lane Is Fine
---
@@ -0,0 +1,4 @@
---
schema: 1
title: Board Is Fine
---
@@ -0,0 +1,5 @@
---
schema: 1
title: Lane Missing Order
---
No 'order' key — required on every lane.
@@ -0,0 +1,4 @@
---
schema: 1
title: Board Is Fine
---
@@ -0,0 +1,5 @@
---
title: A Board Missing Its Schema
---
No 'schema' field here — the loader fails fast before looking at anything
else.
@@ -0,0 +1,7 @@
---
schema: 1
order: banana
title: Card With Non-Numeric Order
---
'order' is present but not a number — malformed, not missing; the same
loud rejection as a missing value.
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Lane Is Fine
---
@@ -0,0 +1,4 @@
---
schema: 1
title: Board Is Fine
---
@@ -0,0 +1,5 @@
---
schema: 2
title: From The Future
---
This board was written by a newer version of the app than this one.
@@ -0,0 +1,5 @@
---
schema: 1
bad: [1, 2
---
Body text.
+33 -1
View File
@@ -2,4 +2,36 @@
Golden fixture boards for the storage-contract test suite — real on-disk folder trees, not inline strings, so the same fixtures can later drive XCUITests via the `--open-board` launch hook. Golden fixture boards for the storage-contract test suite — real on-disk folder trees, not inline strings, so the same fixtures can later drive XCUITests via the `--open-board` launch hook.
Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. Tests live in `KanbanTests/FixtureBoardTests.swift`.
Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never generated at test time), chosen so their lexicographic order matches the expected tie-break order — usually a leading digit (`10000000-...`, `20000000-...`, …) so folder order reads the same as array-index order in the tests.
## Valid/ — one board per tolerated/valid case
| Board | Case |
|---|---|
| `rich-board.kanban` | A full-breadth well-formed board: 2 lanes, 3 cards, bodies, styling (background/icon/iconColor/width), unknown + reserved frontmatter keys, `attachments/` and `comments/` with real content. Also the board every `index.md` in the tree is round-tripped against. |
| `interrupted-create.kanban` | The motivating skip-not-error case: a UUID-shaped lane folder and a UUID-shaped card folder, each with no `index.md` yet (folder created, write not yet landed). |
| `non-uuid-strays.kanban` | Non-UUID-shaped folders at both lane and card depth, with and without `index.md` — name shape gates candidacy before the file is ever read. |
| `stray-files.kanban` | Stray (non-directory) files at board, lane, and card level — never level candidates, never warned about. |
| `tombstones.kanban` | A tombstoned lane and a tombstoned card, both still on disk and still in the snapshot, flagged (`isDeleted`) rather than removed. Also proves a tombstoned lane doesn't recursively flag its own un-deleted children. |
| `duplicate-order-tie-break.kanban` | Three cards sharing one `order` in one lane, and two lanes sharing one `order` — both broken by folder name, ascending. |
| `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. |
| `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `background: 12345`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `background: {x: 1}`, `width: 1.5`), plus a `deleted` with an unusable timestamp that still tombstones. |
| `duplicate-top-level-keys.kanban` | A top-level key written twice — at board, lane (the strict `order` field), and card level. Last occurrence wins; **not** a fail-fast case (settled, newer than the original card text). Round-tripped to prove the earlier occurrence survives on disk, invisible only to reads. |
| `board-level-deleted.kanban` | A board-level `deleted:` key — legal per the frontmatter table but meaningless; ignored + warned, rest of the board loads normally. |
## Malformed/ — one board per fail-fast case
Each board is minimal: one broken thing.
| Board | Case |
|---|---|
| `unparseable-yaml.kanban` | An unterminated flow sequence in the board's frontmatter. |
| `missing-schema.kanban` | Board root `index.md` has no `schema` key. |
| `schema-newer-than-app.kanban` | Board root `schema: 2`, newer than `BoardLoader.supportedSchema`. |
| `missing-order-lane.kanban` | A UUID-shaped lane `index.md` with no `order` key. |
| `missing-order-card.kanban` | A UUID-shaped card `index.md` with no `order` key. |
| `explicit-null-order.kanban` | A lane's `order:` key present with no value — reads as missing, not malformed. |
| `non-numeric-order.kanban` | A card's `order: banana` — present but not a number. |
| `board-root-missing-index.kanban` | The board root folder itself has no `index.md`. |
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Also Business As Usual
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Business As Usual
---
@@ -0,0 +1,9 @@
---
schema: 1
title: Board With A Stray 'deleted' Key
deleted: 2026-01-01T00:00:00Z
---
'deleted' is legal per the common frontmatter table but meaningless at
board level — a board can't tombstone itself out of its own window. The
loader ignores it (and logs a warning) rather than acting on it; the rest
of the board loads completely normally.
@@ -0,0 +1,6 @@
---
schema: 1
order: 1024
title: 2048
---
An unquoted integer title coerces to its source text, "2048".
@@ -0,0 +1,7 @@
---
schema: 1
order: 2048
title: [a, b]
---
A sequence has no sensible string reading — malformed, falls back to the
untitled placeholder.
@@ -0,0 +1,7 @@
---
schema: 1
order: 3072
iconColor: 42
---
An integer iconColor coerces to its source text, "42" — not a real color,
but the value round-trips rather than being rejected.
@@ -0,0 +1,7 @@
---
schema: 1
order: 4096
background: {x: 1}
---
A mapping has no sensible string reading — malformed, falls back to no
color.
@@ -0,0 +1,5 @@
---
schema: 1
order: 5120
background: 12345
---
@@ -0,0 +1,8 @@
---
schema: 1
order: 6144
title: Deleted With An Unusable Timestamp
deleted: definitely-not-a-date
---
The user's intent to delete outranks the broken date — this card still
tombstones, its deletion date just unknown.
@@ -0,0 +1,6 @@
---
schema: 1
order: 1024
title: Width Coerces From A Quoted Number
width: "3"
---
@@ -0,0 +1,8 @@
---
schema: 1
order: 2048
title: Width Is Unusable — Not An Integer
width: 1.5
---
A malformed width is preserved on disk and simply not used — never
rejected, never rewritten.
+8
View File
@@ -0,0 +1,8 @@
---
schema: 1
title: Coercion And Lenient-Field Fallback
---
Schema-owned display fields coerce where a sensible reading exists (a
wrong-type scalar reads as its source text) and fall back to their default
where none does (a sequence/mapping, a non-integer width). A present but
unusable 'deleted' timestamp still tombstones — presence outranks validity.
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Should Render First (folder starts with 1)
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Should Render Second (folder starts with 2)
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Should Render Third (folder starts with 3)
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Lane With Three Tied Cards
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 2048
title: Should Render Before Lane bbb... (same order, 'a' < 'b')
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 2048
title: Should Render After Lane aaa... (same order, 'b' > 'a')
---
@@ -0,0 +1,7 @@
---
schema: 1
title: Duplicate Order Values, Tie-Broken By Folder Name
---
Two siblings that share an 'order' render in a deterministic order —
ascending 'order' first, folder name (lexicographic) as the tiebreak.
This board exercises the tiebreak at both lane level and card level.
@@ -0,0 +1,6 @@
---
schema: 1
order: 2048
title: First Title
title: Second Title
---
@@ -0,0 +1,9 @@
---
schema: 1
order: 1024
order: 4096
title: Dup Order Lane
---
Duplicate keys aren't special-cased by field — 'order' is a strict,
schema-owned field and still resolves via last-wins at the document layer,
before the loader ever validates it. This is NOT a fail-fast case.
@@ -0,0 +1,7 @@
---
schema: 1
title: Draft Name
title: Final Name
---
A hand-edit that types 'title' twice — strict YAML would reject the file
outright. The engine reads the last occurrence and keeps both on disk.
@@ -0,0 +1,6 @@
---
schema: 1
order: 1024
title: A Real Card
---
Body.
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: A Real Lane
---
@@ -0,0 +1,6 @@
---
schema: 1
title: Interrupted Two-Step Create
---
The app creates a folder first and writes index.md a moment later — a crash
or a killed app between those two steps must not brick the board.
@@ -0,0 +1,6 @@
---
schema: 1
order: 1024
title: Real Card
---
Body.
@@ -0,0 +1,5 @@
---
schema: 1
order: 9999
title: This looks like a card but isn't named like one
---
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Real Lane
---
@@ -0,0 +1,7 @@
---
schema: 1
title: Non-UUID Strays At Every Depth
---
Name shape gates level detection — a folder that isn't UUIDv4-shaped is a
stray, whether or not it holds an index.md, and whether it sits at lane
depth or card depth.
@@ -0,0 +1,6 @@
---
schema: 1
order: 9999
title: This looks like a lane but isn't named like one
---
Never rendered — 'todo-notes' isn't UUIDv4-shaped.
@@ -0,0 +1,8 @@
---
schema: 1
author: rzen
created: 2026-07-21T08:30:00Z
---
Comments are enhanced-schema and out of scope for this version — this
folder is here only to prove it's preserved verbatim and never descended
into (cards are leaves; the loader never scans past index.md).
@@ -0,0 +1,17 @@
---
schema: 1
title: "Design the fixture taxonomy"
order: 1024
created: 2026-07-20T10:15:00Z
modified: 2026-07-26T20:07:30Z
modified-by: claude
source: DESIGN/01-storage-format.md # inline comment on a plain scalar
project: lanework
sphere: work
labels: [m1-storage-read]
---
Enumerate every fail-fast and tolerated case from the storage format and
build one golden fixture board per case.
- attachments/ holds a sketch
- comments/ holds a placeholder future-comment folder
@@ -0,0 +1,9 @@
---
schema: 1
title: Wire up the loader's stray tolerance
order: 2048
background: coral
icon: flag.fill
iconColor: orange
---
Plain, unquoted title this time — mixing styles on purpose.
@@ -0,0 +1,10 @@
---
schema: 1
order: 1024
title: Doing
width: 2
background: '#3478F6'
icon: hammer.fill
iconColor: blue
---
WIP limit: 3 cards. Pull from Backlog only when there's room.
@@ -0,0 +1,8 @@
---
schema: 1
order: 1024
title: 'Ship v1'
created: 2026-06-01T12:00:00Z
modified: 2026-07-15T09:00:00Z
---
Shipped. 🎉
@@ -0,0 +1,7 @@
---
schema: 1
order: 2048
title: Done
background: green
---
Completed work lives here until someone clears it out.
+22
View File
@@ -0,0 +1,22 @@
---
schema: 1
# board settings, hand-written
title: "Rich Demo Board"
created: 2026-07-01T09:00:00Z
modified: 2026-07-26T16:41:38Z
modified-by: claude
background: "#1E1E1E"
icon: rectangle.stack.fill
iconColor: purple
project: lanework # agent overlay
sphere: work
labels: [feature, docs]
template: {order: 3}
# trailing note before close
---
A demo board exercising the full breadth of the storage contract: several
lanes, several cards, body text, styling, and unknown/reserved frontmatter
keys living alongside the schema-owned ones.
Nothing here is special-cased — it's an ordinary board.
@@ -0,0 +1,6 @@
---
schema: 1
order: 1024
title: A Card With Junk Inside It
---
Body.
@@ -0,0 +1 @@
this whole subfolder is never scanned — cards are leaves
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: A Lane With Junk Beside It
---
@@ -0,0 +1 @@
board readme
@@ -0,0 +1,7 @@
---
schema: 1
title: Stray Files Everywhere
---
Stray files at board, lane, and card level are ignored — preserved
verbatim, never rendered, never warned about (only directories are level
candidates at all).
@@ -0,0 +1 @@
log line
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: A Living Card
---
@@ -0,0 +1,7 @@
---
schema: 1
order: 2048
title: A Tombstoned Card
deleted: 2026-07-10T08:00:00Z
---
Still on disk, at its recorded order, just hidden from the board.
@@ -0,0 +1,5 @@
---
schema: 1
order: 1024
title: Still Here
---
@@ -0,0 +1,7 @@
---
schema: 1
order: 1024
title: Not Itself Deleted
---
My lane is tombstoned but I am not — isDeleted keys only on my own
'deleted' field, never inherited from an ancestor.
@@ -0,0 +1,10 @@
---
schema: 1
order: 2048
title: A Tombstoned Lane
deleted: 2026-07-11T09:30:00Z
---
Everything beneath a tombstoned lane is hidden from the board too — but
that's a rendering rule, not a loader rule: the loader still reports this
lane's card structurally, un-flagged, since the card's own 'deleted' key
is absent.
@@ -0,0 +1,7 @@
---
schema: 1
title: Tombstoned Lane And Card Still On Disk
---
Deletion writes 'deleted:' into frontmatter; the item never leaves disk.
The loader keeps tombstoned items in the snapshot, flagged — hiding them
from the board is a rendering concern, not a loading one.
@@ -0,0 +1,12 @@
---
schema: 1
labels: [bug, urgent]
order: 1024
assignees: [bob, carol]
title: Card With Unknowns
due: 2026-08-15
remote: {system: github, id: 42}
agent-scratch: notes here
---
Card body describing the bug. Unknown/reserved keys are interleaved with
schema-owned ones on purpose, to prove ordering survives the interleave.
@@ -0,0 +1,9 @@
---
schema: 1
order: 1024
remote-state: syncing
title: Lane With Unknowns
assignees: [alice]
due: 2026-08-01
custom: xyz
---
@@ -0,0 +1,12 @@
---
schema: 1
project: lanework
title: Unknown Key Ordering
sphere: work
template: {order: 5}
labels: [x, y, z]
custom-note: keep me
---
Unknown keys and their relative order are preserved verbatim, on every
level — this is load-bearing for agent overlays (project:, sphere:,
custom tags).
+431
View File
@@ -0,0 +1,431 @@
import Foundation
import Testing
@testable import Kanban
// Golden fixture-board suite (01-storage-format.md, 02-architecture.md § Testing): real
// on-disk folder trees under `Fixtures/`, one board per tolerated/valid case and one per
// fail-fast case, asserted against `BoardLoader`. Complements `BoardLoaderTests.swift`'s
// synthetic smoke coverage this suite is the comprehensive, hand-authored, disk-backed
// counterpart.
// MARK: - Bundle resource resolution
/// A tiny anchor class purely so `Bundle(for:)` can find the test bundle there is no
/// `Bundle.module` in an xcodeproj target (that's an SPM-only convenience).
private final class FixtureBundleAnchor {}
/// The `Fixtures/` folder reference, copied into the test bundle's resources verbatim
/// (`project.yml`'s `KanbanTests` target). Real directories on disk, not synthesized strings.
private func fixturesRoot() -> URL {
guard let resources = Bundle(for: FixtureBundleAnchor.self).resourceURL else {
fatalError("test bundle has no resourceURL")
}
return resources.appendingPathComponent("Fixtures", isDirectory: true)
}
private func fixtureBoard(_ relativePath: String) -> URL {
fixturesRoot().appendingPathComponent(relativePath, isDirectory: true)
}
private func loadFixture(_ relativePath: String) throws -> LoadResult {
try BoardLoader.load(boardRoot: fixtureBoard(relativePath))
}
/// Every `index.md` beneath `root`, found by walking the real tree used by the round-trip
/// assertions, which don't want to hardcode which files exist.
private func allIndexMdFiles(under root: URL) throws -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else {
return []
}
var results: [URL] = []
for case let url as URL in enumerator where url.lastPathComponent == "index.md" {
results.append(url)
}
return results
}
private func iso8601(_ text: String) -> Date {
guard let date = ISO8601DateFormatter().date(from: text) else {
fatalError("bad test fixture: '\(text)' is not ISO-8601")
}
return date
}
private func expectFixtureFailure(
_ relativePath: String,
path: String,
reasonDescription: String,
_ matches: (BoardLoadError.Reason) -> Bool
) {
do {
_ = try loadFixture(relativePath)
Issue.record("expected \(relativePath) to fail with \(reasonDescription) at '\(path)', but it loaded")
} catch let error as BoardLoadError {
#expect(error.path == path, "\(relativePath): wrong path in error")
#expect(matches(error.reason), "\(relativePath): expected \(reasonDescription), got \(error.reason)")
} catch {
Issue.record("\(relativePath): expected a BoardLoadError, got \(error)")
}
}
// MARK: - Valid/rich-board.kanban
private enum RichBoard {
static let laneDoing = "10000000-0000-4000-8000-000000000001"
static let laneDone = "20000000-0000-4000-8000-000000000002"
static let cardTaxonomy = "30000000-0000-4000-8000-000000000003"
static let cardSecond = "40000000-0000-4000-8000-000000000004"
static let cardShip = "50000000-0000-4000-8000-000000000005"
}
struct FixtureRichBoardTests {
@Test func loadsFullShapeWithStylingAndNoWarnings() throws {
let result = try loadFixture("Valid/rich-board.kanban")
let model = result.model
#expect(model.title.value == "Rich Demo Board")
#expect(model.background.value == "#1E1E1E")
#expect(model.icon.value == "rectangle.stack.fill")
#expect(model.iconColor.value == "purple")
#expect(model.modifiedBy.value == "claude")
if case let .mapping(pairs) = model.template {
#expect(pairs.count == 1)
} else {
Issue.record("expected board template to be a mapping, got \(String(describing: model.template))")
}
#expect(model.lanes.map(\.id.rawValue) == [RichBoard.laneDoing, RichBoard.laneDone])
#expect(result.warnings.isEmpty)
let doing = try #require(model.lanes.first { $0.id.rawValue == RichBoard.laneDoing })
#expect(doing.title.value == "Doing")
#expect(doing.width.value == 2)
#expect(doing.background.value == "#3478F6")
#expect(doing.cards.map(\.id.rawValue) == [RichBoard.cardTaxonomy, RichBoard.cardSecond])
let taxonomy = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardTaxonomy })
#expect(taxonomy.title.value == "Design the fixture taxonomy")
#expect(taxonomy.modifiedBy.value == "claude")
#expect(taxonomy.body.contains("attachments/ holds a sketch"))
let done = try #require(model.lanes.first { $0.id.rawValue == RichBoard.laneDone })
#expect(done.cards.map(\.id.rawValue) == [RichBoard.cardShip])
#expect(done.cards[0].title.value == "Ship v1")
}
@Test func boardUnknownAndReservedKeysPreserveOrder() throws {
let result = try loadFixture("Valid/rich-board.kanban")
// schema-owned keys (schema, title, created, modified, modified-by, background, icon,
// iconColor) are filtered out; only the agent-overlay and reserved keys remain, in the
// order they were written.
#expect(result.model.document.unknownFields.map(\.key) == ["project", "sphere", "labels", "template"])
}
/// The whole-tree round-trip guarantee (01-storage-format.md § Fractal layout: "the app
/// never reformats a body it didn't change"): every `index.md` under the rich board parses
/// and re-serializes to its original bytes, untouched.
@Test func everyIndexMdInTheTreeRoundTripsByteIdentically() throws {
let root = fixtureBoard("Valid/rich-board.kanban")
let files = try allIndexMdFiles(under: root)
#expect(files.count == 7) // board + 2 lanes + 3 cards + the one comment folder's index.md
for file in files {
let text = try String(contentsOf: file, encoding: .utf8)
let document = try FrontmatterDocument.parse(text)
#expect(document.serialized() == text, "\(file.path) did not round-trip byte-identically")
}
}
}
// MARK: - Valid/interrupted-create.kanban
struct FixtureInterruptedCreateTests {
@Test func indexlessFoldersAreSkippedNotFailed() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let laneInterrupted = "20000000-0000-4000-8000-000000000002"
let card = "30000000-0000-4000-8000-000000000003"
let cardInterrupted = "40000000-0000-4000-8000-000000000004"
let result = try loadFixture("Valid/interrupted-create.kanban")
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
#expect(result.warnings.count == 2)
#expect(result.warnings.contains(.missingIndex(path: laneInterrupted)))
#expect(result.warnings.contains(.missingIndex(path: "\(lane)/\(cardInterrupted)")))
}
}
// MARK: - Valid/non-uuid-strays.kanban
struct FixtureNonUUIDStraysTests {
@Test func nonUUIDFoldersAreStraysAtEveryDepthRegardlessOfIndex() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "50000000-0000-4000-8000-000000000005"
let result = try loadFixture("Valid/non-uuid-strays.kanban")
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
#expect(result.warnings.count == 4)
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo-notes")))
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "scratch")))
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/draft")))
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/wip")))
}
}
// MARK: - Valid/stray-files.kanban
struct FixtureStrayFilesTests {
@Test func strayFilesEverywhereProduceNoWarningsAndDontAffectTheModel() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/stray-files.kanban")
#expect(result.warnings.isEmpty)
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
}
}
// MARK: - Valid/tombstones.kanban
struct FixtureTombstonesTests {
@Test func tombstonedLaneAndCardStayInTheSnapshotFlagged() throws {
let laneLive = "10000000-0000-4000-8000-000000000001"
let laneDead = "20000000-0000-4000-8000-000000000002"
let cardLive = "30000000-0000-4000-8000-000000000003"
let cardDead = "40000000-0000-4000-8000-000000000004"
let cardUnderDeadLane = "50000000-0000-4000-8000-000000000005"
let result = try loadFixture("Valid/tombstones.kanban")
#expect(result.warnings.isEmpty)
#expect(result.model.lanes.map(\.id.rawValue) == [laneLive, laneDead])
let live = try #require(result.model.lanes.first { $0.id.rawValue == laneLive })
#expect(live.isDeleted == false)
#expect(live.cards.map(\.id.rawValue) == [cardLive, cardDead])
#expect(live.cards.map(\.isDeleted) == [false, true])
let dead = try #require(result.model.lanes.first { $0.id.rawValue == laneDead })
#expect(dead.isDeleted == true)
#expect(dead.cards.map(\.id.rawValue) == [cardUnderDeadLane])
// A tombstoned lane doesn't propagate deletion onto its children's own flag the
// loader is structural, not recursive; hiding an ancestor's tombstoned subtree is a
// rendering concern, not a load-time one.
#expect(dead.cards[0].isDeleted == false)
}
}
// MARK: - Valid/duplicate-order-tie-break.kanban
struct FixtureDuplicateOrderTieBreakTests {
@Test func tiedLanesAndTiedCardsBreakByFolderNameAscending() throws {
let laneA = "10000000-0000-4000-8000-000000000001"
let laneAAA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
let laneBBB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
let cardX = "10000000-0000-4000-8000-000000000001"
let cardY = "20000000-0000-4000-8000-000000000002"
let cardZ = "30000000-0000-4000-8000-000000000003"
let result = try loadFixture("Valid/duplicate-order-tie-break.kanban")
// laneA (order 1024) first; laneAAA and laneBBB tie at 2048, broken 'a' < 'b'.
#expect(result.model.lanes.map(\.id.rawValue) == [laneA, laneAAA, laneBBB])
let lane = try #require(result.model.lanes.first { $0.id.rawValue == laneA })
#expect(lane.cards.map(\.id.rawValue) == [cardX, cardY, cardZ])
#expect(Set(lane.cards.map(\.order)) == [1024])
}
}
// MARK: - Valid/unknown-key-order.kanban
struct FixtureUnknownKeyOrderTests {
@Test func unknownKeysPreserveDocumentOrderAtEveryLevel() throws {
let result = try loadFixture("Valid/unknown-key-order.kanban")
let model = result.model
#expect(model.document.unknownFields.map(\.key) == ["project", "sphere", "template", "labels", "custom-note"])
let lane = try #require(model.lanes.first)
#expect(lane.document.unknownFields.map(\.key) == ["remote-state", "assignees", "due", "custom"])
let card = try #require(lane.cards.first)
#expect(card.document.unknownFields.map(\.key) == ["labels", "assignees", "due", "remote", "agent-scratch"])
}
@Test func everyIndexMdRoundTripsByteIdentically() throws {
let root = fixtureBoard("Valid/unknown-key-order.kanban")
for file in try allIndexMdFiles(under: root) {
let text = try String(contentsOf: file, encoding: .utf8)
#expect(try FrontmatterDocument.parse(text).serialized() == text)
}
}
}
// MARK: - Valid/coercion.kanban
struct FixtureCoercionTests {
@Test func wrongTypeScalarsCoerceOrFallBackToDefaultPerField() throws {
let lane1 = "10000000-0000-4000-8000-000000000001"
let lane2 = "20000000-0000-4000-8000-000000000002"
let cardTitleInt = "30000000-0000-4000-8000-000000000003"
let cardTitleSeq = "40000000-0000-4000-8000-000000000004"
let cardIconColorInt = "50000000-0000-4000-8000-000000000005"
let cardBackgroundMap = "60000000-0000-4000-8000-000000000006"
let cardBackgroundInt = "70000000-0000-4000-8000-000000000007"
let cardDeletedBad = "80000000-0000-4000-8000-000000000008"
let result = try loadFixture("Valid/coercion.kanban")
let model = result.model
let laneWidthCoerces = try #require(model.lanes.first { $0.id.rawValue == lane1 })
#expect(laneWidthCoerces.width == .valid(3)) // width: "3" (quoted string) coerces
let laneWidthMalformed = try #require(model.lanes.first { $0.id.rawValue == lane2 })
#expect(laneWidthMalformed.width == .malformed(raw: "1.5")) // non-integer, no sensible width
func card(_ id: String) throws -> Card {
try #require(laneWidthCoerces.cards.first { $0.id.rawValue == id })
}
#expect(try card(cardTitleInt).title == .valid("2048"))
#expect(try card(cardTitleSeq).title == .malformed(raw: "[a, b]"))
#expect(try card(cardIconColorInt).iconColor == .valid("42"))
#expect(try card(cardBackgroundMap).background == .malformed(raw: "{x: 1}"))
#expect(try card(cardBackgroundInt).background == .valid("12345"))
let deletedBad = try card(cardDeletedBad)
#expect(deletedBad.deleted == .malformed(raw: "definitely-not-a-date"))
// Presence outranks validity: an unusable timestamp still tombstones.
#expect(deletedBad.isDeleted == true)
}
}
// MARK: - Valid/duplicate-top-level-keys.kanban
struct FixtureDuplicateTopLevelKeysTests {
@Test func lastOccurrenceWinsAtBoardLaneAndCardLevel() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/duplicate-top-level-keys.kanban")
#expect(result.warnings.isEmpty) // NOT a fail-fast case, per the newer design-doc rule
#expect(result.model.title == .valid("Final Name"))
let loadedLane = try #require(result.model.lanes.first { $0.id.rawValue == lane })
#expect(loadedLane.order == 4096) // duplicated 'order' a strict field still last-wins
let loadedCard = try #require(loadedLane.cards.first { $0.id.rawValue == card })
#expect(loadedCard.title == .valid("Second Title"))
}
/// Earlier occurrences of a duplicated key are invisible to every read but still preserved
/// verbatim on disk (01-storage-format.md § Frontmatter) proven by round-tripping every
/// file in this board, not just asserting the winning value.
@Test func earlierOccurrencesSurviveOnDiskViaRoundTrip() throws {
let root = fixtureBoard("Valid/duplicate-top-level-keys.kanban")
let files = try allIndexMdFiles(under: root)
#expect(files.count == 3)
for file in files {
let text = try String(contentsOf: file, encoding: .utf8)
#expect(try FrontmatterDocument.parse(text).serialized() == text)
}
}
}
// MARK: - Valid/board-level-deleted.kanban
struct FixtureBoardLevelDeletedTests {
@Test func boardLevelDeletedIsIgnoredButRestOfBoardLoadsNormally() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/board-level-deleted.kanban")
#expect(result.warnings.contains(.boardLevelDeletedIgnored))
#expect(result.model.deleted == .valid(iso8601("2026-01-01T00:00:00Z")))
// Meaningless at board level, but never blanks the board the rest loads as usual.
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
}
}
// MARK: - Malformed/*.kanban fail-fast cases
struct FixtureMalformedTests {
@Test func unparseableYAML() {
expectFixtureFailure("Malformed/unparseable-yaml.kanban", path: "index.md", reasonDescription: "unparseableYAML") {
if case .unparseableYAML = $0 { true } else { false }
}
}
@Test func missingSchema() {
expectFixtureFailure("Malformed/missing-schema.kanban", path: "index.md", reasonDescription: "missingSchema") {
$0 == .missingSchema
}
}
@Test func schemaNewerThanApp() {
expectFixtureFailure(
"Malformed/schema-newer-than-app.kanban", path: "index.md", reasonDescription: "schemaNewerThanApp(2)"
) {
$0 == .schemaNewerThanApp(found: 2)
}
}
@Test func missingOrderOnLane() {
let lane = "10000000-0000-4000-8000-000000000001"
expectFixtureFailure(
"Malformed/missing-order-lane.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
@Test func missingOrderOnCard() {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
expectFixtureFailure(
"Malformed/missing-order-card.kanban", path: "\(lane)/\(card)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
/// Explicit null reads as missing (01-storage-format.md § Malformed input): `order:` with
/// nothing after it fails the same way a missing key does, not as `.malformedOrder`.
@Test func explicitNullOrderReadsAsMissing() {
let lane = "10000000-0000-4000-8000-000000000001"
expectFixtureFailure(
"Malformed/explicit-null-order.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
@Test func presentButNonNumericOrder() {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
expectFixtureFailure(
"Malformed/non-numeric-order.kanban",
path: "\(lane)/\(card)/index.md",
reasonDescription: "malformedOrder(banana)"
) {
$0 == .malformedOrder(raw: "banana")
}
}
@Test func boardRootMissingIndex() {
expectFixtureFailure(
"Malformed/board-root-missing-index.kanban", path: "index.md", reasonDescription: "boardRootMissingIndex"
) {
$0 == .boardRootMissingIndex
}
}
}
+3 -1
View File
@@ -8,7 +8,9 @@ The defining consequence: anything that can read and write files is a first-clas
Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/). Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/).
*Nothing user-facing has landed yet — the project is at the scaffold stage.* *No UI yet — the storage foundation is in place:*
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and tombstone-aware snapshots — pinned by a golden fixture suite of 18 on-disk boards.
## Development ## Development