Ratify the edition split and the 2026-07-28 design-review resolutions
Edition split follow-through: 12-editions.md and 13-native-undo.md join the corpus; git-era docs (06, 07) are Pro-scoped and cross-references realigned. Resolution session rulings written in place: caret chords yield to any focused text control (04, 11); the drop settle holds its proposal as overlay state and 02 gains the overlays family; Duplicate gets the save-panel fallback and a cancellable copy walk (03); Finder open is a standard document open (02); failed first opens record before loading with the folder name provisional (02); trash pointer ranges skip by kind, full-height marquee surface, Select All by kind, rows-only trashed universe (02, 04); Empty Trash counts entries (03); create handoff reads as one arrival (02); range-anchor lifecycle, board- background click grammar, jump landing cards, lane-domain shift-arrows (04); Reduce Motion restated per voice (10). Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
+5
-1
@@ -16,10 +16,14 @@ The defining consequence: **anything that can read and write files is a first-cl
|
|||||||
|
|
||||||
1. **Files first.** Every feature must degrade gracefully to "it's just folders of Markdown." If the app vanishes, the data remains fully usable.
|
1. **Files first.** Every feature must degrade gracefully to "it's just folders of Markdown." If the app vanishes, the data remains fully usable.
|
||||||
2. **The app never surprises the file.** Unknown frontmatter keys survive verbatim; untouched bodies are never rewritten; writes are atomic. Hand edits and app edits coexist without ceremony.
|
2. **The app never surprises the file.** Unknown frontmatter keys survive verbatim; untouched bodies are never rewritten; writes are atomic. Hand edits and app edits coexist without ceremony.
|
||||||
3. **Fail fast on malformed input.** A broken file surfaces a loud, specific error with the offending path — never silent fixing, never partial loads, never data loss by "repair."
|
3. **Fail fast on malformed input.** A broken file surfaces a loud, specific error with the offending path — never partial loads, never data loss by "repair," never a rewrite of anyone's bytes. The one scoped softening is 01-storage-format.md's read-side rescue family (duplicate-key last-wins, the unquoted-colon recovery): an obvious hand-editor slip reads as what the writer meant — silently, with a log line, bytes preserved verbatim — because bricking a board over a recoverable slip fails files-first harder than leniency does. Fail-fast keeps guarding structure the rescues can't legitimize.
|
||||||
4. **Native to the bone.** SwiftUI, macOS conventions (Finder-style rename, ⌥-drag copy, package documents, real windows), no web tech, no JS runtime.
|
4. **Native to the bone.** SwiftUI, macOS conventions (Finder-style rename, ⌥-drag copy, package documents, real windows), no web tech, no JS runtime.
|
||||||
5. **Agents are users, not integrations.** The schema, the agent guide, and the tolerance rules are designed for programmatic writers from day one.
|
5. **Agents are users, not integrations.** The schema, the agent guide, and the tolerance rules are designed for programmatic writers from day one.
|
||||||
|
|
||||||
|
## Editions
|
||||||
|
|
||||||
|
Lanework ships as three editions from one codebase and one format (12-editions.md): **Lanework** (no git; macOS-native undo — 13-native-undo.md), **Lanework Pro** (git-backed history, branches, remote sync — 06/07), and **Lanework Teams** (Pro plus tracker integration over the reserved enhanced schema; deferred). Separate Mac App Store apps sharing the `.kanban` UTI — any board opens in any edition. The deeper reason for the split: history and sync live behind a provider seam, so Teams' sync can be backend-agnostic (git *and* trackers) instead of git being load-bearing everywhere.
|
||||||
|
|
||||||
## App identity
|
## App identity
|
||||||
|
|
||||||
The previous version was a **pathfinder** — it never shipped. This rewrite is the app. It keeps the internal codename `Kanban` (Xcode target, scheme, bundle id `dev.rzen.indie.Kanban`) and ships under the display name **Lanework**. Because nothing shipped, there is no migration story and no compatibility obligation to pathfinder boards; the schema number stays `1`, redefined by this design (see 01-storage-format.md).
|
The previous version was a **pathfinder** — it never shipped. This rewrite is the app. It keeps the internal codename `Kanban` (Xcode target, scheme, bundle id `dev.rzen.indie.Kanban`) and ships under the display name **Lanework**. Because nothing shipped, there is no migration story and no compatibility obligation to pathfinder boards; the schema number stays `1`, redefined by this design (see 01-storage-format.md).
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ MyBoard.kanban/ ← board = the document
|
|||||||
### Rules (carried over from the pathfinder, proven)
|
### Rules (carried over from the pathfinder, proven)
|
||||||
|
|
||||||
- **Level is position.** Root = board, depth 1 = lane, depth 2 = card. No `type` field.
|
- **Level is position.** Root = board, depth 1 = lane, depth 2 = card. No `type` field.
|
||||||
- **Folder names are lowercase UUIDv4, immutable, never renamed.** The folder name is the primary key; titles live in frontmatter only. Collisions are impossible by construction. **The identity predicate is shape-only — lowercase v4 is the emission rule, not the gate** (settled): a folder counts as an identity iff its name is 8-4-4-4-12 hexadecimal (36 chars, hyphens in place) in **any case and any UUID version** — effectively "does `UUID(uuidString:)` parse it". The lenient gate exists for the headline use case: macOS's `uuidgen` and Swift's `UUID().uuidString` both print *uppercase*, and a strict gate would make an agent's standard-tool card a silently skipped stray — the worst failure for a files-first app; the version nibble protects no invariant (an agent's v7 is as unique as a v4). **Identity comparison is UUID-value equality, never string equality** — two case-spellings of one UUID are one identity everywhere (the import-boundary collision check included), matching default-APFS case-insensitivity, where they are one folder anyway; on a case-sensitive volume two case-spelled siblings are one identity with two folders, and **the loader dedupes them deterministically** (settled — a snapshot must never carry two items with equal ids): the canonical all-lowercase spelling wins where present, else the lexicographically first spelling; the loser takes the stray posture — skipped with a pointed log line, preserved verbatim, never rendered. (Content-distinct duplicate sibling UUIDs — a hand-copied folder given a same-UUID name — remain the unspecified edge below.) The *app* still writes only lowercase v4 (`UUID().uuidString` explicitly lowercased), never renames an existing folder to canonicalize, and the agent guide keeps teaching lowercase — accept liberally, emit conservatively.
|
- **Folder names are lowercase UUIDv4, immutable, never renamed.** The folder name is the primary key; titles live in frontmatter only. Collisions are impossible by construction. **The identity predicate is shape-only — lowercase v4 is the emission rule, not the gate** (settled): a folder counts as an identity iff its name is 8-4-4-4-12 hexadecimal (36 chars, hyphens in place) in **any case and any UUID version** — effectively "does `UUID(uuidString:)` parse it". The lenient gate exists for the headline use case: macOS's `uuidgen` and Swift's `UUID().uuidString` both print *uppercase*, and a strict gate would make an agent's standard-tool card a silently skipped stray — the worst failure for a files-first app; the version nibble protects no invariant (an agent's v7 is as unique as a v4). **Identity comparison is UUID-value equality, never string equality** — two case-spellings of one UUID are one identity everywhere (the import-boundary collision check included), matching default-APFS case-insensitivity, where they are one folder anyway; on a case-sensitive volume two case-spelled siblings are one identity with two folders, and **the loader dedupes them deterministically** (settled — a snapshot must never carry two items with equal ids): the canonical all-lowercase spelling wins where present, else the lexicographically first spelling; the loser takes the stray posture — skipped with a pointed log line, preserved verbatim, never rendered. (Content-distinct duplicates — a hand-copied folder keeping its UUID — are deduped by the earlier-occurrence-wins rule below.) The *app* still writes only lowercase v4 (`UUID().uuidString` explicitly lowercased), never renames an existing folder to canonicalize, and the agent guide keeps teaching lowercase — accept liberally, emit conservatively.
|
||||||
- **Identity lifecycle: moves keep the UUID, copies mint fresh ones.** Every move (between lanes, between boards, cut+paste) is a physical folder move — identity travels with it. Every copy (⌘C/⌘V, ⌥-drag duplicate, the cross-board drag default — 04-interactions.md's locality model — and template instantiation) mints fresh UUIDs for every folder it materializes; a copy keeps `created` (it's a fork) but is a new identity. **Whole-board copies are the carve-out**: Save as Template (09-templates.md) and File ▸ Duplicate (03-board-ui.md) copy the board folder wholesale, **GUIDs kept** — each flow states its own exclusions (Save as Template drops `.git` and tombstoned items, 09; Duplicate carries both and strips only the remote configuration, 03) — the remint rule governs *item-level* copies landing inside an existing board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it describes). Template GUIDs are inert anyway — instantiation remints at its own boundary. **Exception: template instantiation stamps `created`/`modified` fresh** — a board created from a template is born today, not forked from it (09-templates.md); Save as Template, by contrast, is an ordinary fork and keeps timestamps (inert anyway — instantiation restamps them). The app never produces a duplicate UUID: **within-board uniqueness is enforced at the import boundary** — a move-in (a cross-board ⌘-drag move or a cut+paste — 04-interactions.md) whose UUID already exists anywhere in the destination board is degraded to a copy (fresh UUID minted, content intact; the source is removed as usual for a move). **Degradation is per-folder, at the finest grain**: in a compound arrival (a lane with its cards, a multi-card drag or paste), only the colliding folders are reminted — everything else keeps its identity and remains a true move, and source removal is uniform regardless (the whole selection leaves the source, as for any move). A lane drag with one colliding nested card thus stays a lane *move*; just that card arrives reminted. One gesture silently yielding moved originals plus reminted copies is the intended behavior, not an accident — the collision is an identity repair, invisible in content terms. Boards are **independent identity namespaces**: the same UUID may legitimately exist in two boards (e.g. after a cross-board move-out undone at the source — 06-history-undo.md); such forks are tolerated and only ever meet at an import boundary, where the collision is neutralized. A user hand-copying a card folder in Finder can still produce sibling duplicates — the app doesn't currently detect that, and behavior with duplicate sibling-level UUIDs in one board is unspecified.
|
- **Identity lifecycle: moves keep the UUID, copies mint fresh ones.** Every move (between lanes, between boards, cut+paste) is a physical folder move — identity travels with it. Every copy (⌘C/⌘V, ⌥-drag duplicate, the cross-board drag default — 04-interactions.md's locality model — and template instantiation) mints fresh UUIDs for every folder it materializes; a copy keeps `created` (it's a fork) but is a new identity. **Whole-board copies are the carve-out**: Save as Template (09-templates.md) and File ▸ Duplicate (03-board-ui.md) copy the board folder wholesale, **GUIDs kept** — each flow states its own exclusions (Save as Template drops `.git` and tombstoned items, 09; Duplicate carries both and strips only the remote configuration, 03) — the remint rule governs *item-level* copies landing inside an existing board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it describes). Template GUIDs are inert anyway — instantiation remints at its own boundary. **Exception: template instantiation stamps `created`/`modified` fresh** — a board created from a template is born today, not forked from it (09-templates.md); Save as Template, by contrast, is an ordinary fork and keeps timestamps (inert anyway — instantiation restamps them). The app never produces a duplicate UUID: **within-board uniqueness is enforced at the import boundary** — a move-in (a cross-board ⌘-drag move or a cut+paste — 04-interactions.md) whose UUID already exists anywhere in the destination board is degraded to a copy (fresh UUID minted, content intact; the source is removed as usual for a move). **Degradation is per-folder, at the finest grain**: in a compound arrival (a lane with its cards, a multi-card drag or paste), only the colliding folders are reminted — everything else keeps its identity and remains a true move, and source removal is uniform regardless (the whole selection leaves the source, as for any move). A lane drag with one colliding nested card thus stays a lane *move*; just that card arrives reminted. One gesture silently yielding moved originals plus reminted copies is the intended behavior, not an accident — the collision is an identity repair, invisible in content terms. Boards are **independent identity namespaces**: the same UUID may legitimately exist in two boards (e.g. after a cross-board move-out undone at the source — 06-history-undo.md); such forks are tolerated and only ever meet at an import boundary, where the collision is neutralized. A user hand-copying a card folder in Finder can still produce duplicate ids — in the same lane or another. **Duplicate ids within a board are never tolerated** (settled): the loader keeps exactly one occurrence per id, board-wide — the case-twin dedupe generalized to content-distinct duplicates. **The occurrence that entered the board earlier wins**: on git boards, the path history already tracks outranks the newcomer (both tracked: the path that entered history first); without history, the older folder (filesystem birth date) wins, with deterministic traversal order (lane `order`, then card `order`, then the folder-name tie-break) the last resort when dates tie or are unreadable. Every later occurrence is withheld from rendering — preserved verbatim, pointed log line — so the snapshot invariant (one item per id) holds by construction and nothing is lost on disk; but unlike a true stray it is **surfaced, not silent** (settled): the board window raises a warning-tone condition banner naming the duplicate ("Duplicate id: 'Fix login' exists twice"; several fold into one row with a count) and offering **Repair** — copy semantics applied at detection: the hand copy was a copy, so Repair gives each withheld occurrence the fresh identity the import boundary would have minted, reminting its folder to a fresh lowercase v4 through the Writer (a rename — content, frontmatter, children untouched), after which it renders as an ordinary item. The loader itself still never writes — detection is read-only, the remint runs under user intent and commits normally ("Repair duplicate of 'Fix login'" — 06-history-undo.md's vocabulary gains the verb) — and the banner follows the condition lifecycle (02-architecture.md ▸ The banner surface): it heals when the duplicates are gone, via Repair or externally (another device repaired first; the copy was hand-deleted), and Repair racing a vanished duplicate is a no-op heal, never an error. Case-spelled twins of one identity (above) stay silent strays — they are spelling artifacts of the same item, not copies, and reminting one would *create* duplicate content the user never made.
|
||||||
- `index.md` = YAML frontmatter between `---` delimiters, then Markdown body.
|
- `index.md` = YAML frontmatter between `---` delimiters, then Markdown body.
|
||||||
- **Reserved child names** at card level: `attachments/` (specified below) and `comments/` (enhanced schema — see below). Never levels themselves, never identity (identity-bearing siblings are UUIDs).
|
- **Reserved child names** at card level: `attachments/` (specified below) and `comments/` (enhanced schema — see below). Never levels themselves, never identity (identity-bearing siblings are UUIDs).
|
||||||
- **Name shape gates level detection** (settled): only folders passing the identity predicate (the shape-only rule above — 8-4-4-4-12 hex, any case, any version) are lane/card candidates. Anything else — even with an `index.md` inside — is a stray: skipped with a log warning, preserved verbatim, never rendered. A hand-made `notes/` folder or a board nested inside another can never brick a load; the cost — a hand-authored lane named `todo/` doesn't render — is what the warning and the agent guide exist for.
|
- **Name shape gates level detection** (settled): only folders passing the identity predicate (the shape-only rule above — 8-4-4-4-12 hex, any case, any version) are lane/card candidates. Anything else — even with an `index.md` inside — is a stray: skipped with a log warning, preserved verbatim, never rendered. A hand-made `notes/` folder or a board nested inside another can never brick a load; the cost — a hand-authored lane named `todo/` doesn't render — is what the warning and the agent guide exist for.
|
||||||
- A UUID-named folder below the root **lacking `index.md` is skipped with a log warning**, not an error — an interrupted two-step create must not brick the board. **Stray tolerance is uniform at every level**: non-reserved files and folders at board, lane, or card level are ignored by the loader, preserved verbatim, never rendered (inside a card, only `index.md` and the reserved child names mean anything — anything else is not the app's business). **Three board-root names are app-claimed, not strays** — the one scope on the verbatim promise: `CLAUDE.md` is app-owned and rewritten on guide version bumps, with a markerless user-authored `CLAUDE.md` relocated to `CLAUDE.user.md` (08-agent-integration.md ▸ The agent guide — that name is claimed too), and adding git seeds `.gitignore` (06-history-undo.md ▸ Repository hygiene; seeded once, then the user's to edit). Agents and hand-editors should treat these names as the app's at board root; everything else is untouched.
|
- A UUID-named folder below the root **lacking `index.md` is skipped with a log warning**, not an error — an interrupted two-step create must not brick the board. **Stray tolerance is uniform at every level**: non-reserved files and folders at board, lane, or card level are ignored by the loader, preserved verbatim, never rendered (inside a card, only `index.md` and the reserved child names mean anything — anything else is not the app's business). **Three board-root names are app-claimed, not strays** — the one scope on the verbatim promise: `CLAUDE.md` is app-owned and rewritten on guide version bumps, with a markerless user-authored `CLAUDE.md` relocated to `CLAUDE.user.md` (08-agent-integration.md ▸ The agent guide — that name is claimed too), and adding git seeds `.gitignore` (06-history-undo.md ▸ Repository hygiene; seeded once, then the user's to edit). Agents and hand-editors should treat these names as the app's at board root; everything else is untouched.
|
||||||
|
- **Symlinks are never traversed** (settled): the loader and the watcher treat any symbolic link in the board tree — identity-shaped name or not — as a stray: skipped with a log warning, preserved verbatim, never rendered, never followed. A link passing the identity predicate would otherwise render bytes living outside the board that FSEvents never reports — the silently-stale board 02-architecture.md structurally excludes — and a link cycle would make the tree walk non-terminating. Copy flows copy the link itself, never its target: Duplicate, Save as Template, instantiation, cross-board copies, and clipboard staging preserve the link verbatim as they do any stray.
|
||||||
- **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).
|
||||||
@@ -84,7 +85,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. Last-wins resolves *before* strictness: a duplicated `order` reads as its final occurrence and only that value faces the malformed-order check — duplication itself is never a fail-fast. An app write of a duplicated key rewrites the winning (last) occurrence and removes the earlier ones — the app owns the keys it writes, and leaving a stale twin would resurrect it if the winner were later removed; removing a key removes all its occurrences. **App rewrites preserve comments** (settled): comments on their own lines always survive a rewrite; an inline comment on a rewritten value line is re-spliced after the new value — best-effort, guaranteed for plain scalar lines (the realistic case), dropped only in pathological shapes. **Unquoted colons in a value read as the string they meant** (ratified — the third member of the rescue family, same posture as last-wins and coercion): the classic slip `title: Fix: the thing` — invalid YAML ("mapping values are not allowed in this context") — recovers when, and only when, the scanner error's mark lands on a line that *starts a top-level key* and whose remainder is a **plain unquoted scalar**: everything after the first `: ` reads as the value. A remainder opening with a quote or flow character (`"`, `'`, `[`, `{`) is a different breakage and stays fail-fast; nested/indented shapes never match the guard and stay errors. Mechanism mirrors the duplicate-key rescue: compose retries on a modified copy (the offending value swapped for a same-line placeholder), so one recovered line leaves every other line's diagnostics intact — a second, genuinely broken line still fails loudly with its own error. Read-side only, bytes preserved verbatim; the span editor keys by the line's key prefix, so the file stays fully editable, and the first app write of that key emits the value properly quoted — the slip heals on first touch. Recovery is **silent with a log line** (the family's posture: the recovered value is exactly what the writer meant, so there is nothing actionable to surface). The agent guide keeps teaching quoting — the rescue is a net, not a license. Test obligation: the hostile-YAML suite gains the `key: value: more` shape (none covers it today). Fail-fast remains reserved for structure (`schema`, `order`, YAML validity) — and it covers *malformed*, not just missing: an `order` that is present but non-numeric is the same loud malformed-input rejection as a missing one. **Readable-but-uneditable shapes load; writes to them refuse** (settled): frontmatter the surgical editor can't key by spans — a whole-frontmatter flow mapping, non-scalar keys, or frontmatter whose YAML resolves to an *empty* mapping while still carrying text (`{}`, `null`, `~` between the delimiters: zero keys load fine, but appending a key after that text would produce unparseable YAML; truly blank or comment-only frontmatter stays editable) — reads and renders normally, and any app write to that file fails loudly through the per-file write-failure banner (02-architecture.md ▸ Write-failure surfacing) naming the shape, never a silent corruption and never a load rejection.
|
Schema-owned display fields are Lanework's to interpret — **coerce where a sensible reading exists, fall back to the field's default where none does** (settled). A scalar of the wrong YAML type reads as its source text (`title: 2048` displays as "2048", `width: "2"` reads as 2); where no sensible reading exists — a sequence or mapping where a scalar belongs, a non-integer width, an unparseable timestamp — the field falls back to its default: untitled placeholder, width 1, no color, no icon. **Ranges are part of the sensible reading** (settled): an integer `width` below 1 reads as 1 — the table's ≥ 1 is a validity bound, and 03-board-ui.md's width division must never see a zero or negative unit — while a non-finite `order` (`.nan`, `.inf`) is the same loud malformed-input rejection as a non-numeric one: fail-fast's `order` guard covers unusable values, not merely non-numeric ones (NaN has no place in the total order the tie-break and midpoint math assume). The hostile-YAML suite gains both shapes (`width: 0`, `order: .nan`). Coercion is read-side only; the bytes on disk are **preserved verbatim, never rewritten**. One tombstone nuance: any *present* `deleted:` key tombstones the item — an unusable timestamp still deletes, its date merely unknown (the user's intent to delete outranks the broken date). **Duplicate keys: last one wins** (settled — the coercing read; strict YAML would reject the file, so this is a deliberate divergence in the editor's favor): a *top-level* key appearing twice reads as its last occurrence, earlier occurrences preserved verbatim on disk and invisible. A duplicate inside a nested mapping value remains a YAML error — the rescue applies where the editor's slip actually happens, the top level. Last-wins resolves *before* strictness: a duplicated `order` reads as its final occurrence and only that value faces the malformed-order check — duplication itself is never a fail-fast. An app write of a duplicated key rewrites the winning (last) occurrence and removes the earlier ones — the app owns the keys it writes, and leaving a stale twin would resurrect it if the winner were later removed; removing a key removes all its occurrences. **App rewrites preserve comments** (settled): comments on their own lines always survive a rewrite; an inline comment on a rewritten value line is re-spliced after the new value — best-effort, guaranteed for plain scalar lines (the realistic case), dropped only in pathological shapes. **Unquoted colons in a value read as the string they meant** (ratified — the third member of the rescue family, same posture as last-wins and coercion): the classic slip `title: Fix: the thing` — invalid YAML ("mapping values are not allowed in this context") — recovers when, and only when, the scanner error's mark lands on a line that *starts a top-level key* and whose remainder is a **plain unquoted scalar**: everything after the first `: ` reads as the value. A remainder opening with a quote or flow character (`"`, `'`, `[`, `{`) is a different breakage and stays fail-fast; nested/indented shapes never match the guard and stay errors. Mechanism mirrors the duplicate-key rescue: compose retries on a modified copy (the offending value swapped for a same-line placeholder), so one recovered line leaves every other line's diagnostics intact — a second, genuinely broken line still fails loudly with its own error. Read-side only, bytes preserved verbatim; the span editor keys by the line's key prefix, so the file stays fully editable, and the first app write of that key emits the value properly quoted — the slip heals on first touch. Recovery is **silent with a log line** (the family's posture: the recovered value is exactly what the writer meant, so there is nothing actionable to surface). The agent guide keeps teaching quoting — the rescue is a net, not a license. Test obligation: the hostile-YAML suite gains the `key: value: more` shape (none covers it today). Fail-fast remains reserved for structure (`schema`, `order`, YAML validity) — and it covers *malformed*, not just missing: an `order` that is present but non-numeric is the same loud malformed-input rejection as a missing one. **Readable-but-uneditable shapes load; writes to them refuse** (settled): frontmatter the surgical editor can't key by spans — a whole-frontmatter flow mapping, non-scalar keys, or frontmatter whose YAML resolves to an *empty* mapping while still carrying text (`{}`, `null`, `~` between the delimiters: zero keys load fine, but appending a key after that text would produce unparseable YAML; truly blank or comment-only frontmatter stays editable) — reads and renders normally, and any app write to that file fails loudly through the per-file write-failure banner (02-architecture.md ▸ Write-failure surfacing) naming the shape, never a silent corruption and never a load rejection.
|
||||||
|
|
||||||
## Enhanced schema (reserved, out of scope)
|
## Enhanced schema (reserved, out of scope)
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ Carried over unchanged — gapped fractional ranks:
|
|||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
|
||||||
- `attachments/` under a card folder holds the card's files. The app's attachment surfaces (the card window's sidebar section and import — 05-card-window.md; the face carousel — 03-board-ui.md ▸ Card face) are **flat**: top-level files only.
|
- `attachments/` under a card folder holds the card's files. The app's attachment surfaces (the card window's sidebar section and import — 05-card-window.md; the face carousel — 03-board-ui.md ▸ Card face) are **flat**: top-level files only. Their listing order is **Finder order** (settled — `localizedStandardCompare`): one shared enumeration between loader and Writer, so the carousel and the sidebar can never disagree.
|
||||||
- **Import collisions auto-rename, Finder-style** (`shot.png` → `shot 2.png`): an import never overwrites an existing file and never refuses the drop. Never-refuses is policy, not an I/O guarantee — a failed copy (source unreadable, destination full) surfaces per 02-architecture.md ▸ Write-failure surfacing, with any partial file removed.
|
- **Import collisions auto-rename, Finder-style** (`shot.png` → `shot 2.png`): an import never overwrites an existing file and never refuses the drop. Never-refuses is policy, not an I/O guarantee — a failed copy (source unreadable, destination full) surfaces per 02-architecture.md ▸ Write-failure surfacing, with any partial file removed.
|
||||||
- **Subfolders are tolerated, preserved verbatim, never created by the app, and not surfaced** in those surfaces. Their contents stay reachable via Reveal in Finder, and body-embedded relative paths into them (``) still render — preview resolves against the card folder.
|
- **Subfolders are tolerated, preserved verbatim, never created by the app, and not surfaced** in those surfaces. Their contents stay reachable via Reveal in Finder, and body-embedded relative paths into them (``) still render — preview resolves against the card folder.
|
||||||
|
|
||||||
|
|||||||
@@ -22,29 +22,30 @@ SwiftUI views (board window + card windows share the store)
|
|||||||
|
|
||||||
One-way flow: **files → watcher → loader → store → views**. User actions go through a Writer that mutates files; the change comes back around through the watcher like any external edit. The app trusts its own writes no more than anyone else's — **for rendering** (settled scope): the snapshot is only ever built from disk, never from memory of what the app meant to write — this is what makes external editors and agents first-class. Provenance is a separate, downstream concern: the **EchoLedger** (Components below) remembers what the app wrote so commit attribution (06-history-undo.md) and VoiceOver announcements (10-accessibility.md) can tell the app's own echo from a foreign change — without the render path ever trusting memory over disk.
|
One-way flow: **files → watcher → loader → store → views**. User actions go through a Writer that mutates files; the change comes back around through the watcher like any external edit. The app trusts its own writes no more than anyone else's — **for rendering** (settled scope): the snapshot is only ever built from disk, never from memory of what the app meant to write — this is what makes external editors and agents first-class. Provenance is a separate, downstream concern: the **EchoLedger** (Components below) remembers what the app wrote so commit attribution (06-history-undo.md) and VoiceOver announcements (10-accessibility.md) can tell the app's own echo from a foreign change — without the render path ever trusting memory over disk.
|
||||||
|
|
||||||
The **one named exception** is transient UI state rendering things that don't exist on disk — concretely the **new-card placeholder** (04-interactions.md): the inline editor for a card being created renders as a pseudo-card overlaid on the snapshot, with no disk presence and no UUID until the title commits. Commit creates the folder through the Writer and round-trips through the watcher like any write — the placeholder stays visible until the real card arrives, then hands off. Abandoning (Escape, empty commit, click-away) discards it; disk was never touched. **A failed create discards it too** (settled): if the Writer create throws after the title commits, the create flow discards the placeholder — the failure surfaces as the ordinary one-shot banner (Write-failure surfacing below), and the overlay never waits for a card that cannot arrive. **Starting a new creation while a placeholder is open is a click-away for the draft** (settled): the open draft discards per its rule and the new placeholder begins — and ⌘N can't even reach this case (board commands disable while the editor is focused, 04-interactions.md), so only pointer paths do. Watcher reloads swap the snapshot *underneath* the overlay (like selection surviving a reload); if the placeholder's lane vanished in the reload, it is discarded — consistent with card windows dismissing when their card is deleted. Everything durable still round-trips through files.
|
The **one named exception** is transient UI state rendering things that don't exist on disk — concretely the **new-card placeholder** (04-interactions.md): the inline editor for a card being created renders as a pseudo-card overlaid on the snapshot, with no disk presence and no UUID until the title commits. Commit creates the folder through the Writer and round-trips through the watcher like any write — the placeholder stays visible until the real card arrives, then hands off. **The handoff must read as one arrival** (settled): the placeholder renders at the arriving card's exact geometry — same slot, same size, same chrome — so the identity swap's cross-fade is imperceptible; two view identities are fine, two visible objects are not (no matched-geometry machinery across the overlay/snapshot boundary, just matched rendering). Abandoning (Escape, empty commit, click-away) discards it; disk was never touched. **A failed create discards it too** (settled): if the Writer create throws after the title commits, the create flow discards the placeholder — the failure surfaces as the ordinary one-shot banner (Write-failure surfacing below), and the overlay never waits for a card that cannot arrive. **Starting a new creation while a placeholder is open is a click-away for the draft** (settled): the open draft discards per its rule and the new placeholder begins — and ⌘N can't even reach this case (board commands disable while the editor is focused, 04-interactions.md), so only pointer paths do. Watcher reloads swap the snapshot *underneath* the overlay (like selection surviving a reload); if the placeholder's lane vanished in the reload, it is discarded — consistent with card windows dismissing when their card is deleted. Everything durable still round-trips through files.
|
||||||
|
|
||||||
### Components
|
### Components
|
||||||
|
|
||||||
- **Frontmatter** — YAML value model: parse, serialize, atomic write, unknown-key preservation with key order. Owns the byte-identical round-trip guarantee. Pure, heavily unit-tested.
|
- **Frontmatter** — YAML value model: parse, serialize, atomic write, unknown-key preservation with key order. Owns the byte-identical round-trip guarantee. Pure, heavily unit-tested.
|
||||||
- **BoardLoader** — walks the folder tree, applies the fail-fast/skip rules, produces an immutable `BoardModel` snapshot. Pure function of the tree.
|
- **BoardLoader** — walks the folder tree, applies the fail-fast/skip rules, produces an immutable `BoardModel` snapshot. Pure function of the tree.
|
||||||
- **BoardWriter** — every mutation (create, move, reorder, tombstone, style) as an explicit filesystem operation. No hidden state; a write is done when the file is on disk. (The EchoLedger's receipts are not this bullet's "hidden state": a receipt describes a *completed* write, and the ledger lives beside the Writer, not in it — no write is ever pending in memory.)
|
- **BoardWriter** — every mutation (create, move, reorder, tombstone, style) as an explicit filesystem operation. No hidden state; a write is done when the file is on disk. (The EchoLedger's receipts are not this bullet's "hidden state": a receipt describes a *completed* write, and the ledger lives beside the Writer, not in it — no write is ever pending in memory.)
|
||||||
- **EchoLedger** — the write-provenance ledger (the "Writer/echo machinery" that 06-history-undo.md ▸ Commit attribution and 10-accessibility.md ▸ Live board announcements consume; settled). Every BoardWriter operation drops a receipt of its expected on-disk outcome before returning: path → content hash for writes (attachment imports hash during the copy — the bytes stream through the app anyway), an absence marker for deletes, an old→new pair for folder moves; a newer app write to the same path supersedes the receipt. Classification runs per observed changed file in a debounce window: current on-disk content matches the receipt → **app-mediated**, receipt consumed; no receipt, or mismatch → **foreign**. Final content deciding is what settles the races: an agent writing byte-identical bytes over a fresh app write matches and classifies app-mediated — with identical bytes the misattribution is unobservable in the tree, accepted; a foreign edit landing on an app-written path inside the same window misses the hash and the file classifies foreign — last writer wins the file, the app's subsumed intermediate never separately recorded (the diff compares snapshots, not a journal — 06-history-undo.md). Consumers: the auto-committer's author field and two-commit split (06), and the announcement filter (10) — on no-git boards the ledger runs identically with the announcer as its only consumer. **In-memory, per-store, dies with the session** — losing it costs attribution and nothing else, so the launch catch-up commit (06) classifies everything foreign: the app never vouches for changes it didn't witness. Bracketed operations don't consult it (they commit themselves and announce once at completion), and the reload-granularity origin tag (Live-reload resilience below) is orthogonal: it classifies *reloads*, the ledger classifies *files*. Feeds attribution and announcements only — never the render path (Layering above).
|
- **EchoLedger** — the write-provenance ledger (the "Writer/echo machinery" that 06-history-undo.md ▸ Interaction with external writers — its Commit attribution rule — and 10-accessibility.md ▸ Live board announcements consume; settled). Every BoardWriter operation drops a receipt of its expected on-disk outcome before returning: path → content hash for writes (attachment imports hash during the copy — the bytes stream through the app anyway), an absence marker for deletes, an old→new pair for folder moves; a newer app write to the same path supersedes the receipt. Classification runs per observed changed file in a debounce window: current on-disk content matches the receipt → **app-mediated**, receipt consumed; no receipt, or mismatch → **foreign**. Final content deciding is what settles the races: an agent writing byte-identical bytes over a fresh app write matches and classifies app-mediated — with identical bytes the misattribution is unobservable in the tree, accepted; a foreign edit landing on an app-written path inside the same window misses the hash and the file classifies foreign — last writer wins the file, the app's subsumed intermediate never separately recorded (the diff compares snapshots, not a journal — 06-history-undo.md). Consumers: the auto-committer's author field and two-commit split (06), and the announcement filter (10) — on no-git boards the ledger runs identically with the announcer as its only consumer. **In-memory, per-store, dies with the session** — losing it costs attribution and nothing else, so the launch catch-up commit (06) classifies everything foreign: the app never vouches for changes it didn't witness. Bracketed operations don't consult it (they commit themselves and announce once at completion), and the reload-granularity origin tag (Live-reload resilience below) is orthogonal: it classifies *reloads*, the ledger classifies *files*. Feeds attribution and announcements only — never the render path (Layering above).
|
||||||
- **BoardStore** — per-board `@Observable` object holding the current snapshot plus transient UI state that must be shared across that board's windows (TransientBoardState — see Changes from Kanban). Coalesces watcher reloads — at most one tree walk in flight, signals landing mid-walk fold into one follow-up; the debounce itself lives in FolderWatcher (below).
|
- **BoardStore** — per-board `@Observable` object holding the current snapshot plus transient UI state that must be shared across that board's windows (TransientBoardState — see Changes from Kanban). Coalesces watcher reloads — at most one tree walk in flight, signals landing mid-walk fold into one follow-up; the debounce itself lives in FolderWatcher (below).
|
||||||
- **BoardStoreRegistry** — refcounted registry so a board window and its card windows share one live store and one watcher. **The board window owns the board** (settled): card windows never outlive it — closing the board window closes its card windows too, so the last-window teardown and board-window close coincide. (The refcount still earns its keep ordering teardown while multiple windows close.)
|
- **BoardStoreRegistry** — refcounted registry so a board window and its card windows share one live store and one watcher. **The board window owns the board** (settled): card windows never outlive it — closing the board window closes its card windows too, so the last-window teardown and board-window close coincide. (The refcount still earns its keep ordering teardown while multiple windows close.)
|
||||||
- **FolderWatcher** — FSEvents (debounced: **200 ms trailing**, the timer restarting per event so a burst yields one reload after quiet, over 50 ms FSEvents latency — settled numbers), attached best-effort to whatever path the board lives at. **Events under any `.git` path component are filtered out** (settled): the board's own root-level repo (a worktree-link `.git` file included) is the app's auto-commit churn, and a repo nested deeper — a card folder containing a clone, a submodule — is a stray (01-storage-format.md) whose internals never render; neither can alter the rendered tree, so neither drives reloads. (A nested repo's *working files* still fire events like any stray's — those reloads are value-equal and quiet.) There is only this one watching path: no NSMetadataQuery for iCloud Drive, no polling fallback for network volumes — on those warned-against locations (07-sync-collab.md) FSEvents delivery is unreliable and live reload silently degrades, accepted per 07's no-accommodations stance.
|
- **FolderWatcher** — FSEvents (debounced: **200 ms trailing**, the timer restarting per event so a burst yields one reload after quiet, over 50 ms FSEvents latency — settled numbers), attached best-effort to whatever path the board lives at. **Events under any `.git` path component are filtered out** (settled): the board's own root-level repo (a worktree-link `.git` file included) is the app's auto-commit churn, and a repo nested deeper — a card folder containing a clone, a submodule — is a stray (01-storage-format.md) whose internals never render; neither can alter the rendered tree, so neither drives reloads. (A nested repo's *working files* still fire events like any stray's — those reloads are value-equal and quiet.) There is only this one watching path: no NSMetadataQuery for iCloud Drive, no polling fallback for network volumes — on those warned-against locations (07-sync-collab.md) FSEvents delivery is unreliable and live reload silently degrades, accepted per 07's no-accommodations stance.
|
||||||
- **Ranks** — gapped fractional ordering math + compaction. Pure.
|
- **Ranks** — gapped fractional ordering math + compaction. Pure.
|
||||||
- **DropSlot** — drop-geometry math: hit zones and insertion-position targeting for drags (lane/position within the masonry, cross-board, Finder file drops). Pure, like Ranks.
|
- **DropSlot** — drop-geometry math: hit zones and insertion-position targeting for drags (lane/position within the masonry, cross-board, Finder file drops). Pure, like Ranks.
|
||||||
- **AgentGuide** — writes/upgrades the board-root `CLAUDE.md` (see 08-agent-integration.md).
|
- **AgentGuide** — writes/upgrades the board-root `CLAUDE.md` (see 08-agent-integration.md).
|
||||||
- **HistoryStore** — git plumbing for undo/redo (see 06-history-undo.md).
|
- **HistoryStore** — the history provider behind the edition seam (12-editions.md): the board session binds one `HistoryProviding` implementation at composition — base Lanework's native undo stack (13-native-undo.md, inverse `WriteOperation`s over NSUndoManager) or Pro's git plumbing (06-history-undo.md). Pro-only source; the base target never links git machinery.
|
||||||
|
|
||||||
### 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.
|
||||||
|
- **Duplicate-id detection surfaces as a repairable condition** (settled): the loader's board-wide dedupe (01-storage-format.md ▸ Fractal layout rules) withholds losing occurrences from every snapshot; a warning-tone condition banner names them and offers **Repair** — a Writer remint under user intent (the loader itself never writes). The condition heals when the duplicates are gone, however they go — Repair here, a repair elsewhere, a hand-deleted copy.
|
||||||
- **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal, so quiet reconciliations cost nothing visible), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry).
|
- **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal, so quiet reconciliations cost nothing visible), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry).
|
||||||
- **App-initiated git churn is bracketed.** Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. **The bracket also locks writes** (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted.
|
- **App-initiated git churn is bracketed.** Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. **The bracket also locks writes** (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted.
|
||||||
- **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A liveness flip is a vanish for this purpose**: re-resolution matches UUID *and* liveness side, so a foreign edit that tombstones a selected live card — or restores a selected tombstoned one — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's homogeneous-by-liveness invariant true across reloads. **Liveness here is effective — ancestor-walked** (settled): tombstoning a card's *lane* ejects the card too, its own flag notwithstanding — the card renders nowhere (03-board-ui.md collapses a tombstoned lane to a single restorable trash entry), and nothing invisible may stay selected, drag-included, or pending-cut. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is 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. **Liveness here is effective — ancestor-walked** (settled): tombstoning a card's *lane* ejects the card too, its own flag notwithstanding — the card renders nowhere (03-board-ui.md collapses a tombstoned lane to a single restorable trash entry), and nothing invisible may stay selected, drag-included, or pending-cut. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is effectively deleted — the ancestor walk included (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map).
|
||||||
- **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The rule arms on every bracket exit, thrown operations included** (settled): an operation that fails or aborts mid-flight is precisely when the tree's state is least known, so the mandatory final reload runs regardless — succeeding, it renders whatever the operation left (often value-equal after a clean failure, whose tree is left as it was — 06-history-undo.md); failing, it locks exactly as above. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and Put Back, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles (these disable in place as controls — the one mutating path with no menu item, 05-card-window.md's in-content rule) — and the board refuses drops, including drags arriving from another board's window. **File ▸ Duplicate and File ▸ Save as Template join the disabled set** (settled): both copy the on-disk tree, which the lock marks as gone (vanished root), unknown (this failed-reload state), or unwritable — and both are specified to run the close flush first (03-board-ui.md, 09-templates.md), which the lock's suspended saves make impossible to honor. **One carve-out: under the unwritable-location lock alone (below), Save as Template stays live** — it reads the board and writes into Application Support, the copy-out-is-a-read principle applied (archiving the read-only DMG board being inspected is a legitimate errand, and that lock starts at open, so no Edit session exists and the flush precondition is vacuous). Duplicate stays disabled even there — its destination is the same unwritable parent (a writable-parent/unwritable-board permission split was weighed and set aside as too rare to earn the inconsistency). Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it).
|
- **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The rule arms on every bracket exit, thrown operations included** (settled): an operation that fails or aborts mid-flight is precisely when the tree's state is least known, so the mandatory final reload runs regardless — succeeding, it renders whatever the operation left (often value-equal after a clean failure, whose tree is left as it was — 06-history-undo.md); failing, it locks exactly as above. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and Put Back, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles (these disable in place as controls — 05-card-window.md's in-content rule) — and the board refuses drops, including drags arriving from another board's window. **The lock is a predicate every mutating entry point consults, not a menu-validation sweep** (settled): mutating paths without a menu item exist beyond the checkboxes — the attachment row's ⌫/Remove (grammar key + context menu only, 11-command-nexus.md ▸ Context menus), plain-⌫ delete, Return-creation — and each disables with its surface or follows its command twin's validation; menu validation is the lock's most visible face, never its whole mechanism. **File ▸ Duplicate and File ▸ Save as Template join the disabled set** (settled): both copy the on-disk tree, which the lock marks as gone (vanished root), unknown (this failed-reload state), or unwritable — and both are specified to run the close flush first (03-board-ui.md, 09-templates.md), which the lock's suspended saves make impossible to honor. **One carve-out: under the unwritable-location lock alone (below), Save as Template stays live** — it reads the board and writes into Application Support, the copy-out-is-a-read principle applied (archiving the read-only DMG board being inspected is a legitimate errand). **The carve-out gates on the hazard itself, open sessions, not on lock provenance** (settled): the item disables while any open Edit or raw-source session holds unsaved content — content the lock's suspended saves cannot flush, which the template would silently miss (09-templates.md's never-misses-keystrokes guarantee outranks availability) — and re-enables when those sessions settle or the lock clears. A lock standing since open never meets this state: it disables the flip into Edit mode, so no session can start beneath it and the gate is vacuously open; unsaved sessions under this lock exist only when the symmetric probe (▸ Write-failure surfacing) raised it mid-session. Duplicate stays disabled even there — its destination is the same unwritable parent (a writable-parent/unwritable-board permission split was weighed and set aside as too rare to earn the inconsistency). Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it).
|
||||||
|
|
||||||
### Write-failure surfacing
|
### Write-failure surfacing
|
||||||
|
|
||||||
@@ -66,8 +67,8 @@ The non-modal banner named throughout the read- and write-side rules above is on
|
|||||||
- **One-shots dismiss, conditions heal.** One-shot failures ("Couldn't move 'Fix login' — disk full") carry an explicit dismiss control and no timeout — an error never evaporates unread. Persistent conditions (reload breakage, suspended auto-commit, read-only locks) have no dismiss: they describe ongoing state, standing until the next success clears them, per the rules above.
|
- **One-shots dismiss, conditions heal.** One-shot failures ("Couldn't move 'Fix login' — disk full") carry an explicit dismiss control and no timeout — an error never evaporates unread. Persistent conditions (reload breakage, suspended auto-commit, read-only locks) have no dismiss: they describe ongoing state, standing until the next success clears them, per the rules above.
|
||||||
- **Concurrent conditions stack.** The strip presents independent rows, precedence-ordered: **in-progress rows (pinned) >** read-only lock > reload breakage > one-shot write failures > commit and attachment failures **> passive info rows** (the remote-change signpost); newest first within a class. Each row heals or dismisses independently; beyond three rows the remainder collapse behind a "+N more" disclosure. **In-progress rows are exempt from the collapse and don't count toward its budget** (settled): they are the strip's only explanation for a bracket's write lock and for a close/quit deferring teardown, and the copy rows carry the reachable Cancel — a spinner may never hide behind "+N more". They're safe to pin: few at once, self-clearing, **newest first within the class like every other** (ratified — insertion order; the class rarely holds more than two rows, and one ordering rule beats a special case). Passive info rows rank last and may collapse — calm by design, nothing gated on seeing them instantly.
|
- **Concurrent conditions stack.** The strip presents independent rows, precedence-ordered: **in-progress rows (pinned) >** read-only lock > reload breakage > one-shot write failures > commit and attachment failures **> passive info rows** (the remote-change signpost); newest first within a class. Each row heals or dismisses independently; beyond three rows the remainder collapse behind a "+N more" disclosure. **In-progress rows are exempt from the collapse and don't count toward its budget** (settled): they are the strip's only explanation for a bracket's write lock and for a close/quit deferring teardown, and the copy rows carry the reachable Cancel — a spinner may never hide behind "+N more". They're safe to pin: few at once, self-clearing, **newest first within the class like every other** (ratified — insertion order; the class rarely holds more than two rows, and one ordering rule beats a special case). Passive info rows rank last and may collapse — calm by design, nothing gated on seeing them instantly.
|
||||||
- **Tones, not components.** The banner has kinds — error, warning, info — sharing layout and the accessibility announcement path (10-accessibility.md). The card window's remote-change signpost (07-sync-collab.md) is this same component in the info tone: visually calm, no error color. **Its lifecycle is the one-shot's — dismissable, untimed** (settled): 07's "transient" means non-modal and non-blocking, never auto-expiring; the strip has exactly two lifecycles (one-shots dismiss, conditions heal) and the signpost doesn't add a third.
|
- **Tones, not components.** The banner has kinds — error, warning, info — sharing layout and the accessibility announcement path (10-accessibility.md). The card window's remote-change signpost (07-sync-collab.md) is this same component in the info tone: visually calm, no error color. **Its lifecycle is the one-shot's — dismissable, untimed** (settled): 07's "transient" means non-modal and non-blocking, never auto-expiring; the strip has exactly two lifecycles (one-shots dismiss, conditions heal) and the signpost doesn't add a third.
|
||||||
- **In-progress operations are info rows** (settled): bracketed git operations ("Pulling…", "Switching to 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment imports) each show an info-tone row with a spinner — determinate where progress is knowable. Completion clears the row (the VoiceOver completion announcement of 10-accessibility.md rides the same event); failure swaps it for the error row. Sighted and VoiceOver users learn one vocabulary.
|
- **In-progress operations are info rows** (settled): bracketed git operations ("Pulling…", "Switching to 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment imports, and cross-board transfers — drag copies and moves, staged-clipboard pastes) each show an info-tone row with a spinner — determinate where progress is knowable. Completion clears the row (the VoiceOver completion announcement of 10-accessibility.md rides the same event); failure swaps it for the error row. Sighted and VoiceOver users learn one vocabulary.
|
||||||
- **Cancel appears on safe copies only** (settled): copy-shaped work — attachment imports, Duplicate, template instantiation — carries Cancel, meaning "remove the partial copy, nothing lost". Git brackets get no Cancel: seconds long, and aborting a rebase mid-flight is a repair job, not a cancel.
|
- **Cancel appears on safe copies only** (settled): copy-shaped work — attachment imports, Duplicate, template instantiation, cross-board copies and pastes — carries Cancel, meaning "remove the partial copy, nothing lost"; a cross-board ⌘-drag *move* cancels the same way during its copy phase — the source deletes only after the copy lands, so Cancel leaves the original untouched. Git brackets get no Cancel: seconds long, and aborting a rebase mid-flight is a repair job, not a cancel.
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ The non-modal banner named throughout the read- and write-side rules above is on
|
|||||||
- **Welcome appears only when nothing restores** — restoration off, nothing was open, or every restoration failed. Always reachable via Window ▸ Welcome to Lanework. Opening a board from welcome closes welcome.
|
- **Welcome appears only when nothing restores** — restoration off, nothing was open, or every restoration failed. Always reachable via Window ▸ Welcome to Lanework. Opening a board from welcome closes welcome.
|
||||||
- **Closing the last board window leaves the app windowless** (menu bar alive) — the close is respected. Reactivation (Dock click) with no windows shows welcome.
|
- **Closing the last board window leaves the app windowless** (menu bar alive) — the close is respected. Reactivation (Dock click) with no windows shows welcome.
|
||||||
- **A restored board that fails surfaces on welcome, row-level**: its window doesn't open; welcome appears alongside whatever did restore, the failed board's recents row carrying fail-fast's specifics (load error) or the unavailable state per Graceful orphaning (offline volume, dead bookmark). Other restorations proceed unaffected — never a launch-time modal chain, never a silent drop.
|
- **A restored board that fails surfaces on welcome, row-level**: its window doesn't open; welcome appears alongside whatever did restore, the failed board's recents row carrying fail-fast's specifics (load error) or the unavailable state per Graceful orphaning (offline volume, dead bookmark). Other restorations proceed unaffected — never a launch-time modal chain, never a silent drop.
|
||||||
|
- **Opening a board from Finder is a standard document open** (settled): double-clicking a `.kanban` folder (or `open -a`) routes through the app's open-documents handler into the exact path welcome and File ▸ Open… already use — registry record (created before loading, Per-board app state below), board window, recents stamp. A board already open focuses its existing window — file-identity match, never a second window (one board window per root, above). A fail-fast failure surfaces row-level on welcome, uniform with the restored-board failure row.
|
||||||
|
|
||||||
- **Close flushes**: closing a board window (and app quit) first closes the board's card windows — each open Edit session ends with its normal session commit (06-history-undo.md's granularity) — then flushes pending debounced work, editor saves before the pending auto-commit, before the store tears down. Nothing about this is conditional: a card window cannot exist without its board window (the ownership rule above), so the close flush is always the whole story.
|
- **Close flushes**: closing a board window (and app quit) first closes the board's card windows — each open Edit session ends with its normal session commit (06-history-undo.md's granularity) — then flushes pending debounced work, editor saves before the pending auto-commit, before the store tears down. Nothing about this is conditional: a card window cannot exist without its board window (the ownership rule above), so the close flush is always the whole story.
|
||||||
- **Close waits for in-flight operations** (settled): a close or quit landing while an in-progress banner row is live — a bracketed git operation or copy-shaped work (The banner surface above) — defers teardown until that operation completes: the window stays open with its row spinning, and completion (or failure) resumes the close-flush sequence unchanged. Nothing is interrupted and nothing initiated is silently discarded — a copy row's Cancel stays available throughout for a user who'd rather expedite the quit ("remove the partial copy, nothing lost"). 06-history-undo.md's own-leftovers stamp recovery is thereby a *crash* net only; no deliberate quit or close ever leans on it.
|
- **Close waits for in-flight operations** (settled): a close or quit landing while an in-progress banner row is live — a bracketed git operation or copy-shaped work (The banner surface above) — defers teardown until that operation completes: the window stays open with its row spinning, and completion (or failure) resumes the close-flush sequence unchanged. Nothing is interrupted and nothing initiated is silently discarded — a copy row's Cancel stays available throughout for a user who'd rather expedite the quit ("remove the partial copy, nothing lost"). 06-history-undo.md's own-leftovers stamp recovery is thereby a *crash* net only; no deliberate quit or close ever leans on it.
|
||||||
@@ -90,7 +92,7 @@ The non-modal banner named throughout the read- and write-side rules above is on
|
|||||||
State that belongs to the app, not the user's files — the recents list, per-board window frames, the open-now restoration flag (Launch and window lifecycle above), the push-on-commit setting and the once-per-board iCloud warning flag (07-sync-collab.md), and whatever accumulates later — lives in a **board registry in Application Support**: one record per known board, anchored by the **security-scoped bookmark** the sandboxed app keeps anyway for reopening boards.
|
State that belongs to the app, not the user's files — the recents list, per-board window frames, the open-now restoration flag (Launch and window lifecycle above), the push-on-commit setting and the once-per-board iCloud warning flag (07-sync-collab.md), and whatever accumulates later — lives in a **board registry in Application Support**: one record per known board, anchored by the **security-scoped bookmark** the sandboxed app keeps anyway for reopening boards.
|
||||||
|
|
||||||
- **Keyed by file identity, never by path.** Bookmarks track renames and moves on the same volume; an opened URL is matched to its record by bookmark resolution / file identity, so a moved board keeps its settings. The recents list *is* this registry sorted by last-opened. **One bookmark per open board** (settled): the open flow mints it once and threads it through — the persisted record and the live store's mid-session re-resolution share the same bookmark, never two independent mints.
|
- **Keyed by file identity, never by path.** Bookmarks track renames and moves on the same volume; an opened URL is matched to its record by bookmark resolution / file identity, so a moved board keeps its settings. The recents list *is* this registry sorted by last-opened. **One bookmark per open board** (settled): the open flow mints it once and threads it through — the persisted record and the live store's mid-session re-resolution share the same bookmark, never two independent mints.
|
||||||
- **Recents counts are registry-cached.** The lane/card counts in the welcome window come from the record, stamped at last close — no directory scan at welcome time (which would be slow or hang on big/unavailable boards). Staleness until the next open is accepted. **The counts are live items only** (settled): tombstoned lanes and cards — and cards hidden beneath a tombstoned lane — don't count; the row advertises the board's working size, and the trash is an errand, not inventory. Records that can't be counted show without counts: unavailable boards per Graceful orphaning below; a board that fails to load just fails on open, fail-fast — the welcome row doesn't pre-detect it. **A first open that fails fail-fast still records** (settled): the registry record is created before loading, so the failed board lands in recents carrying fail-fast's specifics — retry after fixing the file is one click, uniform with the restored-board failure row, and Forget is the eraser for a genuinely mistaken open. **Records that collapse onto one file identity merge silently** (settled — a restored registry file, or an orphan whose bookmark re-resolves onto a recreated board): on detection, the record with the newest `lastOpened` wins wholesale and the others retire — recents never shows one board twice, and per-board settings are conveniences that don't earn a merge UI.
|
- **Recents counts are registry-cached.** The lane/card counts in the welcome window come from the record, stamped at last close — no directory scan at welcome time (which would be slow or hang on big/unavailable boards). Staleness until the next open is accepted. **The counts are live items only** (settled): tombstoned lanes and cards — and cards hidden beneath a tombstoned lane — don't count; the row advertises the board's working size, and the trash is an errand, not inventory. Records that can't be counted show without counts: unavailable boards per Graceful orphaning below; a board that fails to load just fails on open, fail-fast — the welcome row doesn't pre-detect it. **A first open that fails fail-fast still records** (settled): the registry record is created before loading, so the failed board lands in recents carrying fail-fast's specifics — retry after fixing the file is one click, uniform with the restored-board failure row. **The record's provisional display name is the folder name** (settled): fail-fast means the frontmatter can't be trusted, and the folder name is the Finder document name the user just picked; the first successful load replaces it with the cached title. A never-successfully-opened record is not a special class — it lingers in recents like any other, and Forget is the eraser for a genuinely mistaken open. **Records that collapse onto one file identity merge silently** (settled — a restored registry file, or an orphan whose bookmark re-resolves onto a recreated board): on detection, the record with the newest `lastOpened` wins wholesale and the others retire — recents never shows one board twice, and per-board settings are conveniences that don't earn a merge UI.
|
||||||
- **The row's title and icon are registry-cached too — with live write-through** (settled): the record carries the board's `title`, `icon`, and `iconColor` beside the counts, and the welcome row reads only the record — it never opens any board's `index.md` (the same hang-avoidance that motivated the counts rule). Unlike the counts' at-close stamp, these three refresh **whenever an open board's reload changes them**: the store already holds the new snapshot, so an in-app Board rename (03-board-ui.md) lands in the record instantly — never a welcome row showing a name the user just changed away from — and a foreign rename of an *open* board rides the same path for free. The honest residual: renaming a board that isn't open (an agent editing its root `index.md`) stays stale until the next open — accepted, the counts' staleness class. The title falls back to the folder name per 01-storage-format.md, cached at the same moments.
|
- **The row's title and icon are registry-cached too — with live write-through** (settled): the record carries the board's `title`, `icon`, and `iconColor` beside the counts, and the welcome row reads only the record — it never opens any board's `index.md` (the same hang-avoidance that motivated the counts rule). Unlike the counts' at-close stamp, these three refresh **whenever an open board's reload changes them**: the store already holds the new snapshot, so an in-app Board rename (03-board-ui.md) lands in the record instantly — never a welcome row showing a name the user just changed away from — and a foreign rename of an *open* board rides the same path for free. The honest residual: renaming a board that isn't open (an agent editing its root `index.md`) stays stale until the next open — accepted, the counts' staleness class. The title falls back to the folder name per 01-storage-format.md, cached at the same moments.
|
||||||
- **Files-first stays absolute**: nothing app-private is ever written into the board folder — no frontmatter keys, no sidecar files, no xattrs. Two machines sharing a board via a remote each keep their own record (push-on-commit and window frames are genuinely per-machine choices).
|
- **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).
|
||||||
@@ -107,7 +109,7 @@ The old app loaded boards fast enough that the planned SwiftData cache was never
|
|||||||
|
|
||||||
## Changes from Kanban
|
## Changes from Kanban
|
||||||
|
|
||||||
- The store's transient-state grab-bag gets an explicit home (settled, m3): **TransientBoardState**, one per store, holding state by how a reload treats it. **Item-referencing sets** — selection, drag membership, the pending cut — share one shape (a UUID set plus the liveness side it lives on) and one constraint rule, *members must exist in the current universe*, applied in two directions by one primitive: a reload re-resolves each set independently against the new snapshot (present on the same effective-liveness side, ancestor-walked), and the search filter constrains the selection to its visible set — the hidden-cards-leave-the-selection rule and the reload-survival rule are one rule, expressed once. **Derived state is stored as its inputs only**: the search query is kept, its result set never is — the predicate re-runs against each snapshot (04-interactions.md's live filter). **The overlay** — the new-card placeholder — is anchored to its lane, not to items: no UUID until the title commits, discarded when a reload drops or effectively tombstones its lane, and handed off by discarding itself the moment the created card's UUID appears in a snapshot. Trash visibility rides along as a plain per-open value: hidden on every open, never persisted — visiting the trash is an errand, not a layout choice.
|
- The store's transient-state grab-bag gets an explicit home (settled, m3): **TransientBoardState**, one per store, holding state by how a reload treats it. **Item-referencing sets** — selection, drag membership, the pending cut — share one shape (a UUID set plus the liveness side it lives on) and one constraint rule, *members must exist in the current universe*, applied in two directions by one primitive: a reload re-resolves each set independently against the new snapshot (present on the same effective-liveness side, ancestor-walked), and the search filter constrains the selection to its visible set — **the trashed side has exactly one definition: the set with trash rows** (settled): a card carrying its own `deleted:` under a tombstoned lane is in *neither* universe (03-board-ui.md's ancestor walk subsumes it — no row, no membership), so an anchor or selection can never survive on an item that renders nowhere; universe and rows are one function, never a broader set with a pointer-side subset — the hidden-cards-leave-the-selection rule and the reload-survival rule are one rule, expressed once. **Derived state is stored as its inputs only**: the search query is kept, its result set never is — the predicate re-runs against each snapshot (04-interactions.md's live filter). **The overlays** — transient render state covering the gap between a gesture and its disk echo, each discarding itself at handoff. The **new-card placeholder** is anchored to its lane, not to items: no UUID until the title commits, discarded when a reload drops or effectively tombstones its lane, and handed off by discarding itself the moment the created card's UUID appears in a snapshot. The **held drop proposal** (settled — 03-board-ui.md ▸ Motion) is its kin on the other side of a write: at drag release the proposed arrangement keeps rendering over the snapshot while the move write brackets, and the proposal discards itself when the echo reload lands (positions match, nothing visibly moves); a failed write or a reload that vanishes the dragged items discards it and the board animates back to snapshot order. Trash visibility rides along as a plain per-open value: hidden on every open, never persisted — visiting the trash is an errand, not a layout choice.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
|
|||||||
+13
-12
@@ -6,7 +6,7 @@ The board window: layout, lanes, cards, and styling. Interaction mechanics (sele
|
|||||||
|
|
||||||
- **Every lane is always on screen.** The window width divides across the lanes' width units — no horizontal scroll, no enforced minimum lane width. Resizing the window is the width control. (Settled emphatically in the old app: horizontal scroll strays from what kanban is for.) The degenerate case is accepted, not floored: enough lanes/units in a small window compress every lane, titles and cards truncate gracefully, and the remedy is the user's (fewer units, bigger window). A minimum-width setting that reintroduces scroll was considered in the pathfinder and deliberately rejected. **Lane-count changes re-divide, never resize**: new lane (⇧⌘N), lane paste or cross-board lane drop, lane tombstone, Put Back, and Show/Hide Trash (the quasi-lane's fixed unit joins and leaves the division — Trash below) all re-divide the existing window width across the new unit total — window-growing behavior belongs to the right-edge drag alone (Lane below).
|
- **Every lane is always on screen.** The window width divides across the lanes' width units — no horizontal scroll, no enforced minimum lane width. Resizing the window is the width control. (Settled emphatically in the old app: horizontal scroll strays from what kanban is for.) The degenerate case is accepted, not floored: enough lanes/units in a small window compress every lane, titles and cards truncate gracefully, and the remedy is the user's (fewer units, bigger window). A minimum-width setting that reintroduces scroll was considered in the pathfinder and deliberately rejected. **Lane-count changes re-divide, never resize**: new lane (⇧⌘N), lane paste or cross-board lane drop, lane tombstone, Put Back, and Show/Hide Trash (the quasi-lane's fixed unit joins and leaves the division — Trash below) all re-divide the existing window width across the new unit total — window-growing behavior belongs to the right-edge drag alone (Lane below).
|
||||||
- A lane spans a **whole number of width units** (`width` frontmatter, ≥ 1, no cap). Cards stay standard width; a wide lane flows them into as many interior masonry columns as it has units.
|
- A lane spans a **whole number of width units** (`width` frontmatter, ≥ 1, no cap). Cards stay standard width; a wide lane flows them into as many interior masonry columns as it has units.
|
||||||
- New lanes are created via a **File-menu item** (the one committed surface; ⇧⌘N — 11-command-nexus.md); new cards from the lane (see 04-interactions.md for creation flows).
|
- New lanes are created via a **File-menu item** (the one committed surface; ⇧⌘N — 11-command-nexus.md); new cards from the lane (see 04-interactions.md for creation flows). **The created lane becomes the sole selection** (settled): ⇧⌘N → Board ▸ Rename is a pure keyboard path — the create-then-act texture (Return-creation re-selects its lane, ⌫ picks a successor).
|
||||||
|
|
||||||
## Toolbar (board + card windows)
|
## Toolbar (board + card windows)
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
|
|||||||
|
|
||||||
- Title bar: leading SF Symbol (the lane's `icon`), title, **card-count badge** (quiet, secondary styling), new-card button. The whole title bar is the lane's drag surface — no separate grip; a plain click (no movement) on it selects the lane (04-interactions.md ▸ Selection). **The lane has one context menu** (settled), invoked on the header or on lane empty space alike — Rename, Style…, the quick-style recents row, the Width stepper, Delete (inventory normative in 11-command-nexus.md ▸ Context menus); a full lane still has its header, so the menu is always reachable. The count reads the search filter like every other surface (04-interactions.md): during a search it shows the visible count, not the total.
|
- Title bar: leading SF Symbol (the lane's `icon`), title, **card-count badge** (quiet, secondary styling), new-card button. The whole title bar is the lane's drag surface — no separate grip; a plain click (no movement) on it selects the lane (04-interactions.md ▸ Selection). **The lane has one context menu** (settled), invoked on the header or on lane empty space alike — Rename, Style…, the quick-style recents row, the Width stepper, Delete (inventory normative in 11-command-nexus.md ▸ Context menus); a full lane still has its header, so the menu is always reachable. The count reads the search filter like every other surface (04-interactions.md): during a search it shows the visible count, not the total.
|
||||||
- Body: vertical card stack (masonry grid when wide — settled, the pathfinder's masonry works), scrolls vertically.
|
- Body: vertical card stack (masonry grid when wide — settled, the pathfinder's masonry works), scrolls vertically.
|
||||||
- Right-edge **drag-to-resize** between integer widths (1×, 2×, 3×, … — no cap): shadow snaps at the inter-column gap with 10pt release hysteresis; the window grows/shrinks by one standard width per snap so other lanes keep their exact size. **Growth hard-stops at the screen's visible frame, with rubber-band feedback** (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound — pathfinder behavior, proven): the drag never compresses siblings and the window never overflows the screen. The header context menu's Width control (stepper, uncapped) is the precise control — and deliberately the opposite mechanism: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress). Widths beyond the screen's capacity stay reachable through it. The **Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘← — 11-command-nexus.md) are this stepper's keyboard face** — same re-divide semantics, never the window's size; window-growing behavior belongs to the drag alone.
|
- Right-edge **drag-to-resize** between integer widths (1×, 2×, 3×, … — no cap): shadow snaps at the inter-column gap with 10pt release hysteresis; the window grows/shrinks by one standard width per snap so other lanes keep their exact size. **Growth hard-stops at the screen's visible frame, with rubber-band feedback** (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound — pathfinder behavior, proven): the drag never compresses siblings and the window never overflows the screen. The header context menu's Width control (stepper, uncapped) is the precise control — and deliberately the opposite mechanism: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress). Widths beyond the screen's capacity stay reachable through it. The **Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘← — 11-command-nexus.md) are this stepper's keyboard face** — same re-divide semantics, never the window's size; window-growing behavior belongs to the drag alone — and they **batch over a multi-lane selection** (settled, the styling precedent): each selected lane steps one unit, one gesture, one commit; the context-menu stepper itself stays single-lane by nature. **A width write landing on 1 removes the `width` key** (settled — the remove-at-default family: the empty rename removes `title`, the None well removes `background`): a default lane's frontmatter stays clean, drag, stepper, and menu items alike; a hand-written `width: 1` is legal and preserved until the app itself next edits width. **A failed width commit at drag release rolls the window back** (settled): the failure surfaces as the ordinary one-shot banner and the window animates back by the uncommitted delta — 02-architecture.md's write-failure honesty (the action visibly doesn't happen) applied to the one control that moves the window.
|
||||||
|
|
||||||
## Card face
|
## Card face
|
||||||
|
|
||||||
@@ -35,26 +35,27 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
|
|||||||
- **`background`** on board / lane / card: palette name (kebab-case, hand-editable) or `#RRGGBB[AA]` hex. Board color paints the board window's content background (the surface behind and between lanes). Lane and card color are **edge accents, not fills** (settled in the pathfinder's treatment shootout — its settings matrix of C-series lane / K-series card variants landed on **C7 · full-column top edge** and **K1 · left edge stripe**): a lane's color paints a full-width band along its top edge, a card's a stripe along its left edge; the surfaces themselves keep the standard chrome, so colored title text never sits on a colored fill.
|
- **`background`** on board / lane / card: palette name (kebab-case, hand-editable) or `#RRGGBB[AA]` hex. Board color paints the board window's content background (the surface behind and between lanes). Lane and card color are **edge accents, not fills** (settled in the pathfinder's treatment shootout — its settings matrix of C-series lane / K-series card variants landed on **C7 · full-column top edge** and **K1 · left edge stripe**): a lane's color paints a full-width band along its top edge, a card's a stripe along its left edge; the surfaces themselves keep the standard chrome, so colored title text never sits on a colored fill.
|
||||||
- **`icon`**: SF Symbol per item with per-level defaults (board `rectangle.split.3x1`, lane `square.stack`, card `doc.text`).
|
- **`icon`**: SF Symbol per item with per-level defaults (board `rectangle.split.3x1`, lane `square.stack`, card `doc.text`).
|
||||||
- **`iconColor`**: resolved — **schema yes, control no**. The field renders when hand-written (tint palette name or hex); the app offers no control for it (Controls below).
|
- **`iconColor`**: resolved — **schema yes, control no**. The field renders when hand-written (tint palette name or hex); the app offers no control for it (Controls below).
|
||||||
- The pathfinder's palettes (12 icon tints, 12 backgrounds) carry over as the starting point.
|
- The pathfinder's palettes (12 icon tints, 12 backgrounds) carry over as the starting point. **The 12+12 split is a picker split, not a name split** (settled): color resolution searches foregrounds then backgrounds, so a hand-written `background: carnation` (an icon-tint name) resolves and paints — the split governs what the grids offer, never what a name means.
|
||||||
|
|
||||||
### Controls (settled)
|
### Controls (settled)
|
||||||
|
|
||||||
One **style editor** component — a background palette grid and a curated symbol grid — presented from three anchors: **embedded** in the card window sidebar's Style section (05-card-window.md) and in the board popover's styling area, and as a **popover** opened by Style… from a card/lane context menu or the menu bar (Board ▸ Style…, ⌥⌘S — 11-command-nexus.md; selection-aware: it styles the selected cards or lane, and with nothing selected, the board). One component, one behavior, three anchors — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't.
|
One **style editor** component — a background palette grid and a curated symbol grid — presented from three anchors: **embedded** in the card window sidebar's Style section (05-card-window.md) and in the board popover's styling area, and as a **popover** opened by Style… from a card/lane context menu or the menu bar (Board ▸ Style…, ⌥⌘S — 11-command-nexus.md; selection-aware: it styles the selected cards or lane, and with nothing selected, the board). One component, one behavior, three anchors — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't.
|
||||||
|
|
||||||
- **Palette-only in-app**: the background grid offers the 12 palette colors — every pair AA-verified at design time (10-accessibility.md) — plus a leading **None** well that removes the `background` key. Custom hex is not pickable in-app but stays fully honored from disk (runtime contrast, 10-accessibility.md): curated in-app, unlimited on disk.
|
- **Palette-only in-app**: the background grid offers the 12 palette colors — every pair AA-verified at design time (10-accessibility.md), the claim pinned by a computed-contrast unit test over all 12 pairs so palette drift can never silently break it — plus a leading **None** well that removes the `background` key. Custom hex is not pickable in-app but stays fully honored from disk (runtime contrast, 10-accessibility.md): curated in-app, unlimited on disk.
|
||||||
- **Curated symbol grid**: a hand-picked set (roughly five dozen kanban-relevant SF Symbols); its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch.
|
- **Curated symbol grid**: a hand-picked set (roughly five dozen kanban-relevant SF Symbols); its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — named symbols the running OS knows, that is: inventories grow per macOS release, so a newer-OS name renders the level default on an older Mac, value preserved on disk — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch.
|
||||||
- **Off-palette values display leniently**: a hand-written hex background or uncurated symbol shows as the current value in the editor (labeled verbatim, outside the grids); choosing any well replaces it.
|
- **Off-palette values display leniently**: a hand-written hex background or uncurated symbol shows as the current value in the editor (labeled verbatim, outside the grids); choosing any well replaces it.
|
||||||
- **Batch edits**: a multi-selection shows per-dimension mixed state (no well selected, "—" where a value would read); choosing a well applies to the whole selection — one gesture, one commit on git boards.
|
- **Batch edits**: a multi-selection shows per-dimension mixed state (no well selected, "—" where a value would read); choosing a well applies to the whole selection — one gesture, one commit on git boards.
|
||||||
- **Quick-style row, recents only**: card and lane context menus carry one compact row of recently used backgrounds plus the Style… item — one-click recolor for the common case; the pathfinder's second full-palette tier is gone. Recents are app-wide and persist app-side (user preference, never board data).
|
- **The Style… popover tracks its target set live and dismisses when it empties** (settled): its target is the selection, re-resolved across reloads by 02-architecture.md's UUID rule — a member that vanishes or flips liveness leaves the set and the mixed-state display recomputes; a set emptied by a foreign reload dismisses the popover (the inline-rename discard applied here) — it never silently retargets to the board, and nothing writes into a vanished or tombstoned folder. **The read-only lock instead disables its wells in place** (settled): a popover open when the lock lands stays open, content disabled — the banner names why, and the lock never yanks a surface (the Edit buffer's keeps-its-place posture). The embedded anchors need no rule of their own: the card sidebar dismisses with its card's window, and the board popover's target is the board itself.
|
||||||
|
- **Quick-style row, recents only**: card and lane context menus carry one compact row of recently used backgrounds plus the Style… item — one-click recolor for the common case; the pathfinder's second full-palette tier is gone. Before any background has ever been applied, the row is omitted entirely — never an empty strip. Recents are app-wide and persist app-side (user preference, never board data).
|
||||||
- **Keyboard path**: Style… is a menu item with a shortcut (04-interactions.md's contract); inside the editor the grids are arrow-navigable and every well Tab-reachable (10-accessibility.md).
|
- **Keyboard path**: Style… is a menu item with a shortcut (04-interactions.md's contract); inside the editor the grids are arrow-navigable and every well Tab-reachable (10-accessibility.md).
|
||||||
|
|
||||||
## Board popover
|
## Board popover
|
||||||
|
|
||||||
The window-title widget opens the **board popover** — the one board-level surface, hosting:
|
The window-title widget opens the **board popover** — the one board-level surface, hosting:
|
||||||
|
|
||||||
- **Board rename** (settled: this function stays in-app, unlike the pathfinder which dropped it with the inspector). Rename edits the board's frontmatter `title` only — the folder is never renamed by the app; the Finder document name is Finder's to change (01-storage-format.md's board-naming rule).
|
- **Board rename** (settled: this function stays in-app, unlike the pathfinder which dropped it with the inspector). Rename edits the board's frontmatter `title` only — the folder is never renamed by the app; the Finder document name is Finder's to change (01-storage-format.md's board-naming rule). A foreign rename landing while the popover is open resyncs the field from the snapshot only while the field is unfocused — a focused field keeps the user's keystrokes, the dirty-buffer courtesy applied here (settled).
|
||||||
- **Board styling** — the embedded style editor (Styling ▸ Controls above).
|
- **Board styling** — the embedded style editor (Styling ▸ Controls above).
|
||||||
- **Git integration** — mode-aware (06-history-undo.md, 07-sync-collab.md): on a mode-none board, the **add-git** action (opt-in init; on repo-nested boards replaced by the honest this-board-lives-inside-a-repository explanation — 06); on git boards, branch/source display, branch switching and creation, the commit-identity name/email fields (06), and **add/change remote** (a remote can be added or changed at any point — 07); for remote-backed boards additionally remote tracking (ahead/behind) with Pull/Push controls and the push-on-every-commit option. **Remote authentication surfaces inline here** (07 ▸ Remote authentication): credential fields on add/verify, the machine SSH key with Copy, and the Authentication-needed badge state.
|
- **Git integration** — Pro edition surface, mode-aware (06-history-undo.md, 07-sync-collab.md; in base Lanework this section is absent on ordinary boards and reduces to the contextual one-line Pro pointer on boards carrying an inert `.git` — 12-editions.md): on a mode-none board, the **add-git** action (opt-in init; on repo-nested boards replaced by the honest this-board-lives-inside-a-repository explanation — 06); on git boards, branch/source display, branch switching and creation, the commit-identity name/email fields (06), and **add/change remote** (a remote can be added or changed at any point — 07); for remote-backed boards additionally remote tracking (ahead/behind) with Pull/Push controls and the push-on-every-commit option. **Remote authentication surfaces inline here** (07 ▸ Remote authentication): credential fields on add/verify, the SSH key surface — the machine key with Copy + Verify, key import by paste or drag, the per-host key picker with removal of unreferenced imports, and confirm-gated machine-key regeneration — and the Authentication-needed badge state.
|
||||||
|
|
||||||
## Trash
|
## Trash
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ Deletion is a two-stage, Finder-style story: ⌫ tombstones (01-storage-format.m
|
|||||||
- **Drag-to-restore**: dragging a card out of the trash into one of its own board's lanes restores it at the drop position (key removed, `order` set, folder moved only if the destination lane differs). Dropped on another board it follows the drag locality model (04-interactions.md) — a live copy by default, the tombstoned original staying put; ⌘-drag for the true restore-move.
|
- **Drag-to-restore**: dragging a card out of the trash into one of its own board's lanes restores it at the drop position (key removed, `order` set, folder moved only if the destination lane differs). Dropped on another board it follows the drag locality model (04-interactions.md) — a live copy by default, the tombstoned original staying put; ⌘-drag for the true restore-move.
|
||||||
- **Keyboard, selection, and clipboard semantics** inside the shown trash (navigation, no mixed live/tombstoned selections, copy-out-only clipboard, inert moves) are specified in 04-interactions.md ▸ The trash, keyboard-first.
|
- **Keyboard, selection, and clipboard semantics** inside the shown trash (navigation, no mixed live/tombstoned selections, copy-out-only clipboard, inert moves) are specified in 04-interactions.md ▸ The trash, keyboard-first.
|
||||||
- **No editing in the trash**: tombstoned cards don't open — double-click does nothing beyond selection; Put Back or drag out first (Finder vocabulary: the trash is for restoring or purging, not working). Tombstoning a card whose window is open dismisses that window (05-card-window.md).
|
- **No editing in the trash**: tombstoned cards don't open — double-click does nothing beyond selection; Put Back or drag out first (Finder vocabulary: the trash is for restoring or purging, not working). Tombstoning a card whose window is open dismisses that window (05-card-window.md).
|
||||||
- **Delete Immediately** (per item, ⌥⌘⌫) and **Empty Trash…** (confirmed, ⇧⌘⌫) physically remove the folder(s) — Finder's trash trio throughout. **Delete Immediately confirms exactly where the loss is real** (settled): on boards without app-managed git history — mode none and repo-nested — the alert stands between one keystroke and unrecoverable deletion; on git boards it acts immediately, since the content remains reachable in history (06-history-undo.md's delete-never-forgets). A deliberate divergence from Finder's always-confirm: the prompt tracks actual recoverability, not ceremony. Empty Trash… confirms everywhere (bulk scope, not per-item recoverability, is what it guards). Time-based auto-purge remains a deferred follow-up (01-storage-format.md).
|
- **Delete Immediately** (per item, ⌥⌘⌫) and **Empty Trash…** (confirmed, ⇧⌘⌫) physically remove the folder(s) — Finder's trash trio throughout. **Delete Immediately confirms exactly where the loss is real** (settled): on boards without app-managed git history — mode none and repo-nested — the alert stands between one keystroke and unrecoverable deletion; on git boards it acts immediately, since the content remains reachable in history (06-history-undo.md's delete-never-forgets). A deliberate divergence from Finder's always-confirm: the prompt tracks actual recoverability, not ceremony. Empty Trash… confirms everywhere (bulk scope, not per-item recoverability, is what it guards) — and its scope is **the whole trash, search-independent** (settled): emptying purges every tombstone on the board, filter or no filter, the confirmation naming the true count — **counted in entries, the rows the user sees** (settled): a tombstoned lane's single entry counts once, and the alert appends "Deleting a lane also deletes every card inside it." to cover the subsumed interiors honestly ("Permanently delete 5 items" — 06-history-undo.md's plural folding); a bulk command about the trash itself never silently narrows to the visible subset, and menu validation's "non-empty" reads the board's tombstones, not the filtered view. Time-based auto-purge remains a deferred follow-up (01-storage-format.md).
|
||||||
- Every trash operation is an ordinary file write — auto-committed and undoable on git boards; on no-git boards the trash itself is the delete-recovery story (07-sync-collab.md).
|
- Every trash operation is an ordinary file write — auto-committed and undoable on git boards; on no-git boards the trash itself is the delete-recovery story (07-sync-collab.md).
|
||||||
- **Naming constraint**: two "Trash" concepts coexist — attachment Remove moves the file to the *system* Trash (05-card-window.md), while card/lane deletion lands in this in-app quasi-lane. UI copy must keep them distinguishable: Finder's "Move to Trash" phrasing is reserved for the system Trash; board deletion says "Delete", and the quasi-lane is "Trash" / "Show Trash". Final strings settled in one naming pass when the trash UI copy is written.
|
- **Naming constraint**: two "Trash" concepts coexist — attachment Remove moves the file to the *system* Trash (05-card-window.md), while card/lane deletion lands in this in-app quasi-lane. UI copy must keep them distinguishable: Finder's "Move to Trash" phrasing is reserved for the system Trash; board deletion says "Delete", and the quasi-lane is "Trash" / "Show Trash". Final strings settled in one naming pass when the trash UI copy is written.
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ The welcome window carries over from the pathfinder unchanged — confirmed, it
|
|||||||
|
|
||||||
- Welcome: resizable, no title bar (background drag); recents list with board icon, name, location, counts; single click selects, double click opens; context menu Open / Reveal in Finder / Forget.
|
- Welcome: resizable, no title bar (background drag); recents list with board icon, name, location, counts; single click selects, double click opens; context menu Open / Reveal in Finder / Forget.
|
||||||
- **Templates**: New Board (⌥⌘N — ⌘N is new *card*; 11-command-nexus.md) opens a Pages-style chooser with a mini per-lane preview per template. Inventory and definition format: 09-templates.md.
|
- **Templates**: New Board (⌥⌘N — ⌘N is new *card*; 11-command-nexus.md) opens a Pages-style chooser with a mini per-lane preview per template. Inventory and definition format: 09-templates.md.
|
||||||
- File menu: Open Recent (with Clear Menu; available everywhere), and Duplicate (⇧⌘S) — **board window only** (11-command-nexus.md), duplicating the frontmost open board to a Finder-style "copy" sibling; it never acts on a welcome-selected recent. The copy is preceded by the close flush (02-architecture.md ▸ Windows; the rule and its Edit-session exception are stated at 09-templates.md ▸ Save as Template), so neither the tree nor the copied history misses pending work; under the read-only lock Duplicate disables in every state (02-architecture.md — the flush can't run and the sibling destination shares the board's fate). The duplicate **opens in its own board window** once copied — macOS Duplicate convention; the original stays open too. On a git board, the duplicate **keeps `.git` but has its remote configuration stripped** — remotes only: the repo-local `user.name`/`user.email` (06-history-undo.md's identity home) survives, so the fork keeps its commit identity. The copy keeps every GUID — a whole-board copy is 01-storage-format.md's explicit carve-out from the copies-remint rule (a new identity namespace, no collision possible), and keeping them is what keeps the copied history true: its commits name paths that still exist. **Tombstoned items are carried too** (settled): Duplicate is a full fork, trash included — dropping them would leave the copy's working tree disagreeing with its own copied HEAD (the tombstone folders are tracked), where keeping them means the duplicate is born exactly matching its history; Empty Trash in the copy is one command away. Save as Template makes the opposite choice — a template isn't a fork (09-templates.md). A fork of the board keeps its history (undo trail, delete-never-forgets — and it opens straight in git mode via 06-history-undo.md's adoption rule), but it must not silently push into the original's remote — sharing stays a deliberate per-board opt-in. (Push-on-commit lives app-side in the board registry and never carries to a new board path anyway.)
|
- File menu: Open Recent (with Clear Menu; available everywhere), and Duplicate (⇧⌘S) — **board window only** (11-command-nexus.md), duplicating the frontmost open board to a Finder-style "copy" sibling; it never acts on a welcome-selected recent. **A sandbox refusal of the sibling write falls back to a save panel** (settled — the board's security-scoped bookmark grants its subtree, not its parent, so the sibling destination may be unwritable): the silent Finder-style sibling is attempted first; on a permission refusal a save panel opens pre-filled with the parent folder and the "copy" name — the panel's grant is the sandbox's own answer, and it doubles as a choose-another-location affordance. Cancelling the panel cancels the duplicate quietly (no banner — the user declined, nothing failed); non-permission failures (disk full, …) keep the ordinary one-shot banner. **The copy itself is cancellable** (settled — 02-architecture.md's in-progress banner promises Cancel on copy-shaped work, and Duplicate honors it): the copy runs as a per-item file walk that checks cancellation between items — never one monolithic `copyItem` — and Cancel removes the partial sibling before dismissing the banner (the attachment partial-cleanup precedent): a cancelled duplicate never happened. The copy is preceded by the close flush (02-architecture.md ▸ Windows; the rule and its Edit-session exception are stated at 09-templates.md ▸ Save as Template), so neither the tree nor the copied history misses pending work; under the read-only lock Duplicate disables in every state (02-architecture.md — the flush can't run and the sibling destination shares the board's fate). The duplicate **opens in its own board window** once copied — macOS Duplicate convention; the original stays open too. On a git board, the duplicate **keeps `.git` but has its remote configuration stripped** — remotes only: the repo-local `user.name`/`user.email` (06-history-undo.md's identity home) survives, so the fork keeps its commit identity. The copy keeps every GUID — a whole-board copy is 01-storage-format.md's explicit carve-out from the copies-remint rule (a new identity namespace, no collision possible), and keeping them is what keeps the copied history true: its commits name paths that still exist. **Tombstoned items are carried too** (settled): Duplicate is a full fork, trash included — dropping them would leave the copy's working tree disagreeing with its own copied HEAD (the tombstone folders are tracked), where keeping them means the duplicate is born exactly matching its history; Empty Trash in the copy is one command away. Save as Template makes the opposite choice — a template isn't a fork (09-templates.md). A fork of the board keeps its history (undo trail, delete-never-forgets — and it opens straight in git mode via 06-history-undo.md's adoption rule), but it must not silently push into the original's remote — sharing stays a deliberate per-board opt-in. (Push-on-commit lives app-side in the board registry and never carries to a new board path anyway.)
|
||||||
|
|
||||||
## Editing surfaces summary
|
## Editing surfaces summary
|
||||||
|
|
||||||
@@ -100,9 +101,9 @@ The pathfinder's animation behavior carries over as the committed motion languag
|
|||||||
- **Equivalent operations share one dialect.** Paste animates exactly like a drop commit (same curve, same duration) so the clipboard's move story *feels* like drag landing; keyboard one-slot moves slide for the same reason a drop does — an item that teleports is harder to follow than one that slides; cut dims the card in place, Finder-style, until paste moves it (04-interactions.md).
|
- **Equivalent operations share one dialect.** Paste animates exactly like a drop commit (same curve, same duration) so the clipboard's move story *feels* like drag landing; keyboard one-slot moves slide for the same reason a drop does — an item that teleports is harder to follow than one that slides; cut dims the card in place, Finder-style, until paste moves it (04-interactions.md).
|
||||||
- **Appear/disappear is scale + fade** (cards scale from ~0.8, lanes ~0.9, combined with opacity). A restore that moves a card across lanes flies it from old frame to new via matched geometry. Search-hiding rides the same structural transition — hiding is removal, not a special fade.
|
- **Appear/disappear is scale + fade** (cards scale from ~0.8, lanes ~0.9, combined with opacity). A restore that moves a card across lanes flies it from old frame to new via matched geometry. Search-hiding rides the same structural transition — hiding is removal, not a special fade.
|
||||||
- **Some things deliberately never animate**: the rubber-band marquee tracks the cursor 1:1 (an eased band visibly lags the mouse), and the selection highlight rides whatever transaction is active rather than easing on its own.
|
- **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's tracking and the marquee rectangle (1:1 cursor following — animating input echo would be lag), and multi-select churn. **The replica's bracketing transitions do animate** (settled): the pickup lift (scale + shadow as it detaches from the card) and the release — fly-to-slot on drop, fly-back on cancel — are ordinary structural-voice springs; only the tracking between them is verbatim input echo. **The settle holds the drop proposal until the echo lands** (settled — the one-way flow means the write is still in flight at release, and a snapshot-order re-render would glide the dragged item back before the reload animates it forward again): the proposal survives release as overlay state in TransientBoardState (02-architecture.md — the placeholder's kin), the board keeps rendering the proposed arrangement, the replica flies to the slot the proposal holds open, and the proposal discards itself when the bracket's echo reload lands — positions already match, so the handoff moves nothing. A **failed write discards the proposal** and the board animates back to snapshot order with the ordinary one-shot banner — the width-drag rollback posture (the action visibly doesn't happen); a foreign reload that vanishes the dragged item discards it too (02-architecture.md's constraint rule).
|
||||||
- **Motion never feeds back into logic** (the pathfinder's animation-proof-inputs rule, kept as a hard constraint): drop-proposal math reads analytically computed resting zones, the physical mouse position, and item sizes frozen at drag start — never mid-flight measured frames, which are garbage precisely during the ~0.2 s reflow they trigger.
|
- **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, the lane-resize rubber-band feedback, 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's lift and settle, the lane-resize rubber-band feedback, trash) are new work.
|
||||||
|
|
||||||
## Changes from Kanban
|
## Changes from Kanban
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -5,8 +5,10 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
|
|||||||
## Selection
|
## Selection
|
||||||
|
|
||||||
- Cards: click selects; ⌘-click toggles; ⇧-click range-extends; click-drag rubber-bands across lanes. Lanes: ⌘/⇧-click multi-select.
|
- Cards: click selects; ⌘-click toggles; ⇧-click range-extends; click-drag rubber-bands across lanes. Lanes: ⌘/⇧-click multi-select.
|
||||||
|
- **The range anchor** (settled — standard macOS list semantics): the anchor is the last plain- or ⌘-clicked item, per board window, transient — never persisted. ⇧-click ranges from anchor to target in the flatten order (cards), lane order (lanes), or the trash's sort filtered to kind (its skipping rule below), replacing the selection and leaving the anchor in place. A marquee and wholesale selections (Select All) set no anchor, so a following ⇧-click acts as a plain click; a reload that drops or liveness-flips the anchor clears it.
|
||||||
- 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. **The lane header is click-to-select too** (settled — a full lane has no empty space left): a plain click on the title bar selects the lane; the drag surface (03-board-ui.md ▸ Lane) engages only on movement — the click-vs-drag split cards already have.
|
- **Board background** — the margins around and between lanes, and below short content (settled): a plain click clears the selection — the pointer twin of Escape's deselect, Finder's behavior; modified clicks (⇧/⌘) are no-ops there — extension needs an item to extend to; the background is also a rubber-band origin surface on the live side, alongside lane empty space (live) and the trash column's empty space (trashed).
|
||||||
|
- Lane empty-space: single click selects the lane (click again to unselect); double click creates a card at the bottom, title editor focused. **The lane header is click-to-select too** (settled — a full lane has no empty space left): a plain click on the title bar selects the lane — **and toggles like empty space** (settled): a click on the already-selected lane's header unselects, one lane-click behavior everywhere, so a full lane keeps a pointer path out of selection; the drag surface (03-board-ui.md ▸ Lane) engages only on movement — the click-vs-drag split cards already have.
|
||||||
- **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).
|
||||||
|
|
||||||
## Drag & drop
|
## Drag & drop
|
||||||
@@ -25,8 +27,8 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
|
|||||||
|
|
||||||
- ⌘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.
|
- ⌘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`. **A tombstoned selection never anchors paste** (settled — the ⌘N target rule's own wording, returned to the precedent it cites): ⌘V stays enabled and behaves exactly as with nothing selected — a card payload appends to the last-active lane, a lane payload lands at the board's right end; the trash is never the destination (▸ The trash), and a trashed card's live disk-lane never leaks in as "the selected card's lane".
|
- Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at its last member in flatten order — the ⌘N target rule's shared anchor (The map below). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`. **A tombstoned selection never anchors paste** (settled — the ⌘N target rule's own wording, returned to the precedent it cites): ⌘V stays enabled and behaves exactly as with nothing selected — a card payload appends to the last-active lane, a lane payload lands at the board's right end; the trash is never the destination (▸ The trash), and a trashed card's live disk-lane never leaks in as "the selected card's lane".
|
||||||
- **Lane paste** lands after the anchor lane — the selected lane, or the selected card's lane; nothing selected = the board's right end. Semantics mirror the drag pair above exactly: a pasted *copy* takes fresh GUIDs throughout and **strips tombstoned cards**; a cut-paste is the ⌘-drag move — the folder moves whole, tombstoned cards landing in the destination's trash. **Pasting into the source board is supported and is the within-board lane duplicate** (settled): fresh GUIDs and tombstone-stripping apply as anywhere else, no menu-validation special case — the drag path deliberately lacks this operation (⌥ ignored on lane drags, above), the clipboard is its one home.
|
- **Lane paste** lands after the anchor lane — the selected lane, or the selected card's lane (several selected: the last, per the shared anchor rule); nothing selected = the board's right end. Semantics mirror the drag pair above exactly: a pasted *copy* takes fresh GUIDs throughout and **strips tombstoned cards**; a cut-paste is the ⌘-drag move — the folder moves whole, tombstoned cards landing in the destination's trash. **Pasting into the source board is supported and is the within-board lane duplicate** (settled): fresh GUIDs and tombstone-stripping apply as anywhere else, no menu-validation special case — the drag path deliberately lacks this operation (⌥ ignored on lane drags, above), the clipboard is its one home.
|
||||||
|
|
||||||
## Keyboard
|
## Keyboard
|
||||||
|
|
||||||
@@ -34,12 +36,13 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
|
|||||||
|
|
||||||
### Grammar (fixed keys — deliberately not remappable)
|
### Grammar (fixed keys — deliberately not remappable)
|
||||||
|
|
||||||
- **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 — except **⇧↑/⇧↓ in the lane domain, which are inert** (settled: there is nothing above the lane domain and no vertical range within it); selection scrolls into view; all grammar keys inert while a title editor is focused, and menu dispatch narrows to the text domain (focused-editor rule below).
|
||||||
- **⌥-arrows jump**: ⌥↑/⌥↓ to the current lane's first/last card; ⌥←/⌥→ to the first/last lane. **⌥↑ escalates into the lane domain** (settled — the keyboard's one entry to lane selection): with the lane's first card already selected, ⌥↑ selects the *lane* itself — up in the hierarchy sense, the same key one press deeper; with a lane selected, ↓ (or ⌥↓) descends back into its cards at the first (last) card, and ⌥↑ is inert. **An empty selection seeds at the first lane's first card** on any plain arrow (deterministic origin; the ⌥-jumps behave as specified regardless) — two ⌥↑ presses from nothing reach the lane domain.
|
- **⌥-arrows jump**: ⌥↑/⌥↓ to the current lane's first/last card; ⌥←/⌥→ to the first/last lane. **The horizontal jumps land on a card** (settled — ⌥↑ is the keyboard's one entry to lane selection, so ⌥←/⌥→ never select the lane itself): the first card of the first/last *non-empty* lane, scanning inward past empty lanes; ⌥→ prefers the shown non-empty trash — its first entry — per the last-container rule (The trash below). **⌥↑ escalates into the lane domain** (settled — the keyboard's one entry to lane selection): with the lane's first card already selected, ⌥↑ selects the *lane* itself — up in the hierarchy sense, the same key one press deeper; with a lane selected, ↓ (or ⌥↓) descends back into its cards at the first (last) card, and ⌥↑ is inert. **An empty selection seeds at the first lane's first card** on any plain arrow (deterministic origin; the ⌥-jumps behave as specified regardless) — two ⌥↑ presses from nothing reach the lane domain.
|
||||||
- **Return** on a selected lane: creates a card at its bottom, editor focused; Return commits and re-selects the lane (next Return = next card); ⌘↩ commits and opens the card window. Abandoned placeholders (Escape, empty commit, click-away) are discarded — creating-then-abandoning never leaves an empty card behind (untitled cards exist only when made deliberately, e.g. by an external writer or by clearing an existing title). The placeholder is store-transient overlay state — the named exception to 02-architecture.md's one-way flow; nothing exists on disk until the title commits.
|
- **Return** on a selected lane: creates a card at its bottom, editor focused; Return commits and re-selects the lane (next Return = next card); ⌘↩ commits and opens the card window. Abandoned placeholders (Escape, empty commit, click-away) are discarded — creating-then-abandoning never leaves an empty card behind (untitled cards exist only when made deliberately, e.g. by an external writer or by clearing an existing title). The placeholder is store-transient overlay state — the named exception to 02-architecture.md's one-way flow; nothing exists on disk until the title commits.
|
||||||
- **Inline rename tracks its target by UUID, and vanishing discards it** (the placeholder and card-window kin rules — 02-architecture.md — applied to the third inline editor): a foreign *move* mid-rename is invisible — the editor follows the UUID and the commit writes the title wherever the card now lives; a target that is tombstoned, deleted, or gone at commit time discards the editor and its keystrokes silently (a liveness flip is a vanish; nothing is ever written into a vanished folder, and no partial `index.md` can resurrect deleted data). A write that fails *after* a valid commit is the ordinary one-shot write-failure banner. VoiceOver announces the vanished target per 10-accessibility.md's recovery rule.
|
- **Inline rename tracks its target by UUID, and vanishing discards it** (the placeholder and card-window kin rules — 02-architecture.md — applied to the third inline editor): a foreign *move* mid-rename is invisible — the editor follows the UUID and the commit writes the title wherever the card now lives; a target that is 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.
|
||||||
|
- **Caret chords yield to any focused text control** (settled): Board ▸ Move Left/Move Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable via menu validation whenever *any* text control has keyboard focus — inline title editors, the board search field, board-popover fields (rename, git identity, remote), and card-window fields — because an enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the standard line-start/end caret chords. Caret motion always wins in text (the Safari pattern: ⌘← is Back, yet moves the caret while a field is focused); the lane commands re-enable the moment focus returns to the board. This is a narrow, per-command broadening of the focused-editor rule, not a general one: board commands whose chords carry no text meaning keep their surface-specific dispatch — in particular the search field's board-commands-stay-enabled rule (Search below) — and the search field's explicitly ruled ⌘⌫ steal (File ▸ Delete, not delete-to-line-start) stands.
|
||||||
- **⌫** on a live selection: delete (tombstone) — the plain-key synonym for File ▸ Delete ⌘⌫ (see The map). Grammar, not a menu item: giving it a menu home would require a second "Delete"-titled item, which would collide for title-matched remapping (Configurable bindings). Inert while a title editor is focused, like every grammar key.
|
- **⌫** on a 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.
|
||||||
- The card window speaks the same grammar: **Return** in Preview enters Edit, **Escape** returns to Preview (05-card-window.md) — plain keys, not menu items.
|
- The card window speaks the same grammar: **Return** in Preview enters Edit, **Escape** returns to Preview (05-card-window.md) — plain keys, not menu items.
|
||||||
- These plain-key behaviors are platform grammar (Finder's own Return/arrows aren't remappable either) and sit below the remapping mechanism, which handles modifier chords on menu items only — see Configurable bindings.
|
- These plain-key behaviors are platform grammar (Finder's own Return/arrows aren't remappable either) and sit below the remapping mechanism, which handles modifier chords on menu items only — see Configurable bindings.
|
||||||
@@ -50,18 +53,18 @@ Every command is a menu item. The full inventory — every command and action, i
|
|||||||
|
|
||||||
- **⌥⌘↑/⌥⌘↓ 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.
|
- **⌥⌘↑/⌥⌘↓ 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). **On the active trash side it selects by kind** (settled): with the trash visible and a non-empty tombstoned selection, Select All selects all visible trash entries of the current selection's kind — the kind-homogeneity invariant decides which "all" is meant; in every other state it selects all visible live cards.
|
||||||
- **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).
|
||||||
- **⌘N target rule** (settled): with a card selected, the new card is created in that card's lane, immediately after it (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency); the lane header's new-card button **overrides this rule** — the click names its target lane, selection notwithstanding (11-command-nexus.md ▸ Pointer grammar); with nothing selected — or a **tombstoned** selection, which never anchors creation — the **last-active lane** — the lane that most recently held selection or a creation in this window session — falling back to the first lane. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first.
|
- **⌘N target rule** (settled): with a card selected, the new card is created in that card's lane, immediately after it (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency); **a multi-selection anchors at its last member in flatten order** (settled — lane `order`, then card `order`, the multi-drag order; the same anchor serves paste): creation follows the last selected card, or appends to the last selected lane; the lane header's new-card button **overrides this rule** — the click names its target lane, selection notwithstanding (11-command-nexus.md ▸ Pointer grammar); with nothing selected — or a **tombstoned** selection, which never anchors creation — the **last-active lane** — the lane that most recently held selection or a creation in this window session — falling back to the first lane. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first.
|
||||||
|
|
||||||
### The trash, keyboard-first (settled)
|
### The trash, keyboard-first (settled)
|
||||||
|
|
||||||
The trash quasi-lane (03-board-ui.md ▸ Trash) speaks the same keyboard language when shown; hidden, it is invisible to every gesture. Rules:
|
The trash quasi-lane (03-board-ui.md ▸ Trash) speaks the same keyboard language when shown; hidden, it is invisible to every gesture — and **hiding it clears a tombstoned selection** (settled): nothing invisible stays selected, so the Show Trash toggle-off drops the selection (and with it Put Back / Delete Immediately validation) rather than leave commands enabled against rows nobody can see. Rules:
|
||||||
|
|
||||||
- **Navigation**: the shown trash is the **last container for card navigation** — arrows walk into and out of it, and ⌥→ jumps to it. The quasi-lane itself is never selectable *as a lane* (no lane op applies to it): with a lane selected, ←/→ and ⌥→ stop at the last real lane.
|
- **Navigation**: the shown trash is the **last container for card navigation** — arrows walk into and out of it, and ⌥→ jumps to it. The quasi-lane itself is never selectable *as a lane* (no lane op applies to it): with a lane selected, ←/→ and ⌥→ stop at the last real lane.
|
||||||
- **Moves are inert across the boundary**: no move or paste ever targets the trash (deleting is ⌫/⌘⌫), and ⌥⌘↑/⌥⌘↓ are inert *on* tombstoned cards (moving out is Put Back or drag-to-restore).
|
- **Moves are inert across the boundary**: no move or paste ever targets the trash (deleting is ⌫/⌘⌫), and ⌥⌘↑/⌥⌘↓ are inert *on* tombstoned cards (moving out is Put Back or drag-to-restore).
|
||||||
- **Selection is homogeneous by liveness** (extending the homogeneous-selection rule): a selection never mixes live and tombstoned cards. Select All selects visible live cards only; a rubber-band stays on the side of the boundary it started on. **⇧-arrow extension stops at both boundaries — liveness and kind** (settled, the rubber-band's rule applied to the keyboard): a ⇧-arrow whose next step would cross from live cards into the trash (or back), or from card entries onto a lane entry within it, is simply inert — the held range is never silently dropped for a move-and-replace, and the invariants below are unbreakable by keyboard. Plain arrows still walk across (navigation moves, extension stops). Menu validation stays binary — Delete for live selections, Put Back / Delete Immediately for tombstoned ones. External liveness flips can't breach the invariant: a reload that flips `deleted:` on a selected card ejects it from the selection (02-architecture.md's reload-survival rule — a flip is a vanish from its side of the boundary), so validation never sees a mixed selection.
|
- **Selection is homogeneous by liveness** (extending the homogeneous-selection rule): a selection never mixes live and tombstoned cards. Select All selects visible live cards only; a rubber-band stays on the side of the boundary it started on. **⇧-arrow extension stops at both boundaries — liveness and kind** (settled, the rubber-band's rule applied to the keyboard): a ⇧-arrow whose next step would cross from live cards into the trash (or back), or from card entries onto a lane entry within it, is simply inert — the held range is never silently dropped for a move-and-replace, and the invariants below are unbreakable by keyboard. Plain arrows still walk across (navigation moves, extension stops). Menu validation stays binary — Delete for live selections, Put Back / Delete Immediately for tombstoned ones. External liveness flips can't breach the invariant: a reload that flips `deleted:` on a selected card ejects it from the selection (02-architecture.md's reload-survival rule — a flip is a vanish from its side of the boundary), so validation never sees a mixed selection.
|
||||||
- **Tombstoned lane entries are full keyboard citizens, homogeneous by kind**: arrows walk every trash entry in its sorted order — card and lane entries alike (a lane's single restorable entry, 03-board-ui.md ▸ Trash) — and the board's cards-XOR-lanes rule extends into the trash: a selection never mixes card entries and lane entries (on top of never mixing live and tombstoned). Put Back (⌘⌫) and Delete Immediately (⌥⌘⌫) apply to lane entries exactly as to cards — a put-back lane returns whole, cards and all. A lane entry is not draggable (its entry is a compact row, not the lane); its copy-out is ⌘C only, and its move-out is Put Back.
|
- **Tombstoned lane entries are full keyboard citizens, homogeneous by kind**: arrows walk every trash entry in its sorted order — card and lane entries alike (a lane's single restorable entry, 03-board-ui.md ▸ Trash) — and the board's cards-XOR-lanes rule extends into the trash: a selection never mixes card entries and lane entries (on top of never mixing live and tombstoned). Put Back (⌘⌫) and Delete Immediately (⌥⌘⌫) apply to lane entries exactly as to cards — a put-back lane returns whole, cards and all. A lane entry is not draggable (its entry is a compact row, not the lane); its copy-out is ⌘C only, and its move-out is Put Back. **Pointer ranges keep the kind invariant by skipping, not stopping** (settled): a ⇧-click range collects only entries of the anchor's kind, skipping other-kind rows inside the span — the pointer names an unambiguous same-kind target, so it skips where the keyboard (which must cross row by row) goes inert; a trash-side marquee takes the kind of the topmost intersected entry and selects only that kind. Both keep every selection kind-homogeneous, each in the idiom of its input. **The column's gesture surface is full height** (settled): the trash column carries a full-height backdrop like the board background, so a marquee can arm from the blank area below the last row — the board side has no dead zone and the trash side doesn't either.
|
||||||
- **Clipboard: copy out only.** ⌘C (cards and lane entries), ⌥-drag, and the cross-board drag default (cards) always yield *live* copies — `deleted:` is stripped on paste/duplicate/drop, like copying a file out of Finder's Trash; a lane entry's copy additionally strips its tombstoned interior cards (the lane-copy rule — copies transfer content, and trash isn't content). ⌘X is disabled: the move-out vocabulary is Put Back or drag-to-restore, nothing else. The *cross-board restore-move* (⌘-drag below) needs no command of its own — its keyboard equivalent is the composition Put Back → ⌘X → ⌘V in the destination: same folder, same identity.
|
- **Clipboard: copy out only.** ⌘C (cards and lane entries), ⌥-drag, and the cross-board drag default (cards) always yield *live* copies — `deleted:` is stripped on paste/duplicate/drop, like copying a file out of Finder's Trash; a lane entry's copy additionally strips its tombstoned interior cards (the lane-copy rule — copies transfer content, and trash isn't content). ⌘X is disabled: the move-out vocabulary is Put Back or drag-to-restore, nothing else. The *cross-board restore-move* (⌘-drag below) needs no command of its own — its keyboard equivalent is the composition Put Back → ⌘X → ⌘V in the destination: same folder, same identity.
|
||||||
- **Everything edit-shaped is disabled** on tombstoned selections — Open Card, Rename, Style… (File ▸ Duplicate is untouched: it duplicates the board, never the selection — 11-command-nexus.md; card copies out of the trash are ⌘C or ⌥-drag, which name a live destination). Finder file drops (attachment import) on tombstoned cards are inert — 03-board-ui.md's no-editing-in-the-trash.
|
- **Everything edit-shaped is disabled** on tombstoned selections — Open Card, Rename, Style… (File ▸ Duplicate is untouched: it duplicates the board, never the selection — 11-command-nexus.md; card copies out of the trash are ⌘C or ⌥-drag, which name a live destination). Finder file drops (attachment import) on tombstoned cards are inert — 03-board-ui.md's no-editing-in-the-trash.
|
||||||
- **Drag-to-restore follows the locality model**: dropping a tombstoned card into one of its own board's lanes restores it at the drop position (`deleted:` removed, `order` set). Dropped on *another* board it follows the copy default — a live copy lands there and the tombstoned original stays in the source trash (copy-out, like ⌘C); ⌘-drag forces the true cross-board restore-move (the tombstone leaves the source board; ordinary cross-board move semantics, `deleted:` cleared at the destination).
|
- **Drag-to-restore follows the locality model**: dropping a tombstoned card into one of its own board's lanes restores it at the drop position (`deleted:` removed, `order` set). Dropped on *another* board it follows the copy default — a live copy lands there and the tombstoned original stays in the source trash (copy-out, like ⌘C); ⌘-drag forces the true cross-board restore-move (the tombstone leaves the source board; ordinary cross-board move semantics, `deleted:` cleared at the destination).
|
||||||
@@ -83,7 +86,7 @@ Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App
|
|||||||
|
|
||||||
- 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 — 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 is staged** (settled): in a non-empty field it clears the query, focus staying in the field; in an empty field it returns focus to the board; with *board* focus and an active search, one press clears the search and the full board returns — search takes Escape before its clear-selection meaning, which applies only when no search is active.
|
- 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 is staged** (settled): in a non-empty field it clears the query, focus staying in the field; in an empty field it returns focus to the board; with *board* focus and an active search, one press clears the search and the full board returns — search takes Escape before its clear-selection meaning, which applies only when no search is active.
|
||||||
- **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. **Every key with the field focused acts on the field — stock NSSearchField behavior, no pass-throughs** (settled): vertical arrows are caret movement, ⇧-arrows select query text, and **Return is a swallowed no-op** (the filter is live, there is nothing to submit — it never reaches the board's rename/create grammar). **Tab is the keep-filter path**: plain key-view traversal moves focus to the board with the query intact, and the whole board grammar (arrows, ⌥↑ escalation, Return, ⌘↩) then applies over the *filtered* board; ⌘F returns to the field. 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. **Every key with the field focused acts on the field — stock NSSearchField behavior, no pass-throughs** (settled): vertical arrows are caret movement, ⇧-arrows select query text, and **Return is a swallowed no-op** (the filter is live, there is nothing to submit — it never reaches the board's rename/create grammar). **Tab is the keep-filter path**: plain key-view traversal moves focus to the board with the query intact, and the whole board grammar (arrows, ⌥↑ escalation, Return, ⌘↩) then applies over the *filtered* board; ⌘F returns to the field. 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) — **except the caret-chord commands**: Move Left/Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable while the field is focused (Grammar above, caret-chords rule), so ⌘←/⌘→ stay line-start/end in the query even with a lane selected — and the Delete pair stays unambiguous by construction: plain ⌫ is query editing, ⌘⌫ is File ▸ Delete on the selection, and ⌘Z/⇧⌘Z are the field's own text undo, never git undo (06-history-undo.md ▸ Undo routing's control-class rule).
|
||||||
|
|
||||||
## Changes from Kanban
|
## Changes from Kanban
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Settled the hard way in the pathfinder (WYSIWYG built, then reversed): the body
|
|||||||
|
|
||||||
- Renders headings, bold/italic/code, bullet/ordered/task lists, fenced + indented code, nested quotes, GFM tables (per-column alignment, columns sized to contents with the browser sizing rule), thematic breaks, HTML shown **verbatim as literal code-styled text** (never interpreted — no web view, per 00-vision.md's no-web-tech stance), and images resolved against the card's own folder (``).
|
- Renders headings, bold/italic/code, bullet/ordered/task lists, fenced + indented code, nested quotes, GFM tables (per-column alignment, columns sized to contents with the browser sizing rule), thematic breaks, HTML shown **verbatim as literal code-styled text** (never interpreted — no web view, per 00-vision.md's no-web-tech stance), and images resolved against the card's own folder (``).
|
||||||
- **Remote images are never fetched** — Preview does no networking (sandbox-quiet, files-first). An `` renders as a quiet placeholder chip carrying the alt text (or the URL); the file-relative form above is the supported image story.
|
- **Remote images are never fetched** — Preview does no networking (sandbox-quiet, files-first). An `` renders as a quiet placeholder chip carrying the alt text (or the URL); the file-relative form above is the supported image story.
|
||||||
- **Task-list checkboxes are live**: clicking a `- [ ]` / `- [x]` checkbox flips exactly that marker in the source — a single-character textual edit; every other byte of the body is untouched. This is the deliberate exception to "Preview only reads": checklists are kanban's working currency, and a mode flip to tick a box is ceremony. A toggle is an ordinary user edit — the standard atomic write, auto-committed and undoable on git boards. **The pointer-free path is the system focus model** (settled): checkboxes — like Preview's links — are real controls in the keyboard-focus and accessibility tree, so Full Keyboard Access Tab-reaches them and Space toggles, and VoiceOver toggles with VO-Space (10-accessibility.md's real-accessible-checkboxes promise, honored natively). Without FKA they are not in the key loop — standard macOS content behavior, so ordinary Tab users never wade through a long checklist. In-content controls are *content*, not commands: no menu item, no chord — 04's every-function-has-a-menu-item contract covers commands, and 11-command-nexus.md scopes them accordingly. Under the read-only lock (02-architecture.md) the controls disable in place — the one mutating path menu validation can't reach.
|
- **Task-list checkboxes are live**: clicking a `- [ ]` / `- [x]` checkbox flips exactly that marker in the source — a single-character textual edit; every other byte of the body is untouched. This is the deliberate exception to "Preview only reads": checklists are kanban's working currency, and a mode flip to tick a box is ceremony. A toggle is an ordinary user edit — the standard atomic write, auto-committed and undoable on git boards. **The pointer-free path is the system focus model** (settled): checkboxes — like Preview's links — are real controls in the keyboard-focus and accessibility tree, so Full Keyboard Access Tab-reaches them and Space toggles, and VoiceOver toggles with VO-Space (10-accessibility.md's real-accessible-checkboxes promise, honored natively). Without FKA they are not in the key loop — standard macOS content behavior, so ordinary Tab users never wade through a long checklist. In-content controls are *content*, not commands: no menu item, no chord — 04's every-function-has-a-menu-item contract covers commands, and 11-command-nexus.md scopes them accordingly. Under the read-only lock (02-architecture.md) the controls disable in place — an in-content mutation menu validation can't reach (and not the only such path: the attachment row's ⌫/Remove shares the posture — 02's every-entry-point predicate).
|
||||||
- Links: external URLs open in the browser; relative links open the target file with its default app (resolved against the card folder, like images).
|
- Links: external URLs open in the browser; relative links open the target file with its default app (resolved against the card folder, like images).
|
||||||
- **Edit ▸ Find (⌘F) is find-in-text here** — the standard find bar over the focused body surface (Preview's selectable text, the Edit editor, raw source); board search is a board-window concern (04-interactions.md ▸ Search).
|
- **Edit ▸ Find (⌘F) is find-in-text here** — the standard find bar over the focused body surface (Preview's selectable text, the Edit editor, raw source); board search is a board-window concern (04-interactions.md ▸ Search).
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ The card-level styling home: the **embedded style editor** — background palett
|
|||||||
## Deletion & lifecycle
|
## Deletion & lifecycle
|
||||||
|
|
||||||
- The window follows its card across lanes (keyed by board URL + GUID) — *within its board*. A **cross-board move dismisses the window like a delete**: the card left this board — its UUID travels with the move (reminted only on an import-boundary collision, 01-storage-format.md's identity lifecycle), but the window's key is board URL + GUID, and the board half no longer names it.
|
- The window follows its card across lanes (keyed by board URL + GUID) — *within its board*. A **cross-board move dismisses the window like a delete**: the card left this board — its UUID travels with the move (reminted only on an import-boundary collision, 01-storage-format.md's identity lifecycle), but the window's key is board URL + GUID, and the board half no longer names it.
|
||||||
- Window dismisses itself if the card is deleted — and a **tombstone counts as deleted**: ⌫ on the board closes the card's open window (the card is gone from the board's perspective; Put Back and reopen if it was a slip). Cards in the shown trash quasi-lane don't open at all — restore first (03-board-ui.md). Reopening a live card focuses the existing window.
|
- Window dismisses itself if the card is deleted — and a **tombstone counts as deleted**: ⌫ on the board closes the card's open window (the card is gone from the board's perspective; Put Back and reopen if it was a slip). **Liveness is effective — ancestor-walked** (settled; 02-architecture.md's rule extended to its last consumer): tombstoning or deleting the card's *lane* dismisses the window too, the card's own flag notwithstanding — the card renders nowhere and the trash shows only the lane's single subsuming entry, so no window may stay open, much less writable, over it. Put Back of the lane reopens nothing — reopening is the user's act, like any open. **Dismissal never eats typed work silently where a save can land** (settled): a dirty Edit buffer flushes into the tombstoned card's folder before the window dismisses — a surgical body write that leaves `deleted:` intact, so the keystrokes survive Put Back and enter history on git boards (the composer reads it as an edit to a deleted card — accurate). An open raw-source buffer discards instead: its Apply writes the *whole* pre-tombstone `index.md` and would silently undelete the card — a foreign delete is never reverted by a stale buffer. A card hard-deleted externally (folder gone) discards both — nowhere left to write, the inline-rename rule. Cards in the shown trash quasi-lane don't open at all — restore first (03-board-ui.md). Reopening a live card focuses the existing window.
|
||||||
|
|
||||||
## Changes from Kanban
|
## Changes from Kanban
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
# History & Undo
|
# History & Undo
|
||||||
|
|
||||||
|
**Edition scope: Lanework Pro** (12-editions.md). This doc is the git HistoryProvider; base Lanework ships mode:none only, with macOS-native undo (13-native-undo.md) and the inert-`.git` posture (12). The Undo routing section below is edition-independent — both substrates dispatch through it.
|
||||||
|
|
||||||
Git is the undo substrate — on boards that have git. **Git is opt-in per board (a pivot from the pathfinder, which auto-initialized every board): a board may be created without git, and git can be added later** (via the board popover; see 07-sync-collab.md's mode progression). A board without git has **no undo/redo** (board history, that is — text editors keep their standard typing undo everywhere; see Undo routing below) — consistent with the settled no-undo stance for repo-nested boards; deletes are the exception, recoverable on every board via the tombstone trash (03-board-ui.md). On git-enabled boards, every settled change auto-commits; those mechanics are carried over from the pathfinder with their hard rules intact.
|
Git is the undo substrate — on boards that have git. **Git is opt-in per board (a pivot from the pathfinder, which auto-initialized every board): a board may be created without git, and git can be added later** (via the board popover; see 07-sync-collab.md's mode progression). A board without git has **no undo/redo** (board history, that is — text editors keep their standard typing undo everywhere; see Undo routing below) — consistent with the settled no-undo stance for repo-nested boards; deletes are the exception, recoverable on every board via the tombstone trash (03-board-ui.md). On git-enabled boards, every settled change auto-commits; those mechanics are carried over from the pathfinder with their hard rules intact.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- **Opt-in init**: adding git to a board initializes a local repo at the board root. Bundled libgit2 — no git install required. No silent auto-init, ever.
|
- **Opt-in init**: adding git to a board initializes a local repo at the board root. Bundled libgit2 — no git install required. No silent auto-init, ever.
|
||||||
- **Adoption**: a board whose root already contains `.git` opens **in git mode, silently** — adoption is not init. The no-silent-auto-init rule forbids *creating* a repository the user didn't ask for; recognizing one that exists is the opposite of that: the repo's presence *is* the opt-in (someone ran `git init` or `git clone`), and this is the primary way a second machine joins a shared board — clone in a terminal, open in the app (07-sync-collab.md's second entry arrow). All git-mode behavior applies from the first open: auto-commit, undo reseeded from the existing HEAD's first-parent ancestry, remote tracking if a remote is configured.
|
- **Adoption**: a board whose root already contains `.git` opens **in git mode, silently** — adoption is not init. The no-silent-auto-init rule forbids *creating* a repository the user didn't ask for; recognizing one that exists is the opposite of that: the repo's presence *is* the opt-in (someone ran `git init` or `git clone`), and this is the primary way a second machine joins a shared board — clone in a terminal, open in the app (07-sync-collab.md's second entry arrow). All git-mode behavior applies from the first open: auto-commit, undo reseeded from the existing HEAD's first-parent ancestry, remote tracking if a remote is configured.
|
||||||
- **Detection is nearest-`.git`-wins**, checked at every board open: `.git` at the board root → git mode (adoption above); no `.git` at the root but one at any ancestor → repo-nested (below); neither → mode none. A board can therefore change mode between opens (e.g. the user ran `git init` in a terminal) — the app just reflects what it finds. **Open-time only, deliberately**: a `git init` under an open mode-none board takes effect at the next open — the running session keeps its mode, and the watcher does not scan for `.git` appearing (no mid-session mode flips; stated here so it isn't rediscovered as a bug).
|
- **Detection is nearest-`.git`-wins**, checked at every board open: `.git` at the board root → git mode (adoption above); no `.git` at the root but one at any ancestor → repo-nested (below); neither → mode none. A board can therefore change mode between opens (e.g. the user ran `git init` in a terminal) — the app just reflects what it finds. **Open-time only, deliberately — for *discovery***: a `git init` under an open mode-none board takes effect at the next open — the running session keeps its mode, and the watcher does not scan for `.git` appearing (no mid-session mode flips from watching; stated here so it isn't rediscovered as a bug). The one deliberate mid-session transition is the app's own **add-git** (Opt-in init above): clicking it flips the open board into git mode immediately — the popover flows straight into the git controls, the first auto-commit follows — the rule forbids *discovered* flips, never commanded ones.
|
||||||
- **Abnormal repo states** (settled; adoption never assumes a tidy clone): an **unborn HEAD** (`git init`, no commits yet) is normal git mode — the first auto-commit creates the root commit on the branch HEAD names, and the undo trail simply starts empty. A **detached HEAD**, or an **in-progress merge/rebase/cherry-pick** left by outside-the-app git (`MERGE_HEAD`, `rebase-merge`/`rebase-apply`, `CHERRY_PICK_HEAD` — pause states that load fine on a clean tree and are otherwise invisible), instead **pauses the git surface honestly — the *whole* surface, remote half included** (settled): auto-commit holds (the auth-pause posture, 07-sync-collab.md — pause, badge, explain, never hammer), Undo/Redo and the branch controls disable, **and Pull, Push, and push-on-commit hold with them** — with auto-commit held, 07's clean-tree-by-pull-time invariant is false, and a pull's rebase (or a rejected push's fetch→rebase→push) would run against a dirty tree carrying uncommitted edits, precisely what flush-before-overwrite exists to prevent; the ahead/behind badge keeps counting (a fetch is a read), and the popover's git section names the state plainly ("HEAD is detached — commits would belong to no branch"; "a merge is in progress") and says resolving it belongs to the tool that created it. Edits keep landing on disk — files are the board — and commit as one settled batch when the state clears. The app **never mutates repo state it didn't create** (no auto branch-at-HEAD, no `merge --abort`); the check runs at open and again before every flush, so finishing the operation in a terminal resumes the pipeline without ceremony. **The one exemption is the app's own leftovers** (settled): every bracketed operation stamps its intent app-side (per-board registry) before touching the repo, so an interrupted app-run rebase or checkout is recognizable as Lanework's — finding a pause state with a matching stamp, the app **aborts its own unfinished operation** to restore the pre-operation state and says so via banner ("a branch switch was interrupted — the previous state is restored"), then clears the stamp. Abort discards nothing: fetched commits stay fetched, local commits are restored — the rebase's own no-loss accounting. Without a matching stamp the leftover is outside git's, and the pause-and-defer stance above holds unchanged.
|
- **Abnormal repo states** (settled; adoption never assumes a tidy clone): an **unborn HEAD** (`git init`, no commits yet) is normal git mode — the first auto-commit creates the root commit on the branch HEAD names, and the undo trail simply starts empty. **The root commit has its own subject** (settled): whenever the app creates a repo's first commit — immediately on the app's own add-git (init doesn't wait for the debounce; the board is protected from the moment git exists), or at the first settled change on an adopted unborn repo — it commits the whole tree as **"Initial board state"**, never a folded diff-from-empty: there is no last-committed snapshot to diff against, and forty Adds would bury the event. A **detached HEAD**, or an **in-progress merge/rebase/cherry-pick** left by outside-the-app git (`MERGE_HEAD`, `rebase-merge`/`rebase-apply`, `CHERRY_PICK_HEAD` — pause states that load fine on a clean tree and are otherwise invisible), instead **pauses the git surface honestly — the *whole* surface, remote half included** (settled): auto-commit holds (the auth-pause posture, 07-sync-collab.md — pause, badge, explain, never hammer), Undo/Redo and the branch controls disable, **and Pull, Push, and push-on-commit hold with them** — with auto-commit held, 07's clean-tree-by-pull-time invariant is false, and a pull's rebase (or a rejected push's fetch→rebase→push) would run against a dirty tree carrying uncommitted edits, precisely what flush-before-overwrite exists to prevent; the ahead/behind badge keeps counting (a fetch is a read), and the popover's git section names the state plainly ("HEAD is detached — commits would belong to no branch"; "a merge is in progress") and says resolving it belongs to the tool that created it. Edits keep landing on disk — files are the board — and commit as one settled batch when the state clears. The app **never mutates repo state it didn't create** (no auto branch-at-HEAD, no `merge --abort`); the check runs at open and again before every flush, so finishing the operation in a terminal resumes the pipeline without ceremony. **The one exemption is the app's own leftovers** (settled): every bracketed operation stamps its intent app-side (per-board registry) before touching the repo, so an interrupted app-run rebase or checkout is recognizable as Lanework's — finding a pause state with a matching stamp, the app **aborts its own unfinished operation** to restore the pre-operation state and says so via banner ("a branch switch was interrupted — the previous state is restored"), then clears the stamp. Abort discards nothing: fetched commits stay fetched, local commits are restored — the rebase's own no-loss accounting. Without a matching stamp the leftover is outside git's, and the pause-and-defer stance above holds unchanged.
|
||||||
- **Boards nested inside an existing repository are left strictly alone** — git cannot be added to them (no nested repo, no commits into the user's repo), so they get **no undo** (settled; no app-managed undo journal, which would violate self-containment). The board popover's git section must say so honestly: not a hidden "add git" but a short explanation ("this board lives inside a repository; Lanework leaves it to that repository") — the option is absent because it *can't* apply, and the UI should teach that rather than look broken.
|
- **Boards nested inside an existing repository are left strictly alone** — git cannot be added to them (no nested repo, no commits into the user's repo), so they get **no undo** (settled; no app-managed undo journal, which would violate self-containment). The board popover's git section must say so honestly: not a hidden "add git" but a short explanation ("this board lives inside a repository; Lanework leaves it to that repository") — the option is absent because it *can't* apply, and the UI should teach that rather than look broken.
|
||||||
- **Auto-commit**: every settled change (debounced past drag/typing churn) commits with a descriptive message ("Move card 'Fix login' to Doing"). Board undo sees **Edit sessions, not save ticks** (resettled from typing-settle granularity): the body editor's ~700 ms disk saves (05-card-window.md) keep the file crash-safe throughout a session but stay **uncommitted** — the body commit lands when the session ends, the **Edit→Preview flip being the effective Save button** (raw-source entry and window close end the session too). The committer **stages around open Edit sessions**: a board change committing mid-session excludes the session card's folder from staging, so a lane move never sweeps half-typed body text into its commit. Settled-tree events that cannot wait — a pull's flush-before-overwrite — commit the session's on-disk saves as-is (a mechanical exception; 07-sync-collab.md's same-card signpost covers the visible half); branch switch instead gates on explicit save-or-discard (Branch switching below). The cadence constraint below demands batching at least this coarse. **Board-window close and app quit flush the pipeline** — any pending editor save (05-card-window.md), then the pending auto-commit — before teardown; nothing settled is ever left unsaved or uncommitted by closing.
|
- **Auto-commit**: every settled change (debounced past drag/typing churn) commits with a descriptive message ("Move card 'Fix login' to Doing"). Board undo sees **Edit sessions, not save ticks** (resettled from typing-settle granularity): the body editor's ~700 ms disk saves (05-card-window.md) keep the file crash-safe throughout a session but stay **uncommitted** — the body commit lands when the session ends, the **Edit→Preview flip being the effective Save button** (raw-source entry and window close end the session too). The committer **stages around open Edit sessions**: a board change committing mid-session excludes the session card's folder from staging, so a lane move never sweeps half-typed body text into its commit. Settled-tree events that cannot wait — a pull's flush-before-overwrite — commit the session's on-disk saves as-is (a mechanical exception; 07-sync-collab.md's same-card signpost covers the visible half); branch switch instead gates on explicit save-or-discard (Branch switching below). The cadence constraint below demands batching at least this coarse. **Board-window close and app quit flush the pipeline** — any pending editor save (05-card-window.md), then the pending auto-commit — before teardown; nothing settled is ever left unsaved or uncommitted by closing.
|
||||||
- **Flush-before-overwrite**: before an app write overwrites on-disk state that differs from the last-loaded snapshot (an uncommitted external change — e.g. an agent's body rewrite racing the card editor's debounced save, 05-card-window.md), the pending auto-commit is flushed so the external version enters history first. "Both versions exist as commits" is thereby a guarantee, not a likelihood. The same flush settles the tree before a pull runs (07-sync-collab.md).
|
- **Flush-before-overwrite**: before an app write overwrites on-disk state that differs from the last-loaded snapshot (an uncommitted external change — e.g. an agent's body rewrite racing the card editor's debounced save, 05-card-window.md), the pending auto-commit is flushed so the external version enters history first. "Both versions exist as commits" is thereby a guarantee, not a likelihood. The same flush settles the tree before a pull runs (07-sync-collab.md).
|
||||||
@@ -21,16 +23,16 @@ Git is the undo substrate — on boards that have git. **Git is opt-in per board
|
|||||||
|
|
||||||
## Undo routing
|
## Undo routing
|
||||||
|
|
||||||
**Routing is by focus** — the platform's first-responder rule, its own section because two undo systems coexist and four docs cite the rule. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is the git trail. Text undo works on **every** board — no-git and repo-nested included; "no undo/redo" above means board history, not typing. With focus anywhere else, Edit ▸ Undo/Redo are git undo (and are disabled on boards without it). **No fall-through**: exhausting a focused editor's stack beeps; it never reaches board history.
|
**Routing is by focus** — the platform's first-responder rule, its own section because two undo systems coexist and four docs cite the rule. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is the git trail. Text undo works on **every** board — no-git and repo-nested included; "no undo/redo" above means board history, not typing. **Control-class text fields route the same way** (settled): the search field (04-interactions.md ▸ Search) and the popover's text fields (board rename, commit identity, credentials) own ⌘Z/⇧⌘Z as field-local text undo while focused — "board menu commands stay enabled" never hands Edit ▸ Undo to git while a text-bearing control has focus; a reflexive undo over a typo must never become a tree checkout. With focus outside every text-bearing surface — editor or control — Edit ▸ Undo/Redo are git undo (and are disabled on boards without it). **No fall-through**: exhausting a focused editor's stack beeps; it never reaches board history.
|
||||||
|
|
||||||
## Commit messages
|
## Commit messages
|
||||||
|
|
||||||
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'"), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: `deleted:` appearing is Delete (the tombstone), `deleted:` *clearing* is Restore ("Restore card 'X'" — Put Back, drag-to-restore), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — Delete Immediately, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows fall back to "Update board" — always with a bulleted body naming every event, so the oneline log stays scannable and the full message stays complete.
|
- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus Attach / Remove for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's banner-gated fix; app-mediated, so the composer names it directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: `deleted:` appearing is Delete (the tombstone), `deleted:` *clearing* is Restore ("Restore card 'X'" — Put Back, drag-to-restore), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — Delete Immediately, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows fall back to "Update board" — always with a bulleted body naming every event, so the oneline log stays scannable and the full message stays complete.
|
||||||
- **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".
|
||||||
- **Non-snapshot files commit too** (settled — the repo tracks more than the model: the agent guide, `CLAUDE.user.md`, the seeded `.gitignore`, and strays at every level): the committer **stages the whole board root** — whatever `git status` shows, `.gitignore` respected — and its commit condition is the *tree*, not the snapshot diff, so a stray-only window commits rather than leaving the tree dirty (a permanently dirty stray would break branch switch's cannot-fail-dirty guarantee and void flush-before-overwrite for every file the model can't see). The composer's input extends accordingly: beside the snapshot diff it receives the changed-path list, and non-snapshot paths compose **path-shaped events** — `CLAUDE.md` composes "Update agent guide (vN)", the version read from the guide's marker first line (a pure function of file content, *not* write-site tagging — the no-interception rule stands); any other non-snapshot path composes "Update '⟨path⟩'", folding plural ("Update 3 files"). Model events keep the subject when present; non-snapshot changes then ride as body bullets — recorded, never silently absorbed under an unrelated subject. Attribution needs no new rule: the EchoLedger (02-architecture.md) is path-keyed, so the app's guide write classifies app-mediated by its receipt (the mechanism behind the guide-attribution exception below) and a stray edit classifies foreign, the two-commit split applying per file as everywhere else.
|
- **Non-snapshot files commit too** (settled — the repo tracks more than the model: the agent guide, `CLAUDE.user.md`, the seeded `.gitignore`, and strays at every level): the committer **stages the whole board root** — whatever `git status` shows, `.gitignore` respected, **open Edit sessions still staged around** (settled): the session card's folder stays excluded exactly as in Rules ▸ Auto-commit, whole-root staging widening *what* commits, never overriding the exclusion — and its commit condition is the *tree*, not the snapshot diff, so a stray-only window commits rather than leaving the tree dirty (firing mid-session, it commits the strays and leaves the session folder untouched) (a permanently dirty stray would break branch switch's cannot-fail-dirty guarantee and void flush-before-overwrite for every file the model can't see). The composer's input extends accordingly: beside the snapshot diff it receives the changed-path list, and non-snapshot paths compose **path-shaped events** — `CLAUDE.md` composes "Update agent guide (vN)", the version read from the guide's marker first line (a pure function of file content, *not* write-site tagging — the no-interception rule stands); any other non-snapshot path composes "Update '⟨path⟩'", folding plural ("Update 3 files"). Model events keep the subject when present; non-snapshot changes then ride as body bullets — recorded, never silently absorbed under an unrelated subject. Attribution needs no new rule: the EchoLedger (02-architecture.md) is path-keyed, so the app's guide write classifies app-mediated by its receipt (the mechanism behind the guide-attribution exception below) and a stray edit classifies foreign, the two-commit split applying per file as everywhere else.
|
||||||
- **Titles truncate in subjects only** (~40 chars, keeping `git log --oneline` sane); body lines carry full titles. An untitled item reads "(untitled)" — never a bare `""` (a pathfinder edge fixed, not carried). Undo/redo restores commit as "Undo: ⟨subject⟩" / "Redo: ⟨subject⟩"; the undo-menu labels are the *crossed* commit's subject, so labels never nest.
|
- **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.
|
||||||
|
|
||||||
**The external gap, closed** (the pathfinder weakness this section exists to fix): the composer is origin-agnostic, but external writers routinely touch what the pathfinder's diff never modeled — `labels`, `assignees`, `due`, custom frontmatter keys — so their commits degraded to a generic fallback even though the attribution machinery knew plenty. The rewrite:
|
**The external gap, closed** (the pathfinder weakness this section exists to fix): the composer is origin-agnostic, but external writers routinely touch what the pathfinder's diff never modeled — `labels`, `assignees`, `due`, custom frontmatter keys — so their commits degraded to a generic fallback even though the attribution machinery knew plenty. The rewrite:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Sync & Collaboration
|
# Sync & Collaboration
|
||||||
|
|
||||||
|
**Edition scope: Lanework Pro** (12-editions.md). Base Lanework ships mode:none only — the state machine below never leaves its first state there, `.git` encountered on disk is inert (12), and the Mode: none section's no-undo caveat is superseded in base by native undo (13-native-undo.md). Teams adds tracker-backed sync behind the same seam (deferred).
|
||||||
|
|
||||||
Every board has exactly one **collab mode** at a time, but the mode is not fixed at creation — it can evolve over the board's lifetime:
|
Every board has exactly one **collab mode** at a time, but the mode is not fixed at creation — it can evolve over the board's lifetime:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -21,9 +23,9 @@ The board is a git repository (the 06-history-undo.md substrate — undo/redo, a
|
|||||||
- Push/pull becomes a sharing mechanism between machines/people at file-level granularity; the fractal one-item-one-file design keeps conflicts rare and small (a reorder touches one file).
|
- Push/pull becomes a sharing mechanism between machines/people at file-level granularity; the fractal one-item-one-file design keeps conflicts rare and small (a reorder touches one file).
|
||||||
- **Remote tracking lives in the board popover** (03-board-ui.md): ahead/behind indicator plus manual **Pull** and **Push** controls — which are also Board-menu items (no default chord, remappable — 11-command-nexus.md; the configuration carve-out is 04-interactions.md's).
|
- **Remote tracking lives in the board popover** (03-board-ui.md): ahead/behind indicator plus manual **Pull** and **Push** controls — which are also Board-menu items (no default chord, remappable — 11-command-nexus.md; the configuration carve-out is 04-interactions.md's).
|
||||||
- **Which remote is the board's remote** (settled — adopted clones can carry several): resolution is git's own defaulting — the current branch's upstream remote; else `origin`; else the repo's sole remote. The popover names the remote it tracks, its change-remote control edits exactly that one, add-remote on a remote-less repo creates `origin`, and 06-history-undo.md's quiet first `push -u` targets the resolved remote (recording it as the upstream, which pins resolution thereafter). The unresolvable case — several remotes, no upstream, none named `origin` — is surfaced honestly: remote operations disable and the popover offers a one-time remote picker, whose choice becomes the branch's upstream on the next push.
|
- **Which remote is the board's remote** (settled — adopted clones can carry several): resolution is git's own defaulting — the current branch's upstream remote; else `origin`; else the repo's sole remote. The popover names the remote it tracks, its change-remote control edits exactly that one, add-remote on a remote-less repo creates `origin`, and 06-history-undo.md's quiet first `push -u` targets the resolved remote (recording it as the upstream, which pins resolution thereafter). The unresolvable case — several remotes, no upstream, none named `origin` — is surfaced honestly: remote operations disable and the popover offers a one-time remote picker, whose choice becomes the branch's upstream on the next push.
|
||||||
- **Optional push-on-commit**: a per-board setting (stored app-side in the board registry — 02-architecture.md's per-board app state); when enabled, every auto-commit is pushed immediately.
|
- **Optional push-on-commit**: a per-board setting (stored app-side in the board registry — 02-architecture.md's per-board app state); when enabled, **every commit the app makes** is pushed immediately — auto-commits and undo/redo restore commits alike (06-history-undo.md's restores are commits like any other; a shared board never shows a phantom lag after an undo).
|
||||||
- **Push failures never nag.** A push rejected as non-fast-forward (another machine pushed first) triggers an automatic **fetch → rebase → push**, with bounded retries — the same rebase machinery as Pull, so it cannot block and cannot conflict. Manual Push behaves identically. Stated plainly: enabling push-on-commit implicitly accepts that remote commits may land in the live board whenever pushes race — consistent with the board's live-reload nature, but it should be learned from the design, not discovered. All other push failures stay quiet: pushes queue, the ahead/behind indicator in the board popover carries the pending count and the last error, and pushing resumes automatically on the next commit or manual Push. No modals, no per-commit errors. The one refinement: **authentication failures pause rather than retry** — see Remote authentication below.
|
- **Push failures never nag.** A push rejected as non-fast-forward (another machine pushed first) triggers an automatic **fetch → rebase → push**, with bounded retries — the same rebase machinery as Pull, so it cannot block and cannot conflict. Manual Push behaves identically. Stated plainly: enabling push-on-commit implicitly accepts that remote commits may land in the live board whenever pushes race — consistent with the board's live-reload nature, but it should be learned from the design, not discovered. All other push failures stay quiet: pushes queue, the ahead/behind indicator in the board popover carries the pending count and the last error, and pushing resumes automatically on the next commit or manual Push. No modals, no per-commit errors. The one refinement: **authentication failures pause rather than retry** — see Remote authentication below.
|
||||||
- **There can be no conflicts — and no data loss.** Every edit becomes a commit before anything can overwrite it (auto-commit settles local changes; the tree is clean by the time a pull runs). The clean-tree premise is why **abnormal repo states pause the remote half too**: a detached HEAD or in-progress merge/rebase holds Pull, Push, and push-on-commit alongside auto-commit (06-history-undo.md ▸ Abnormal repo states — the whole git surface pauses; the ahead/behind badge keeps counting, a fetch being a read). A pull — manual, or the automatic fetch-rebase after a rejected push — runs only at **interaction rest**: it queues behind an in-flight drag or open inline editor (the same settled-change notion the auto-commit debounce uses), flushes the pending auto-commit (06-history-undo.md's flush-before-overwrite), then runs bracketed (02-architecture.md's live-reload resilience) — never an error dialog, never a board yanked mid-drag. An open card-body **Edit session neither blocks a pull nor is interrupted by one**: the flush commits the session's on-disk saves as-is (06-history-undo.md's mechanical exception to session-granularity commits) and the rebase runs; when the pulled commits touch the very card being edited, the card window **signposts** the remote change (a transient banner, no modal, no merge UI) while the dirty buffer stays put and wins per 05-card-window.md — the losing remote version is a commit, one revert away. An open **raw-source buffer gets the same treatment** (settled): a pull neither blocks on it nor invalidates it — the bracket's write lock merely disables Apply while the pull runs — and the same-card signpost shows in source mode too (the banner strip is window furniture, not part of the swapped content area). A later Apply is last-writer-wins across the *whole file*, frontmatter included, with the overwritten pulled version a commit one revert away — on the same branch this is exactly the Edit-buffer race. (Branch switch and undo restore must gate raw buffers on save-or-discard instead — 06-history-undo.md ▸ Branch switching — because there a stale Apply would write onto a *different tree's* card, not merely race a newer version of the same one.) A pull fetches the remote's commits and **rebases local commits on top of them**; where a rebase hits a genuinely conflicting hunk, the **local side wins** — always, with no configuration. (This is the one deliberate history rewrite in the app, and it only ever touches unpushed local commits — see 06-history-undo.md's undo-never-rewrites rule.) Crucially, resolution discards nothing: the losing remote version survives intact in the fetched commits below, so an edit that "lost" the rebase is visible in any git client and one revert away. What may *appear* as data loss is always recoverable. No interactive merge UI, no conflict markers written by the app, sync never blocks. A pull that cannot start or fails cleanly follows 06-history-undo.md's app-initiated-operation posture — lock contention shows as a waiting state in the operation's banner row, clean failures as one-shot banner errors, an interrupted rebase is aborted-and-reported via the own-leftovers exemption; push alone keeps the richer queue-and-badge story (below). Conflict markers encountered in files (from git activity *outside* the app) fail fast only where they break parsing — markers in or around the frontmatter make the file the malformed-input case the loader rejects loudly with the offending path. Markers wholly inside a Markdown body are, honestly, valid input: they load fine and render as body text, and the app deliberately doesn't police body content to detect them (stated stance, not an oversight). (The old repo's AI-THINKING-merge-conflicts.md explored this territory; mine it when specifying the rebase mechanics.)
|
- **There can be no conflicts — and no data loss.** Every edit becomes a commit before anything can overwrite it (auto-commit settles local changes; the tree is clean by the time a pull runs). The clean-tree premise is why **abnormal repo states pause the remote half too**: a detached HEAD or in-progress merge/rebase holds Pull, Push, and push-on-commit alongside auto-commit (06-history-undo.md ▸ Rules ▸ Abnormal repo states — the whole git surface pauses; the ahead/behind badge keeps counting, a fetch being a read). A pull — manual, or the automatic fetch-rebase after a rejected push — runs only at **interaction rest**: it queues behind an in-flight drag or open inline editor (the same settled-change notion the auto-commit debounce uses), flushes the pending auto-commit (06-history-undo.md's flush-before-overwrite), then runs bracketed (02-architecture.md's live-reload resilience) — never an error dialog, never a board yanked mid-drag. An open card-body **Edit session neither blocks a pull nor is interrupted by one**: the flush commits the session's on-disk saves as-is (06-history-undo.md's mechanical exception to session-granularity commits) and the rebase runs; when the pulled commits touch the very card being edited, the card window **signposts** the remote change (a transient banner, no modal, no merge UI) while the dirty buffer stays put and wins per 05-card-window.md — the losing remote version is a commit, one revert away. An open **raw-source buffer gets the same treatment** (settled): a pull neither blocks on it nor invalidates it — the bracket's write lock merely disables Apply while the pull runs — and the same-card signpost shows in source mode too (the banner strip is window furniture, not part of the swapped content area). A later Apply is last-writer-wins across the *whole file*, frontmatter included, with the overwritten pulled version a commit one revert away — on the same branch this is exactly the Edit-buffer race. (Branch switch and undo restore must gate raw buffers on save-or-discard instead — 06-history-undo.md ▸ Branch switching — because there a stale Apply would write onto a *different tree's* card, not merely race a newer version of the same one.) A pull fetches the remote's commits and **rebases local commits on top of them**; where a rebase hits a genuinely conflicting hunk, the **local side wins** — always, with no configuration. (This is the one deliberate history rewrite in the app, and it only ever touches unpushed local commits — see 06-history-undo.md's undo-never-rewrites rule.) Crucially, resolution discards nothing: the losing remote version survives intact in the fetched commits below, so an edit that "lost" the rebase is visible in any git client and one revert away. What may *appear* as data loss is always recoverable. No interactive merge UI, no conflict markers written by the app, sync never blocks. A pull that cannot start or fails cleanly follows 06-history-undo.md's app-initiated-operation posture — lock contention shows as a waiting state in the operation's banner row, clean failures as one-shot banner errors, an interrupted rebase is aborted-and-reported via the own-leftovers exemption; push alone keeps the richer queue-and-badge story (below). Conflict markers encountered in files (from git activity *outside* the app) fail fast only where they break parsing — markers in or around the frontmatter make the file the malformed-input case the loader rejects loudly with the offending path. Markers wholly inside a Markdown body are, honestly, valid input: they load fine and render as body text, and the app deliberately doesn't police body content to detect them (stated stance, not an oversight). (The old repo's AI-THINKING-merge-conflicts.md explored this territory; mine it when specifying the rebase mechanics.)
|
||||||
|
|
||||||
## Remote authentication (settled)
|
## Remote authentication (settled)
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ Everything above assumes credentials exist; this is where they come from. Constr
|
|||||||
|
|
||||||
## iCloud Drive — not supported (decided)
|
## iCloud Drive — not supported (decided)
|
||||||
|
|
||||||
Boards should not live in iCloud Drive. The app makes **no iCloud accommodations**: no NSMetadataQuery watching, no eviction handling, no download triggering, no NSFileVersion conflict resolution. When the user opens or creates a board at a path inside iCloud Drive, the app **warns with a thorough explanation and recommends git integration instead** — it does not hard-block (the user is always right), but the warning must genuinely teach why this is a bad idea:
|
Boards should not live in iCloud Drive. The app makes **no iCloud accommodations**: no NSMetadataQuery watching, no eviction handling, no download triggering, no NSFileVersion conflict resolution. When the user opens or creates a board at a path inside iCloud Drive, the app **warns with a thorough explanation and recommends git integration instead** — it does not hard-block (the user is always right), but the warning must genuinely teach why this is a bad idea. (Base Lanework keeps the warning without the recommendation — there is no git to recommend and no Pro pitch in a warning; it recommends a local folder on the eviction/silent-fork grounds alone — 12-editions.md ▸ Edition naming in base.)
|
||||||
|
|
||||||
- **Git and iCloud corrupt each other.** A board with git enabled (the undo substrate) has a `.git` inside; iCloud syncs `.git` internals — thousands of small object files and constantly-rewritten refs/packs — poorly and non-atomically; partial or reordered sync can corrupt the repository. Two Macs auto-committing the same board produce divergent histories iCloud cannot merge.
|
- **Git and iCloud corrupt each other.** A board with git enabled (the undo substrate) has a `.git` inside; iCloud syncs `.git` internals — thousands of small object files and constantly-rewritten refs/packs — poorly and non-atomically; partial or reordered sync can corrupt the repository. Two Macs auto-committing the same board produce divergent histories iCloud cannot merge.
|
||||||
- **Eviction breaks fail-fast loading.** iCloud may evict any file's contents to free space, leaving a placeholder. An evicted `index.md` is unreadable; with no download-trigger machinery the board simply fails to load with an I/O error until the user manually re-downloads it.
|
- **Eviction breaks fail-fast loading.** iCloud may evict any file's contents to free space, leaving a placeholder. An evicted `index.md` is unreadable; with no download-trigger machinery the board simply fails to load with an I/O error until the user manually re-downloads it.
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ Creating a board from a template: copy the tree — **skipping tombstoned items*
|
|||||||
|
|
||||||
## Save as Template
|
## Save as Template
|
||||||
|
|
||||||
A "Save as Template" function copies the current board into the user templates store; the chooser lists user templates after the bundled ones. **The copy is preceded by the close flush** (02-architecture.md ▸ Windows: editor saves, then the pending auto-commit — with the pull-style mechanical exception committing an open Edit session's on-disk saves as-is, sessions staying open), so the template never misses the last keystrokes; the same rule covers File ▸ Duplicate (03-board-ui.md), where the flush also keeps the copied `.git`'s history from lagging its tree. Under the read-only lock, Save as Template disables with one exception — the unwritable-location state, where it stays live (reads the board, writes Application Support; 02-architecture.md ▸ Live-reload resilience has the settled scoping). Because a template *is* a board, the copy is nearly literal: **`.git` is not copied** (a template is content, not history — copying it would embed the board's full repo, every attachment version included, in the template store; see 06-history-undo.md's repo-growth note), tombstoned items are dropped, a `template:` key is added — or, when the board already carries one (e.g. it was itself instantiated from a template), its stale `order` is overwritten — with an order appended after existing user templates; GUIDs are left as-is (instantiation mints fresh ones anyway), and timestamps are kept per 01-storage-format.md's copies-keep-`created` rule (equally inert — instantiation restamps them).
|
A "Save as Template" function copies the current board into the user templates store; the chooser lists user templates after the bundled ones. **The copy is preceded by the close flush** (02-architecture.md ▸ Windows: editor saves, then the pending auto-commit — with the pull-style mechanical exception committing an open Edit session's on-disk saves as-is, sessions staying open), so the template never misses the last keystrokes; the same rule covers File ▸ Duplicate (03-board-ui.md), where the flush also keeps the copied `.git`'s history from lagging its tree. Under the read-only lock, Save as Template disables with one exception — the unwritable-location state, where it stays live unless an open Edit or raw-source session holds unsaved content the suspended saves can't flush (reads the board, writes Application Support; 02-architecture.md ▸ Live-reload resilience has the settled scoping). Because a template *is* a board, the copy is nearly literal: **`.git` is not copied** (a template is content, not history — copying it would embed the board's full repo, every attachment version included, in the template store; see 06-history-undo.md's repo-growth note), tombstoned items are dropped, a `template:` key is added — or, when the board already carries one (e.g. it was itself instantiated from a template), its stale `order` is overwritten — with an order appended after existing user templates; GUIDs are left as-is (instantiation mints fresh ones anyway), and timestamps are kept per 01-storage-format.md's copies-keep-`created` rule (equally inert — instantiation restamps them).
|
||||||
|
|
||||||
Two edges, settled:
|
Two edges, settled:
|
||||||
|
|
||||||
- **Store collisions auto-rename, Finder-style** (`Board.kanban` → `Board 2.kanban`) — the 01 import precedent: saving never overwrites an existing template and never refuses.
|
- **Store collisions auto-rename, Finder-style** (`Board.kanban` → `Board 2.kanban`) — the 01 import precedent: saving never overwrites an existing template and never refuses.
|
||||||
- **Strays copy through.** The copy is literal apart from the stated exclusions (`.git`, tombstoned items) — `CLAUDE.user.md`, a seeded `.gitignore`, and other non-schema files carry through Save as Template *and* instantiation alike. Deliberate: a template is the folder, and `CLAUDE.user.md` carrying a board's custom agent instructions into boards born from it is a feature. The app-owned `CLAUDE.md` copies inertly and self-heals to the current guide version when the new board is opened (08-agent-integration.md).
|
- **Strays copy through.** The copy is literal apart from the stated exclusions (`.git`, tombstoned items) — `CLAUDE.user.md`, a seeded `.gitignore`, and other non-schema files carry through Save as Template *and* instantiation alike. Deliberate: a template is the folder, and `CLAUDE.user.md` carrying a board's custom agent instructions into boards born from it is a feature. The app-owned `CLAUDE.md` copies inertly and self-heals to the current guide version when the new board is opened (08-agent-integration.md).
|
||||||
|
|
||||||
**Storage (settled): Application Support** (`…/Lanework/Templates/`, inside the app container) as the canonical store — friction-free sandbox writes, no location ceremony — kept honest by a **Reveal in Finder** affordance in the template chooser: revealed, it's plain board folders, hand-editable and agent-writable, and a board folder dropped in becomes a template — **no `template:` key required**. Chooser order: bundled templates by `template.order`, then keyed user templates by `template.order`, then keyless user boards last, sorted by display name (`title` ?? folder name — 01-storage-format.md's board naming). The app never stamps a key into store files it didn't write itself — a hand-dropped board is never touched, and adding a key by hand is how its user picks a position; the one writer of keyed files is Save as Template, whose own copies arrive keyed (above). A user-visible or user-configurable location was considered and set aside as ceremony disproportionate to a secondary feature; revisit if template sharing becomes a real workflow.
|
**Storage (settled): Application Support** (`…/Lanework/Templates/`, inside the app container) as the canonical store — friction-free sandbox writes, no location ceremony — kept honest by a **Reveal in Finder** affordance in the template chooser: revealed, it's plain board folders, hand-editable and agent-writable, and a board folder dropped in becomes a template — **no `template:` key required**. Chooser order: bundled templates by `template.order`, then keyed user templates by `template.order`, then keyless user boards last, sorted by display name (`title` ?? folder name — 01-storage-format.md's board naming). An unloadable user template sorts with the keyless tier, by folder name — the failed load can supply neither `template.order` nor `title`, so the folder name is the only identity it has (and the one its unloadable row already shows). The app never stamps a key into store files it didn't write itself — a hand-dropped board is never touched, and adding a key by hand is how its user picks a position; the one writer of keyed files is Save as Template, whose own copies arrive keyed (above). A user-visible or user-configurable location was considered and set aside as ceremony disproportionate to a secondary feature; revisit if template sharing becomes a real workflow.
|
||||||
|
|
||||||
## Rejected alternative
|
## Rejected alternative
|
||||||
|
|
||||||
|
|||||||
@@ -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, 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.
|
- **Reduce Motion is a per-voice rule, not a feature list** (settled): movement animations go **instant**, appear/disappear transitions go **crossfade**, uniformly — every animated surface derives its reduced variant from its voice, the store's reload seam included (the largest animated surface in the app), so new surfaces never need individual rulings. The named cases — reflow-on-drag, search animate-out, the drag replica's lift and settle transitions (its 1:1 tracking never animates, like the selection marquee, which needs no variant — 03-board-ui.md ▸ Motion), the lane-resize rubber-band feedback (03-board-ui.md ▸ Lane), trash animations — are applications of the rule, not the rule itself. **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
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ The single source of truth for **every command and action the app can perform**
|
|||||||
| File | New Board… (opens the template chooser) | ⌥⌘N | Everywhere |
|
| File | New Board… (opens the template chooser) | ⌥⌘N | Everywhere |
|
||||||
| File | Open… | ⌘O | Everywhere; standard open panel (boards = `.kanban` packages and extension-less board folders — 01) |
|
| File | Open… | ⌘O | Everywhere; standard open panel (boards = `.kanban` packages and extension-less board folders — 01) |
|
||||||
| File | Open Recent ▸ (with Clear Menu) | — | Everywhere; reads the board registry (02) |
|
| File | Open Recent ▸ (with Clear Menu) | — | Everywhere; reads the board registry (02) |
|
||||||
| File | Board Info (opens the board popover) | ⌘I | Board window |
|
| File | Board Info (toggles the board popover — opens it closed, closes it open) | ⌘I | Board window |
|
||||||
| File | Duplicate (the board — a Finder-style "copy" sibling, 03 ▸ Welcome; never the selection) | ⇧⌘S | Board window |
|
| File | Duplicate (the board — a Finder-style "copy" sibling, 03 ▸ Welcome; never the selection) | ⇧⌘S | Board window |
|
||||||
| File | Save as Template | — (no default) | Board window; 09-templates.md |
|
| File | Save as Template | — (no default) | Board window; 09-templates.md |
|
||||||
| File | Reveal in Finder | — (no default) | Board window: the selection's folder(s), or the board root with nothing selected; card window: the card's folder — the selected attachment's file instead when the attachments section is focused; welcome: the selected recent's folder (disabled on unavailable rows) — the context-menu entry's required twin |
|
| File | Reveal in Finder | — (no default) | Board window: the selection's folder(s), or the board root with nothing selected; card window: the card's folder — the selected attachment's file instead when the attachments section is focused; welcome: the selected recent's folder (disabled on unavailable rows) — the context-menu entry's required twin |
|
||||||
@@ -32,7 +32,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 shown and non-empty |
|
| File | Empty Trash… (confirmed) | ⇧⌘⌫ | Board window, trash shown and non-empty (whole-trash scope, search-independent — 03 ▸ Trash) |
|
||||||
| File | Close | ⌘W | Any window; flushes per 02 ▸ Windows |
|
| File | Close | ⌘W | Any window; flushes per 02 ▸ Windows |
|
||||||
| Edit | Undo / Redo (M−) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards, during 06's abnormal-state pause (detached HEAD, in-progress merge/rebase), and under the read-only lock (02) |
|
| Edit | Undo / Redo (M−) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards, during 06's abnormal-state pause (detached HEAD, in-progress merge/rebase), and under the read-only lock (02) |
|
||||||
| Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copy-out only (card and lane entries), ⌘X disabled (04 ▸ The trash); text editors: standard text clipboard |
|
| Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copy-out only (card and lane entries), ⌘X disabled (04 ▸ The trash); text editors: standard text clipboard |
|
||||||
@@ -43,8 +43,8 @@ The single source of truth for **every command and action the app can perform**
|
|||||||
| Board | Rename | — (cards: Return in place) | Board window, sole selected card/lane; a lane's only rename path (Return on a lane creates); exists for completeness and remapping |
|
| Board | Rename | — (cards: Return in place) | Board window, sole selected card/lane; a lane's only rename path (Return on a lane creates); exists for completeness and remapping |
|
||||||
| Board | Style… (the style editor; selection-aware) | ⌥⌘S | Board window: selected cards or lane; nothing selected = the board |
|
| Board | Style… (the style editor; selection-aware) | ⌥⌘S | Board window: selected cards or lane; nothing selected = the board |
|
||||||
| Board | Move Up / Move Down | ⌥⌘↑ / ⌥⌘↓ | Card selection within one lane (within-lane sort, logical order; non-contiguous selections gather behind their first card on the first press); disabled when the selection spans lanes; inert on lanes and on tombstoned cards |
|
| Board | Move Up / Move Down | ⌥⌘↑ / ⌥⌘↓ | Card selection within one lane (within-lane sort, logical order; non-contiguous selections gather behind their first card on the first press); disabled when the selection spans lanes; inert on lanes and on tombstoned cards |
|
||||||
| Board | Move Left / Move Right | ⌘← / ⌘→ | Lane selection only (one slot; never into the trash) — cards cross lanes by drag or Cut/Paste, not ⌘-arrows |
|
| Board | Move Left / Move Right | ⌘← / ⌘→ | Lane selection only (one slot; never into the trash) — cards cross lanes by drag or Cut/Paste, not ⌘-arrows; disabled while any text control is focused (04 ▸ Grammar, caret-chords rule) |
|
||||||
| Board | Increase Lane Width / Decrease Lane Width (the stepper's re-divide semantics, never the window's size — 03 ▸ Lane) | ⌥⌘→ / ⌥⌘← | Selected lane |
|
| Board | Increase Lane Width / Decrease Lane Width (the stepper's re-divide semantics, never the window's size — 03 ▸ Lane) | ⌥⌘→ / ⌥⌘← | Selected lane(s) — batches over a multi-lane selection like style (03 ▸ Lane); disabled while any text control is focused (04 ▸ Grammar, caret-chords rule) |
|
||||||
| Board | Pull / Push | — (no default) | Remote-backed boards only (07); disabled during 06's abnormal-state pause (the whole git surface holds) and on an unresolvable remote (07's one-time remote picker case); popover twins exist |
|
| Board | Pull / Push | — (no default) | Remote-backed boards only (07); disabled during 06's abnormal-state pause (the whole git surface holds) and on an unresolvable remote (07's one-time remote picker case); popover twins exist |
|
||||||
| View | Show Trash (checkmark toggle) | — (no default) | Board window — ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled (settled; see Standard macOS furniture), so the chord is the system's; assign one via the remapping mechanism if wanted (04 ▸ Configurable bindings) |
|
| View | Show Trash (checkmark toggle) | — (no default) | Board window — ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled (settled; see Standard macOS furniture), so the chord is the system's; assign one via the remapping mechanism if wanted (04 ▸ Configurable bindings) |
|
||||||
| View | Edit Body (checkmark toggle) | ⌘E | Card window; disabled while Raw Source is active |
|
| View | Edit Body (checkmark toggle) | ⌘E | Card window; disabled while Raw Source is active |
|
||||||
@@ -95,7 +95,7 @@ Context menus are the per-item action inventory VoiceOver reads (10 ▸ The boar
|
|||||||
|
|
||||||
## Configuration controls (C)
|
## Configuration controls (C)
|
||||||
|
|
||||||
- **Board popover** (Board Info ⌘I — 03 ▸ Board popover): board rename; embedded style editor; add-git (mode none) / repo-nested explanation (06); branch display, switch, create; commit-identity name/email (06); add/change remote, credential fields, machine SSH key Copy + Verify, Authentication-needed state (07); ahead/behind with Pull/Push buttons and the push-on-commit toggle.
|
- **Board popover** (Board Info ⌘I — 03 ▸ Board popover): board rename; embedded style editor; add-git (mode none) / repo-nested explanation (06); branch display, switch, create; commit-identity name/email (06); add/change remote, credential fields, the SSH key surface — machine key Copy + Verify, key import by paste or drag, the per-host key picker, removal of an unreferenced import, confirm-gated machine-key regeneration — Authentication-needed state (07); ahead/behind with Pull/Push buttons and the push-on-commit toggle.
|
||||||
- **Style editor** (three anchors — 03 ▸ Styling ▸ Controls): grids arrow-navigable, every well Tab-reachable.
|
- **Style editor** (three anchors — 03 ▸ Styling ▸ Controls): grids arrow-navigable, every well Tab-reachable.
|
||||||
- **Template chooser** (09): template selection; Reveal in Finder for the user store.
|
- **Template chooser** (09): template selection; Reveal in Finder for the user store.
|
||||||
- **Welcome** (03): recents list; Forget.
|
- **Welcome** (03): recents list; Forget.
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Editions
|
||||||
|
|
||||||
|
Lanework ships as three editions built from one codebase and one on-disk format. This doc owns the edition axis: what each edition is, how the split is engineered (targets, the provider seam), and which features land where. Individual docs stay edition-agnostic where they can — they conditionalize on **board mode** (none / git / git+remote — 07-sync-collab.md), and this doc defines which modes each edition ships.
|
||||||
|
|
||||||
|
## The three editions
|
||||||
|
|
||||||
|
- **Lanework** (base) — no git integration. Boards are plain folders (mode `none` everywhere); undo/redo is macOS-native (13-native-undo.md). The full board experience: lanes, cards, styling, trash, attachments, card window, templates, agents, accessibility.
|
||||||
|
- **Lanework Pro** — git integration as designed in 06-history-undo.md and 07-sync-collab.md: opt-in init, adoption, git-backed undo/history, branches, remotes, pull/push, auth. Plus Pro-only differentiators (matrix below).
|
||||||
|
- **Lanework Teams** — Pro plus tracker integration over the reserved enhanced schema (`remote`/`remote-state`, `comments/` — 01-storage-format.md). **Deferred** — months out, no design pass yet; this doc only keeps the seam honest.
|
||||||
|
|
||||||
|
Why a real split and not feature flags: the editions differ at the binary level — base ships without libgit2 and without the network-client entitlement; Pro links git machinery and talks to remotes. And the strategic reason (settled): Teams' card sync must be **backend-agnostic** — it has to work over git and over a range of trackers — so history and sync must sit behind a genuine provider seam. Base's native undo is the first proof the seam is real: two working history providers before a third arrives.
|
||||||
|
|
||||||
|
## Distribution (settled)
|
||||||
|
|
||||||
|
Separate Mac App Store apps — real Xcode targets, not an IAP unlock. Both editions declare the same `.kanban` package UTI (`dev.rzen.indie.kanban-board`) verbatim, so any board opens in either app; the format is one format, owned by 01-storage-format.md, with no edition-specific keys. Upgrade path is buying the other app; boards need no migration whatsoever (files are the interchange).
|
||||||
|
|
||||||
|
## The provider seam
|
||||||
|
|
||||||
|
History (and later sync) is a provider behind one protocol boundary, chosen per edition at the composition root:
|
||||||
|
|
||||||
|
- **HistoryProviding** — the undo/redo substrate. Base binds the native undo stack (13-native-undo.md: NSUndoManager over inverse `WriteOperation`s). Pro binds the git provider (06-history-undo.md: undo as forward restore commits over HEAD's first-parent ancestry). Teams inherits Pro's.
|
||||||
|
- **Sync/tracker providers** — deferred with Teams; the reserved schema keys and the one-way file flow (02-architecture.md) are the format-level seam already in place.
|
||||||
|
|
||||||
|
What is shared across providers (settled): **06's Undo routing is edition-independent** — focus decides text-undo vs board-undo; only the substrate behind board-undo differs. The command surface is identical (⌘Z/⇧⌘Z, dynamically retitled menu items — both providers use NSUndoManager's title rewriting); menu titles draw on the same semantic vocabulary (06 ▸ Commit messages). A user moving between editions relearns nothing.
|
||||||
|
|
||||||
|
## Base and `.git` — the inert posture (settled)
|
||||||
|
|
||||||
|
Base Lanework generalizes the repo-nested stance to every `.git` it meets: **any `.git` is inert**. Opening a board that has one (a Pro user's board, a repo-nested board) works normally — files read and write as on any board, native undo runs, the trash works — but the app never reads history, never commits, never touches `.git` in any way. To base, `.git` at the board root is a stray like any other, preserved verbatim. Pro's external-writer machinery (06 ▸ Interaction with external writers) already reconciles the uncommitted drift a base session leaves behind — a base edit is just a foreign change to Pro. The watcher's `.git` event filtering stays in base (it exists to ignore git churn, which mixed households will produce).
|
||||||
|
|
||||||
|
Base's popover git slot (03-board-ui.md ▸ Board popover) does not offer add-git. **Its posture is contextual** (settled — ruled 2026-07-27): on ordinary boards the section is simply absent — the popover is rename + style, complete in itself. Only when the board carries an inert `.git` does a calm info line appear: "This board has a git history. Lanework Pro works with it." — an honest explanation of what the folder is, surfacing exactly where the question arises, never a standing ad. The card window's absent History section follows the same pattern: absent, no placeholder.
|
||||||
|
|
||||||
|
## Edition matrix
|
||||||
|
|
||||||
|
The feature sort. Everything not listed rides with "board experience" and is identical everywhere.
|
||||||
|
|
||||||
|
| Feature | Lanework | Pro | Teams |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Board experience: lanes, cards, drag & drop, keyboard map, clipboard, search, styling, trash, attachments, card window, templates, welcome screen | ✓ | ✓ | ✓ |
|
||||||
|
| Agent integration: agent guide, `modified-by` attribution, tolerance rules | ✓ | ✓ | ✓ |
|
||||||
|
| Accessibility (10-accessibility.md, all of it) | ✓ | ✓ | ✓ |
|
||||||
|
| Comments (post-2.0 design pass; per the standing decision, **all editions**) | ✓ (future) | ✓ (future) | ✓ (future) |
|
||||||
|
| Undo/redo | native (13) | git (06) | git (06) |
|
||||||
|
| Undo of foreign/agent edits | — (honest gap, 13) | ✓ (stack absorbs foreign commits) | ✓ |
|
||||||
|
| Overwrite protection (flush-before-overwrite, both-versions-as-commits) | — (07's accepted caveat is permanent here; trash + native undo are the safety story) | ✓ | ✓ |
|
||||||
|
| History surfaces: card History sidebar (05), View ▸ History (11) | — | ✓ | ✓ |
|
||||||
|
| Git: init/adoption, branches, commit identity, repository hygiene | — | ✓ | ✓ |
|
||||||
|
| Remotes: pull/push, push-on-commit, auth (Keychain, SSH, TOFU) | — | ✓ | ✓ |
|
||||||
|
| "While you were away" digest (WISHLIST item 1, requires git) | — | ✓ (future) | ✓ (future) |
|
||||||
|
| Tracker integration (`remote`/`remote-state` sync, tracker-backed boards) | — | — | ✓ (future) |
|
||||||
|
|
||||||
|
Docs 06 and 07 are **Pro-edition docs**; every other doc applies to all editions, with mode-conditioned passages (undo availability, popover git surface, Delete Immediately's confirmation branch) resolving per the modes the edition ships. Base ships exactly one mode: `none` (with the inert-`.git` posture above); Pro ships the full state machine.
|
||||||
|
|
||||||
|
## Targets (settled shape; details at implementation)
|
||||||
|
|
||||||
|
Three app targets sharing sources: `Lanework` (base — excludes Pro-only source dirs, no libgit2, current minimal entitlements), `LaneworkPro` (adds git sources, libgit2 dependency, network-client entitlement, Keychain access), Teams deferred. **Bundle ids** (settled — ruled 2026-07-27): base keeps `dev.rzen.indie.Kanban` — it is the current app and ships first, so nothing re-wires; Pro mints `dev.rzen.indie.KanbanPro` with matching test-target ids; Teams reserves nothing yet. Shared UTI declaration in both Info.plists (base remains the exporter); distinct icon assets; parallel test targets and schemes. Milestones: the current plan's m7-git-undo and m8-remote-sync become Pro milestones, built as the git HistoryProvider behind the seam after base ships; a new native-undo milestone takes their place for base.
|
||||||
|
|
||||||
|
## Edition naming in base (settled — ruled 2026-07-27)
|
||||||
|
|
||||||
|
**Quiet signposts.** Base presents as a complete app, not a demo: Pro is named in exactly two places — one line in the About box, and the contextual popover line on `.git` boards (above). Nothing on the welcome screen, nothing in banners. The iCloud/network-volume warning (07-sync-collab.md) is rewritten for base without the git recommendation — it warns on its own merits (eviction, silent forks) and recommends a local folder; there is no git to recommend and no Pro pitch in a warning (a warning that sells reads as manufactured).
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None currently — bundle ids, the popover slot posture, and edition naming were ruled 2026-07-27 and are settled above.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Native Undo (base edition)
|
||||||
|
|
||||||
|
The undo/redo substrate for base Lanework (12-editions.md), filling the one gap mode:none admits (06-history-undo.md, 07-sync-collab.md): boards without git had no undo. Pro's substrate remains git (06); this doc never applies there. The design problem is not NSUndoManager itself — it is native undo over **files-are-truth**: the disk can change underneath the stack, because the app is not the only writer.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **One stack per board, owned by the board session.** Not per-window: every window over a board (board window, its card windows) shares the store and shares the stack. `window.undoManager` for board surfaces returns the session's manager; 06 ▸ Undo routing applies unchanged — text-editing surfaces get their session-scoped text undo, everywhere else ⌘Z/⇧⌘Z hit the board stack. Undo is board-local, exactly as git undo was.
|
||||||
|
- **Registration at the Writer boundary.** Every app-mediated mutation already passes through the Writer as a `WriteOperation` (02-architecture.md) — that closed enum is the exact inventory of undoable operations. Each Writer call site registers the inverse operation, computed from the pre-write snapshot the store already holds: move → move back (original lane, original `order`); reorder → restore original `order`; rename → restore title; restyle → restore prior style; resize → restore prior width; Edit-session body save → restore prior body bytes; tombstone (⌫) → restore; restore (Put Back) → tombstone; create → remove the created folder.
|
||||||
|
- **What is not undoable** (settled): **Permanently delete** (Delete Immediately, Empty Trash) — `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule (03-board-ui.md) already fires on all base boards, since none have git history: the confirm *is* the safety. **Repair** (01-storage-format.md's duplicate-id remint) — undoing a remint would recreate the duplicate id the operation exists to remove. Both match their existing "destructive, confirmed, final" posture.
|
||||||
|
- **Coalescing follows commit granularity** (settled): one gesture, one undo step — a multi-card move is one step with a plural title; an Edit session is one step, registered at the Edit→Preview flip (the effective Save — 05-card-window.md); a styling batch is one step (03's one-gesture-one-commit rule, substrate swapped). The 06 vocabulary supplies menu titles ("Undo Move 3 Cards"), via NSUndoManager's dynamic retitling — the same naming machinery both editions use.
|
||||||
|
- **Session-only persistence** (settled): the stack lives with the board session and dies at close/quit — standard macOS behavior. Git undo's survive-relaunch property is a Pro difference, stated honestly (12's matrix).
|
||||||
|
- **Foreign writes never join the stack** (settled): NSUndoManager can only undo what the app mediated. An agent's or hand edit is not a step — the honest capability gap vs Pro (12's matrix). Foreign changes also do not clear the stack wholesale; collisions are handled lazily, per step, by validation:
|
||||||
|
- **Staleness validation before every apply** (settled): an inverse operation re-checks its target against the current snapshot at ⌘Z time. **The predicate is field-level** (settled — ruled 2026-07-27): each step registers both sides of its write anyway (the before-value is the inverse; the after-value is what its write set), so validation compares the targeted field's current value against the expected after-value — nearly free, and truer to never-surprise-the-file than an existence-only check (an inverse rename must not clobber a foreign rename on a still-existing card; body steps compare bytes). Target folder gone, or the field no longer holding the step's after-value → the step is **skipped, not applied**: popped from the stack with an info-tone banner ("Undo skipped — 'Fix login' changed outside Lanework"), and ⌘Z falls through to the next step. Never apply a stale inverse on top of someone else's newer write. **Invalidation is lazy** (settled — ruled 2026-07-27): staleness is discovered at ⌘Z time, never by background pruning — the EchoLedger's foreign diffs do not eagerly drop colliding steps. The stack always looks full; with the field-level predicate a skip fires only on a genuine per-field collision, and a skipped step's banner explains itself, where eager pruning would shrink the stack invisibly mid-session.
|
||||||
|
- **Locks disable the stack** (settled): every read-only lock (vanished root, failed reload after wholesale ops, unwritable location — 02-architecture.md) disables Undo/Redo with the other mutating commands; the stack itself survives the lock and resumes when it clears. Steps landed before a lock validate like any other at apply time.
|
||||||
|
|
||||||
|
## Interaction with the trash
|
||||||
|
|
||||||
|
⌫'s undo is restore and Put Back's undo is tombstone — the stack and the trash are two doors to the same tombstone state, never in conflict: undo of a delete is *identical* in effect to Put Back (position-preserving, 01's deletion bullet), just addressed by recency instead of by selection.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Attachment operations, v1** (deferred — ratified 2026-07-27): attach → remove is a clean inverse, but remove-attachment → re-add requires the removed file to survive somewhere (a staging area with a lifecycle — App Support, bounded, its own cleanup rules; possibly shared with 04 ▸ Clipboard's staging). The deferral is the ruling: attachment add/remove registers **no undo step** in v1 (the operations remain, as today, confirmed-or-benign); the staging design pass reopens post-2.0.
|
||||||
|
- **EchoLedger-synthesized foreign undo** (deferred, wishlist — WISHLIST.md item 6): the ledger already classifies foreign diffs for announcements; it could synthesize inverse operations and push foreign steps onto the stack, narrowing the gap to Pro. Real design needed (ordering vs app steps, attribution, user expectations) — not assumed by this doc.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None currently — the staleness predicate (field-level) and invalidation timing (lazy) were ruled 2026-07-27 and are settled in Rules above.
|
||||||
+5
-3
@@ -18,16 +18,18 @@ Each document covers one aspect of the design. Within each:
|
|||||||
| [03-board-ui.md](03-board-ui.md) | Board window: layout, lanes, cards, styling, templates |
|
| [03-board-ui.md](03-board-ui.md) | Board window: layout, lanes, cards, styling, templates |
|
||||||
| [04-interactions.md](04-interactions.md) | Selection, drag & drop, keyboard, clipboard, search |
|
| [04-interactions.md](04-interactions.md) | Selection, drag & drop, keyboard, clipboard, search |
|
||||||
| [05-card-window.md](05-card-window.md) | The card window: Markdown preview/edit, attachments |
|
| [05-card-window.md](05-card-window.md) | The card window: Markdown preview/edit, attachments |
|
||||||
| [06-history-undo.md](06-history-undo.md) | Git-backed undo/redo and history |
|
| [06-history-undo.md](06-history-undo.md) | Git-backed undo/redo and history — **Pro edition** |
|
||||||
| [07-sync-collab.md](07-sync-collab.md) | Board modes: local-only, git; iCloud Drive warned against |
|
| [07-sync-collab.md](07-sync-collab.md) | Board modes: local-only, git; iCloud Drive warned against — **Pro edition** |
|
||||||
| [08-agent-integration.md](08-agent-integration.md) | AI agents as first-class users of the board |
|
| [08-agent-integration.md](08-agent-integration.md) | AI agents as first-class users of the board |
|
||||||
| [09-templates.md](09-templates.md) | Board templates: inventory and definition format |
|
| [09-templates.md](09-templates.md) | Board templates: inventory and definition format |
|
||||||
| [10-accessibility.md](10-accessibility.md) | VoiceOver, text scaling, visual accommodations |
|
| [10-accessibility.md](10-accessibility.md) | VoiceOver, text scaling, visual accommodations |
|
||||||
| [11-command-nexus.md](11-command-nexus.md) | The command Nexus — every command and action: bindings, contexts, customizability |
|
| [11-command-nexus.md](11-command-nexus.md) | The command Nexus — every command and action: bindings, contexts, customizability |
|
||||||
|
| [12-editions.md](12-editions.md) | The three editions (Lanework / Pro / Teams): distribution, provider seam, feature matrix |
|
||||||
|
| [13-native-undo.md](13-native-undo.md) | macOS-native undo/redo — the base edition's history substrate |
|
||||||
|
|
||||||
## Deferred design iterations
|
## Deferred design iterations
|
||||||
|
|
||||||
None remaining — the card window (05-card-window.md), toolbar (03-board-ui.md ▸ Toolbar), and styling controls (03-board-ui.md ▸ Styling ▸ Controls) each had their focused pass and are settled.
|
The card window (05-card-window.md), toolbar (03-board-ui.md ▸ Toolbar), and styling controls (03-board-ui.md ▸ Styling ▸ Controls) each had their focused pass and are settled. The 2026-07-27 edition split (12-editions.md) added two deferred passes: the attachment-undo staging design and the EchoLedger foreign-undo bridge (both flagged in 13-native-undo.md), plus Teams' tracker integration (no design yet).
|
||||||
|
|
||||||
## Wishlist
|
## Wishlist
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user