From d32f03f8c9a849dc41c2e71c983efc67425d191a Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 19:06:08 -0400 Subject: [PATCH] Resolve eleven Redesign-board findings across the design corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contradictions: v1 gains a minimal Settings pane (⌘,) housing the restore-at-launch toggle; Window ▸ Welcome to Lanework inventoried in 11. Gaps: close/quit waits for in-flight bracketed and copy-shaped operations; pull extends the Edit-session treatment to open raw-source buffers (signpost + last-writer-wins Apply); File ▸ Duplicate keeps tombstones (01's "as-is" softened to per-flow exclusions). Minor: Undo routing promoted to a real heading in 06; 11's dead "10 ▸ Actions" citation repointed. m3 watcher findings: root-gone cancels armed debounced delivery; mid-bracket root changes owned by the root-change path; .git filtered at any depth; banked mid-bracket reconciles and the 200 ms debounce ratified. --- DESIGN/01-storage-format.md | 2 +- DESIGN/02-architecture.md | 11 ++++++----- DESIGN/03-board-ui.md | 2 +- DESIGN/05-card-window.md | 4 ++-- DESIGN/06-history-undo.md | 5 ++++- DESIGN/07-sync-collab.md | 2 +- DESIGN/11-command-nexus.md | 6 ++++-- 7 files changed, 19 insertions(+), 13 deletions(-) diff --git a/DESIGN/01-storage-format.md b/DESIGN/01-storage-format.md index fe659fd..1a0623c 100644 --- a/DESIGN/01-storage-format.md +++ b/DESIGN/01-storage-format.md @@ -27,7 +27,7 @@ MyBoard.kanban/ ← board = the document - **Level is position.** Root = board, depth 1 = lane, depth 2 = card. No `type` field. - **Folder names are lowercase UUIDv4, immutable, never renamed.** The folder name is the primary key; titles live in frontmatter only. Collisions are impossible by construction. -- **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 as-is, **GUIDs kept** — 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 sibling duplicates — the app doesn't currently detect that, and behavior with duplicate sibling-level UUIDs in one board is unspecified. - `index.md` = YAML frontmatter between `---` delimiters, then Markdown body. - **Reserved child names** at card level: `attachments/` (specified below) and `comments/` (enhanced schema — see below). Never levels themselves, never identity (identity-bearing siblings are UUIDs). - **Name shape gates level detection** (settled): only folders named as lowercase UUIDv4 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. diff --git a/DESIGN/02-architecture.md b/DESIGN/02-architecture.md index 6e0b339..81c07d1 100644 --- a/DESIGN/02-architecture.md +++ b/DESIGN/02-architecture.md @@ -31,7 +31,7 @@ The **one named exception** is transient UI state rendering things that don't ex - **BoardWriter** — every mutation (create, move, reorder, tombstone, style) as an explicit filesystem operation. No hidden state; a write is done when the file is on disk. - **BoardStore** — per-board `@Observable` object holding the current snapshot plus transient UI state that must be shared across that board's windows (selection, drag state, search query, pending cut, the new-card placeholder, trash visibility). Debounces watcher reloads. - **BoardStoreRegistry** — refcounted registry so a board window and its card windows share one live store and one watcher. **The board window owns the board** (settled): card windows never outlive it — closing the board window closes its card windows too, so the last-window teardown and board-window close coincide. (The refcount still earns its keep ordering teardown while multiple windows close.) -- **FolderWatcher** — FSEvents (debounced), attached best-effort to whatever path the board lives at. There is only this one watching path: no NSMetadataQuery for iCloud Drive, no polling fallback for network volumes — on those warned-against locations (07-sync-collab.md) FSEvents delivery is unreliable and live reload silently degrades, accepted per 07's no-accommodations stance. +- **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. - **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). @@ -40,7 +40,7 @@ The **one named exception** is transient UI state rendering things that don't ex ### 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. -- **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. +- **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal, so quiet reconciliations cost nothing visible), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry). - **App-initiated git churn is bracketed.** Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. **The bracket also locks writes** (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted. - **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A liveness flip is a vanish for this purpose**: re-resolution matches UUID *and* liveness side, so a foreign edit that tombstones a selected live card — or restores a selected tombstoned one — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's homogeneous-by-liveness invariant true across reloads. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map). - **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and Put Back, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles — and the board refuses drops, including drags arriving from another board's window. Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it). @@ -51,8 +51,8 @@ The read-side rules above have a write-side mirror — one banner vocabulary for - **The one-way flow makes write failures honest by construction.** Views render only what is on disk, so a failed Writer operation (disk full, permissions, volume error) never shows phantom state — the action visibly doesn't happen. The failure surfaces in the same non-modal banner as read-side breakage, naming the operation and the cause ("Couldn't move 'Fix login' — disk full"). **The operation is a closed enum, not a string** (settled): Writer errors identify the failed operation as an enum case (create, move, reorder, tombstone, restore, style, …) carrying the affected item's title where known; the banner owns all user-facing phrasing and localization from that vocabulary, and a new Writer operation without a banner rendering is a compile-time hole, not a silent default. Free-form English survives only inside the diagnostic `reason`, never as the banner's verb. One-shot actions (move, tombstone, style, create) fail once and wait for the user to act again; nothing is queued behind their back. - **The debounced body save retries on its own cadence** — keystrokes stay in the dirty buffer, so nothing is lost while the window stays open; the banner stands until a save lands. The **one modal moment on the write-failure path**: closing a window (or the board, or quitting) with a dirty buffer that cannot be written — the only state that exists nowhere but memory — raises an alert (retry / save a copy elsewhere / discard) instead of failing silently. Everything else on this path stays non-modal. (Deliberate confirmations elsewhere are their own stories: Empty Trash… and Delete Immediately on boards without git history — 03-board-ui.md, the branch-switch save-or-discard step — 06-history-undo.md, machine-key regeneration — 07-sync-collab.md, the raw-source Apply validation alert — 05-card-window.md, the once-per-board iCloud/network-volume warning on open/create — 07, and the SSH trust-on-first-use fingerprint confirmation with its mismatch hard-block — 07.) -- **A renamed or moved board root follows its file identity** (settled): the board the app has open is the *file*, not the path string — the registry's security-scoped bookmark is the identity, mid-session as much as across opens (01-storage-format.md calls Finder renames ordinary, and mid-session must honor that). On any root-gone signal — the watcher's path stops delivering, a write lands on a stale path — the app first **re-resolves the bookmark**: if it resolves to a new location, the rename/move is absorbed transparently — the watcher re-attaches there, Writer URLs and card-window keys re-derive from the new root, one full reload runs, and the window title follows the folder-name fallback where it applies — no banner, no lock, nothing was ever wrong. Only when the bookmark does not resolve is the root truly vanished (below). -- **A vanished board root locks the board read-only** — the bracketed-reload vocabulary applied to a root that is gone (volume unmounted, folder Finder-deleted while open — and the rename re-resolution above found nothing): every write would land nowhere, so the last-good snapshot stays on screen, read-only, banner up. The watcher keeps watching; if the root returns (remount, Finder undo), the next successful reload clears the lock and pending dirty buffers save normally. +- **A renamed or moved board root follows its file identity** (settled): the board the app has open is the *file*, not the path string — the registry's security-scoped bookmark is the identity, mid-session as much as across opens (01-storage-format.md calls Finder renames ordinary, and mid-session must honor that). On any root-gone signal — the watcher's path stops delivering, a write lands on a stale path — the app first **re-resolves the bookmark**: if it resolves to a new location, the rename/move is absorbed transparently — the watcher re-attaches there, Writer URLs and card-window keys re-derive from the new root, one full reload runs, and the window title follows the folder-name fallback where it applies — no banner, no lock, nothing was ever wrong. Only when the bookmark does not resolve is the root truly vanished (below). Either way, a root-gone signal **cancels any armed debounced tree-event delivery** (settled): both outcomes end in a full reload — at the re-resolved root, or on the root's return from the vanished-root lock — so delivering a stale tree event for a path that just stopped being the root would only be noise. +- **A vanished board root locks the board read-only** — the bracketed-reload vocabulary applied to a root that is gone (volume unmounted, folder Finder-deleted while open — and the rename re-resolution above found nothing): every write would land nowhere, so the last-good snapshot stays on screen, read-only, banner up. The watcher keeps watching; if the root returns (remount, Finder undo), the next successful reload clears the lock and pending dirty buffers save normally. **A root change landing mid-bracket is owned by the root-change path, not the bracket** (settled): re-resolution runs immediately even inside a bracket — a rename is absorbed transparently and the bracket's final reload simply runs at the re-resolved root; a true vanish raises this lock at once and the bracket's eventual final reload becomes a no-op rather than a redundant failure. Nothing is lost by skipping it: the root's return runs the reconciling reload, and an operation the vanish killed mid-flight is 06-history-undo.md's own-leftovers case, recognized at the next open or flush. (The composition matters for a pull-rebase mid-flight when a volume unmounts — 07-sync-collab.md.) - **An unwritable board location enters the read-only lock at open** (settled): opening probes the root's writability — a read-only volume (DMG, snapshot, read-only share) or a permission-denied folder opens straight into the read-only lock, banner naming the cause ("this board's volume is read-only"), rather than letting every gesture fail one at a time — fail loudly, specifically, *once*. The open-time agent-guide write (08-agent-integration.md) is skipped-with-log, the `CLAUDE.user.md`-taken precedent. Writability re-probes on every reconciling reload (wake, activation — above), so a fixed permission or rewritable remount clears the lock without ceremony. The lock's read affordances stay live as always — inspecting an archived board on a DMG is a legitimate errand, and viewing-first is the point. - **Auto-commit failures beyond `index.lock` contention** (06-history-undo.md covers the lock) — disk full mid-commit, repo corruption: the files are safely on disk but history stops advancing, which quietly suspends the undo trail and the flush-before-overwrite guarantee. That degradation is surfaced, not hidden: the banner states that changes aren't being recorded to history; the committer retries on the next debounce and the banner clears on the first successful commit. - **Attachment import copy failures** (source unreadable, destination full): the drop was accepted — "never refuses the drop" (01-storage-format.md ▸ Attachments) is policy, not an I/O guarantee — so a failed copy surfaces in the banner with the filename, and any partial file is removed; no half-copied attachment is ever left in `attachments/`. @@ -75,12 +75,13 @@ The non-modal banner named throughout the read- and write-side rules above is on - **Card windows** — `WindowGroup(for: CardWindowRef.self)`; at most one per card (reopen focuses); follows its card across lanes; dismisses itself if the card is deleted. ### Launch and window lifecycle (settled) -- **Restoration is a preference** — "Restore open boards at launch", default on. On: boards open at last quit reopen (bookmark-resolved), with their per-board frames and 05's card-window restoration. Off: every launch starts at welcome. +- **Restoration is a preference** — "Restore open boards at launch" in Settings (⌘, — 11-command-nexus.md), default on. On: boards open at last quit reopen (bookmark-resolved), with their per-board frames and 05's card-window restoration. Off: every launch starts at 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. - **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. - **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. ## Per-board app state diff --git a/DESIGN/03-board-ui.md b/DESIGN/03-board-ui.md index f8398fb..557497f 100644 --- a/DESIGN/03-board-ui.md +++ b/DESIGN/03-board-ui.md @@ -77,7 +77,7 @@ The welcome window carries over from the pathfinder unchanged — confirmed, it - Welcome: resizable, no title bar (background drag); recents list with board icon, name, location, counts; single click selects, double click opens; context menu Open / Reveal in Finder / Forget. - **Templates**: New Board (⌥⌘N — ⌘N is new *card*; 11-command-nexus.md) opens a Pages-style chooser with a mini per-lane preview per template. Inventory and definition format: 09-templates.md. -- File menu: Open Recent (with Clear Menu; available everywhere), and Duplicate (⇧⌘S) — **board window only** (11-command-nexus.md), duplicating the frontmost open board to a Finder-style "copy" sibling; it never acts on a welcome-selected recent. 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. 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. 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. 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. 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 diff --git a/DESIGN/05-card-window.md b/DESIGN/05-card-window.md index f40333e..9482f65 100644 --- a/DESIGN/05-card-window.md +++ b/DESIGN/05-card-window.md @@ -36,7 +36,7 @@ Settled the hard way in the pathfinder (WYSIWYG built, then reversed): the body - A monospaced editor with **lightweight Markdown syntax highlighting** — headings emphasized, bold/italic rendered as such, code tinted, link targets and structural markers dimmed. Highlighting is presentation only: the text is the raw Markdown, character for character — no hidden transforms, no smart substitutions. - Saved on a ~700 ms debounce; flushed on leaving Edit, entering source mode, and window close. -- **⌘Z here is the text view's own undo** — session-scoped, ending when the editor loses focus or the mode flips; it works on every board, git or not. Board-level undo routing and commit granularity (one commit per Edit session — the Edit→Preview flip is the effective Save button; never per save tick): 06-history-undo.md ▸ Undo routing. +- **⌘Z here is the text view's own undo** — session-scoped, ending when the editor loses focus or the mode flips; it works on every board, git or not. Board-level undo routing: 06-history-undo.md ▸ Undo routing; commit granularity (one commit per Edit session — the Edit→Preview flip is the effective Save button; never per save tick): 06 ▸ Rules ▸ Auto-commit. ### Write rules (settled, storage-facing) @@ -45,7 +45,7 @@ Settled the hard way in the pathfinder (WYSIWYG built, then reversed): the body ## Raw source outlet -A toggle (View ▸ Raw Source, ⌥⌘E — 11-command-nexus.md) swaps the **entire content area — title, body, and sidebar —** for the literal on-disk `index.md` (frontmatter and all) in a monospaced editor with Cancel/Apply: the same frontmatter is being edited as raw text, so interactive controls over it would fight the raw edit. Entering source mode flushes any pending title/body edits first, then reads the file fresh from disk. Apply validates through the same fail-fast parse the loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte (including a `modified-by` stamp the user typed or kept — Apply is the one app write that doesn't clear it, 01-storage-format.md); the watcher reload then refreshes every window. Cancel (and window close) discards without ceremony. This is the escape hatch that keeps *everything* — unknown keys, exotic formatting — reachable in-app. +A toggle (View ▸ Raw Source, ⌥⌘E — 11-command-nexus.md) swaps the **entire content area — title, body, and sidebar —** for the literal on-disk `index.md` (frontmatter and all) in a monospaced editor with Cancel/Apply: the same frontmatter is being edited as raw text, so interactive controls over it would fight the raw edit. Entering source mode flushes any pending title/body edits first, then reads the file fresh from disk. Apply validates through the same fail-fast parse the loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte (including a `modified-by` stamp the user typed or kept — Apply is the one app write that doesn't clear it, 01-storage-format.md); the watcher reload then refreshes every window. Cancel (and window close) discards without ceremony. This is the escape hatch that keeps *everything* — unknown keys, exotic formatting — reachable in-app. A pull landing mid-session neither blocks on the open buffer nor invalidates it (07-sync-collab.md — same-card signpost, Apply stays last-writer-wins); branch switch and undo restore instead settle it explicitly via save-or-discard (06-history-undo.md ▸ Branch switching). Key grammar in source mode, completing the window's key story: **Escape is Cancel**, **⌘↩ is Apply**, and toggling off via ⌥⌘E (menu or toolbar) is **Apply too** — leaving-by-toggle commits, mirroring leaving-Edit-flushes; a failed validation keeps source mode open (toggle stays checked) with the alert. Return just types — it's an editor. View ▸ Edit Body (⌘E) disables while source mode is active, matching its toolbar item. diff --git a/DESIGN/06-history-undo.md b/DESIGN/06-history-undo.md index 6740910..23a5a25 100644 --- a/DESIGN/06-history-undo.md +++ b/DESIGN/06-history-undo.md @@ -10,7 +10,6 @@ Git is the undo substrate — on boards that have git. **Git is opt-in per board - **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**: auto-commit holds (the auth-pause posture, 07-sync-collab.md — pause, badge, explain, never hammer), Undo/Redo and the branch controls disable, 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. - **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. -- **Undo routing is by focus** — the platform's first-responder rule, stated here because two undo systems coexist. 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. - **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). - **Cadence constraint** (agreed): the auto-commit cadence must not make the history of a remote-shared board unbearable — one commit per drag is fine for a local undo trail but noisy as a shared log. The debounce/batching design here must serve both consumers; the push/pull side is settled in 07-sync-collab.md (optional push-on-commit, automatic fetch-rebase-push on rejected pushes). - **Undo never rewrites history.** Undo (⌘Z) and redo (⇧⌘Z) restore earlier states as **new forward commits** — never reset, never force. The whole trail stays inspectable in any git client. The one deliberate rewrite anywhere in the app is pull's rebase of **unpushed local** commits (07-sync-collab.md); published history is never touched. @@ -20,6 +19,10 @@ Git is the undo substrate — on boards that have git. **Git is opt-in per board - **Undo is board-local.** A cross-board move-out undone at the source resurrects the card even though it lives on in the destination — per-board histories cannot and must not mutate other boards. The resulting same-UUID fork across boards is legitimate (boards are independent identity namespaces); if the two ever meet through a move-in, the import boundary remints the arrival (01-storage-format.md's identity lifecycle). - The git surface lives in the **board popover** (03-board-ui.md): branch/source display, branch switching and creation, and the commit-identity name/email fields (see Interaction with external writers below) — alongside board rename and styling. +## 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. + ## 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: diff --git a/DESIGN/07-sync-collab.md b/DESIGN/07-sync-collab.md index 40dedaf..5b731f6 100644 --- a/DESIGN/07-sync-collab.md +++ b/DESIGN/07-sync-collab.md @@ -23,7 +23,7 @@ The board is a git repository (the 06-history-undo.md substrate — undo/redo, a - **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. - **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). 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. 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). 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) diff --git a/DESIGN/11-command-nexus.md b/DESIGN/11-command-nexus.md index 7e117cf..003cad9 100644 --- a/DESIGN/11-command-nexus.md +++ b/DESIGN/11-command-nexus.md @@ -18,6 +18,7 @@ The single source of truth for **every command and action the app can perform** | Menu | Command | Default | Context | |---|---|---|---| +| App | Settings… | ⌘, | Everywhere; the app-wide preferences pane. v1 holds one control: "Restore open boards at launch" (02 ▸ Launch and window lifecycle) | | File | New Card | ⌘N | Board window; disabled on a zero-lane board. Target rule: 04 | | File | New Lane | ⇧⌘N | Board window | | File | New Board… (opens the template chooser) | ⌥⌘N | Everywhere | @@ -49,6 +50,7 @@ The single source of truth for **every command and action the app can perform** | View | Edit Body (checkmark toggle) | ⌘E | Card window; disabled while Raw Source is active | | View | Raw Source (checkmark toggle; toggling off = Apply) | ⌥⌘E | Card window | | View | History | — (no default) | Card window; focuses the sidebar History section (05); git boards only — section absent, item disabled on mode none / repo-nested | +| Window | Welcome to Lanework | — (no default) | Everywhere; shows (or focuses) the welcome window (02 ▸ Launch and window lifecycle) | ## Fixed grammar keys (G) @@ -79,7 +81,7 @@ All board grammar keys are inert while a title editor is focused, and menu dispa ## Context menus -Context menus are the per-item action inventory VoiceOver reads (10 ▸ Actions). Every entry is a twin of a menu command, a fixed grammar key, or a configuration control — no function's only home: +Context menus are the per-item action inventory VoiceOver reads (10 ▸ The board through VoiceOver). Every entry is a twin of a menu command, a fixed grammar key, or a configuration control — no function's only home: | Surface | Entries | |---|---| @@ -98,7 +100,7 @@ Context menus are the per-item action inventory VoiceOver reads (10 ▸ Actions) ## Standard macOS furniture -System-provided; the app adds nothing beyond convention: App menu (About, Hide, Quit — **no Settings pane in v1**: the only app-wide preferences, quick-style recents and `NSUserKeyEquivalents`, need no UI; ⌘, unused), Window menu, Help (carries the one line teaching the System Settings remap path — 04). **No Print story in v1** (⌘P unused). Customize Toolbar… per system convention (03). +System-provided: App menu (About, Hide, Quit), Window menu, Help (carries the one line teaching the System Settings remap path — 04). The app's own additions to this furniture are inventoried in Menu commands above — App ▸ Settings… and Window ▸ Welcome to Lanework; the remaining app-wide preferences, quick-style recents and `NSUserKeyEquivalents`, need no UI. **No Print story in v1** (⌘P unused). Customize Toolbar… per system convention (03). ## Changes from Kanban