Both context menus (`CardFaceView`'s pointer menu and VoiceOver twin, `LaneView`'s own pair)
twin a plain "Paste" row over `ClipboardStore.paste(into:)`. It now reads the clipboard's own
manifest and titles itself "Paste Card" / "Paste Lane" for a single copied item, "Paste N Cards" /
"Paste N Lanes" for several, and stays plain "Paste" for no app payload — a Finder file copy, a
screenshot, anything else the paste command still accepts but never decodes to a
`ClipboardManifest`.
`ClipboardManifest.pasteMenuTitle(for:)` is the one pure function both menus call — `kind` and
`entries.count` are the whole of it, since `SelectionKind` is singular by construction
(`SelectionGrammar.mixesKinds` refuses a mixed copy), so there is no mixed shape to compose a
plural for. `CardFaceView.pasteTitle` rides the exact Observable read `pasteEnabled` already makes
(`appModel.clipboard.payload`) — no new subscription on a builder that is not lazy.
`LaneView.pasteTitle` reads the same field directly rather than through `canPaste(into:)`, since
this view is already unconditionally subscribed to selection/snapshot and there is no reduction to
preserve.
Edit ▸ Paste on the menu bar stays plain — it is a responder attached to the platform's own Edit
menu row (`ClipboardCommands.boardClipboardCommands`), not a `Button` this app titles, and the
card's scope is context menus only.
Flagged for a follow-up: DESIGN/11-command-nexus.md's Card and Lane rows (lines 110-111) describe
Paste generically and could note the dynamic title.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
01 releases `labels` from the reserved list with its full first-party
shape; 04's search note stops calling it inert (integration stays
unruled); 05 reorders the sidebar inventory, retires Actions into the
toolbar, and adds the Labels section; 11 rewrites the lane menu row to
its five groups, brings the card row current (live Navigation, Labels ▸,
Copy Special ▸), and adds the chord-less File ▸ Delete Card twin.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
`HostedWindowController.hideTitle()` was the same shape of bug a429a7e fixed for
`titlebarAppearsTransparent`: an out-of-band `NSWindow.titleVisibility` write, correct the
instant it ran, undone by SwiftUI's own next pass over the window's configuration — a tree
that declares nothing resolves `.visible`, and SwiftUI writes that back over the out-of-band
`.hidden` on the very next `@State`-driven re-render this board window's own liveness causes.
The system title reappeared beside the board-popover widget, "occasionally" — whenever that
next re-render happened to land.
Confirmed with an A/B harness (no interactive display in this session, so not reproduced on
screen; mechanism established in code, per the card's own fallback): a bare out-of-band write
held indefinitely against resize and key-status changes alone, but reverted on the very next
`@State`-driven render and stayed reverted — reasserting from `body`'s own construction or
from `.onChange` both lost the same race, since SwiftUI's resync runs later than either. The
only thing that held was declaring the posture in the tree itself, mirroring
`.toolbarBackgroundVisibility`'s role in a429a7e.
`KanbanApp`'s board `WindowGroup` now declares `.windowToolbarStyle(.unified(showsTitle:
false))`. It is a scene modifier, not a per-window one, so — unlike `.toolbarBackgroundVisibility`
— it cannot wait for a board's load to finish before taking effect; every board window it
creates keeps the system title hidden from its very first frame. `boardLoadingTitlebarAccessory`
covers the gap that opens before the loading window has a store to build the real widget from: a
small, non-interactive, plain-text stand-in carrying the registry record's cached name, installed
the moment the window attaches and swapped by identity for the real widget the moment the store
loads — so the loading window's chrome still carries a name throughout, per 02-architecture.md.
`hideTitle()`'s own write stays; it is no longer what keeps the title hidden, but it is still
correct for the one render turn before the scene's own re-assertion catches up.
Confined to `BoardWindowHost.swift`, `BoardInfoPopover.swift` and `KanbanApp.swift` —
`WindowAccessor.swift`'s shared `hideTitle()`/`titleVisibility` machinery is untouched, since a
concurrent fix is addressing the card window's version of this same bug through that file.
New regression tests (`BoardLoadingTitlebarStandInTests`, `KanbanTests/BoardLoadingTests.swift`)
pin the stand-in's layout and the identity-based swap. Full suite green (3219 tests) except the
pre-existing, documented environment-sensitive `PointerLatencyTests`, confirmed unaffected by
rerunning them in isolation.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Owner's first review of the combo rework (2026-08-09): remove the face padding, make the
field taller and narrower at about a 4:5 width:height ratio, and center the symbol glyph in
its face. All three land in ComboFieldMetrics, so ColorComboControl and SymbolComboControl
stay the identical shape they were built to share.
- ComboFieldMetrics grows a width figure (height * widthToHeightRatio, 0.8), replacing
NSView.noIntrinsicMetric — every combo now carries its own taller, narrower intrinsic size
instead of stretching to whatever a caller's frame proposed.
- facePaddingH/facePaddingV/glyphPadding are gone; a face fills its zone edge to edge.
glyphPointSize is now whichever of the face's own width/height is smaller, with nothing
subtracted for padding that no longer exists.
- SymbolComboControl.drawFace centers the glyph on both axes — it only ever centered
vertically before, despite its own doc comment claiming otherwise.
- ComboFieldControl.drawTrigger bounds its chevron square by the smaller of the trigger
strip's own width/height, not height alone, since the strip is no longer close to square
once the field is much taller than it is wide.
- CardSidebarSections drops the sidebar's old '* 0.55' fixed-width frame on both combo rows;
each control now sizes itself, and both anchors (card sidebar, board popover) compose the
narrower field with no other changes needed.
- ComboFieldMetricsTests updated for the new figures, plus a ratio-holds-at-every-size test
and a rewritten glyph-fit test matching the no-padding rule.
Verification: xcodebuild build succeeded; xcodebuild test -only-testing:KanbanTests — 3220
tests in 559 suites, 3 failures, all PointerLatencyTests (documented locked-screen
environmental mode, confirmed unrelated by isolated rerun). Pixel verification unexercised —
same locked-screen constraint the first pass hit.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Toggling a card window between Edit and Raw Source could leave it with no toolbar at
all, which collapses AppKit's two-line title-and-subtitle chrome down to the single
combined "⟨title⟩ — ⟨board⟩ › ⟨lane⟩" line (the malformed titlebar reported on the
Pipeline card) — that stacked rendering only appears when a toolbar is installed.
Root cause was in HostedWindowController.attach/detach (WindowAccessor.swift), shared
by every window this app hosts. WindowAccessor's own doc comment already recorded that
SwiftUI "dismantles and re-makes the background representable" on macOS 26, and every
slot attach()/detach() manage was made repeat-safe against that (BoardChromeTests
.theSlotReappliesToTheNextWindow pins it for extendsUnderTitlebar) — but that safety
net assumes a dismantle always arrives before its matching attach, and nothing
guarantees that ordering. A content swap deep in the card window's tree (the raw-source
outlet replacing the whole content area, or an edit-mode flush landing a reload) is the
kind of churn that can make SwiftUI recreate the representable mid-session. If the old
view's dismantleNSView lands after the new view's attach has already reinstalled the
toolbar, the old identity-blind detach() had no way to tell — its guards check "is my
state still installed", which is coincidentally true right after a fresh reattach too —
so it tore the toolbar, the titlebar accessory and the delegate proxy right back off a
window a newer attach had just finished configuring, with nothing left to reinstall it.
Fix: attach(to:through:)/detach(through:) track which WindowAccessor view is the
current owner (HostedWindowController.attachedThroughView) and refuse a detach for any
other view outright. WindowAccessor.makeNSView/dismantleNSView pass their own view
through; every existing bare attach(to:)/detach() caller (this file's own tests,
BoardChromeTests, InlineEditWriteTests, HistoryProviderTests) is untouched — the guard
only engages when both sides of a call name a view. State re-asserted at the ownership
point rather than a notification-race band-aid, the same shape a429a7e's
titlebar-transparency fix used.
Could not reproduce live — the screen is locked in this environment (CGSSessionScreen
IsLocked). Established the mechanism from code and verified it with a targeted harness
instead: ToolbarStaleDismantleTests (ToolbarTests.swift) drives HostedWindowController
directly through the exact race (attach view A, attach view B over the same still-live
window, then a stale detach for view A), confirms the toolbar and delegate survive, and
separately confirms the legitimate owner's detach, the ordinary detach-then-attach
order, and every viewless caller all behave exactly as before. Verified the new test
fails without the fix (temporarily disabled the identity guard, reran in isolation, saw
the expected failure) before restoring it.
Tests: 3223 KanbanTests, 3220 passing. The only 3 failures are PointerLatencyTests'
documented locked-screen environmental mode (CGEvent-driven clicks need a live screen)
— reran that suite alone and got the identical 3 failures, none of which touch this
window-attachment code.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
LaneView.laneMenu/laneActions restructured to the owner's shape (card 22f660b0),
twinning the card menu's own reshape from earlier today (72ca222, card fe66c461):
Rename/Properties…/Style ▸ (Symbol, Color), a divider, then Copy/Cut/Paste, a
divider, then Width — Increase/Decrease/Reset, a divider, then Collapse Lane /
Expand Lane, a divider, then Send to Trash (relabeled from "Delete"). Every row
routes through existing machinery — no new commands, no new store method.
ClipboardStore's targeted copy(from:targeting:)/cut(from:targeting:) — added for
the card menu — are reused verbatim here: a new clipboardTarget computed property
wraps targetIDs (the lane's existing widen-to-selection rule) as an
ItemReferenceSet, exactly the card menu's clipboardTarget one type over. Paste
does not retarget, the card menu's own posture (a destination operation with no
per-item widening precedent). Reset Width is exactly setLaneWidth(lane.id,
units: 1) — the same call Decrease already makes at the floor — which
setLaneWidth's own remove-at-default rule already turns into an absent width
key, so no new write path is needed.
Style ▸ Symbol and ▸ Color both open the one existing style popover (the card
menu's v1 posture, unchanged), and the quick-style recents row is dropped from
this menu for symmetry with the card menu's own drop — StyleMenuItems is no
longer called from LaneView, though it and QuickStyleRow are left in place
(unused, easy to restore) exactly as the card menu's own commit chose to leave
them. Properties… is a disabled placeholder row, the owner's own word, left out
of laneActions since an always-disabled row has nothing to announce a custom
action for.
Unlike CardFaceView, LaneView's Copy/Cut/Paste enablement (copyEnabled/
cutEnabled/pasteEnabled) calls ClipboardStore's real predicates directly rather
than reducing them to selection/snapshot-free forms: this body is already
unconditionally subscribed to store.selection (isSelected) and store.snapshot
(headerInk) every pass — the struct's own "Equality gate" doc section says so —
so nothing new is subscribed, and the predicate cost is paid once per lane
(a handful) rather than once per card (hundreds), the axis the card menu's own
reduction was protecting.
Journaled on the card: Style's one-popover posture and the dropped recents row
both flagged "needs owner review" (mirroring the card menu's own flags);
DESIGN/11-command-nexus.md's Lane row is owed a rewrite, left for the main
session, same as the card menu's commit left its own Card row.
Tests: a new targeted copy/cut test proving ClipboardStore's targeted overloads
work on a lane id (writes a lane manifest, arms a lane pending-cut) — the exact
call LaneView's new Copy/Cut rows make — plus the full existing suite: 3220
tests, 3 pre-existing environmental failures (PointerLatencyTests, confirmed by
isolated rerun, unrelated to this change), all else passing.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Right-clicking a card now offers Labels: up to twelve rows ranked by how many cards on the
board carry each one, tie-broken by what this user reached for last, each a checkmark that
says whether *this* card already has it. More… opens the whole inventory beside the card, in
lookup order, with a field that mints a name the board has never used.
The rows deliberately do not widen to the selection the way Copy, Cut and Send to Trash do. A
checkmark is a claim about one card, and three cards where two carry `bug` have no honest
checked state — the rows that widen are the ones that say what they will do rather than what
is already true. It is also what keeps the menu cheap: a context menu's contents are rebuilt
on every ordinary pass of every card face, so reading the selection there would resubscribe
the whole board. The board-sized half of the ranking is cached on the store and gated on
equality; what runs per face is bounded by how many distinct labels exist, not by how many
cards do.
Copy Link leaves the first group for a new Copy Special submenu, mirroring Paste Special —
the placement the owner's latest layout asks for, and the one the row's own note has been
waiting on since it shipped as a same-day deviation. It keeps its VoiceOver action even so: it
is an action that changed doors, not a door.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
`labels` has been a reserved tracker key since the rewrite: preserved verbatim, never
interpreted, drawn only as an anonymous row in the Details section beside `assignees` and
`due`. The owner's cards claim it for first-party use, so it joins the schema — read
leniently (a list of names, a bare scalar coercing to one, a mapping malformed and
preserved), written canonically (a quoted flow list in the order the user arranged, no
auto-sort), and removed outright when the last label goes, the way an expanded lane drops
`collapsed`.
Identity is case-insensitive and display is case-preserving, so a card carries `bug` once
however many ways the board spells it, and entries the reading cannot name ride through the
write untouched at the tail.
The section sits second, above Details — which is the point rather than a layout preference:
Details is where keys the app does *not* own are shown, and this key just stopped being one.
Rows rather than chips, because the sidebar is twenty-six characters wide. The add field
autocompletes against the board's own used-labels universe, derived from every live and
trashed card with no store beside the files, and says out loud when Return would mint a word
the board has never used.
Writes ride a `.relabel` operation of their own, because the commit composer has said
"Relabel card 'X'" since long before there was a control to press — and now the undo row says
it too.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The agent-written test inited BoardStore on a bare WriterFixture temp dir;
the fail-fast loader refuses that, so the test failed on its own branch
despite the reported green. Swapped to the file's own makeBoard() fixture.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
CardMoveTarget (BoardCommands.swift), LaneMoveTarget's cousin: validates
the clicked card's own current lane against the live lane order rather
than the board's live selection, refuses a target that reaches outside
that lane (no coherent left for a spread), and answers an index —
the clicked card's own position in its lane, clamped — for the
adjacent live lane in either direction. Trash is never a candidate
(not a Lane); a collapsed lane is a fine landing (a fold hides cards,
it doesn't close the lane).
CardFaceView's Navigation rows now call moveCardAcrossLane(by:), which
hands CardMoveTarget's answer straight to BoardStore.moveCards(_:toLane:at:)
— the exact call a released drag makes, so rank-minting, the undo step,
the watcher echo and the banner all come free. Targeting is Copy/Cut's
own widening (targetIDs): the clicked card, or the live selection when
the clicked card is a member of it.
Enablement stays render-safe the way isSelected/selectedCount already
are: three new CardFaceView parameters (hasLeftNeighbor, hasRightNeighbor,
selectionSpansLanes) are hoisted once per lane in LaneView.scrollableCards
and handed down as compared parameters, never read from inside a card
face's own .disabled. Caught and fixed a real regression here during
development: an early cut of the multi-lane-spread check answered true
for any lane that simply didn't contain the selected card, which
flipped a compared parameter for most of the board on an ordinary
single-card select and defeated CardFaceView's equality gate wholesale
(BoardRenderPerformanceTests.selectionStillRepaints caught it at 151 of
180 card faces).
Tests: CardMoveTargetTests (KeyboardGrammarTests.swift) pins the pure
predicate — leftmost/rightmost lane, single lane, index clamping, a
widened group anchoring on the clicked member rather than its own
extent, the multi-lane-spread refusal, and an integration test feeding
the answer straight through moveCards. CardFaceViewEquatableTests
gains a case pinning the three new compared parameters.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
CardWindowView's header is now the title, live: a static Text in Preview, an editable
single-line TextField in Edit, both reading a new CardTitleEditSession's buffer rather
than the card's own snapshot value — the same "buffer outranks the snapshot" reason the
body surface already reads bodySession.text instead of card.body.
CardTitleEditSession is CardBodyEditSession's shape one field over: the one-isDirty write
gate, dirty-buffer-wins on adopt(diskTitle:), the ~700ms injectable debounce, flush() /
flushOrThrow() for DirtyBufferGuard, and beginEditSession()/endEditSession() with a
session-coalesced undo step (one per session, never per debounced tick). It rides the
body's own begin/end/flush doors rather than opening a second session boundary — title is
only ever editable while the body column is in Edit mode — because the two write through
different WriteOperations with different validation and merging them would conflate two
unrelated frontmatter keys behind one buffer.
BoardStore.commitCardTitle(inCard:title:) reuses the same private setTitle helper and the
same .rename WriteOperation the board's own inline rename commits through, so trimming,
empty-removes-the-key, unchanged-writes-nothing and banner enrichment are one code path,
not a re-implementation. It resolves through cardBodyTarget (spans lanes and the trash),
not boardItem (board only), because a card window's title field stays live through the
same dismissal-into-trash flush the body already gets — the one deliberate divergence
from the board's own rename, which treats a trashed target as vanished.
registerTitleEdit(inCard:priorTitle:newTitle🔛) mirrors registerBodyEdit, anchored by
card identity and the already-reserved ExpectedField.title, folding into the same
one-coarse-step-per-window-close undo model.
Every place the body's Edit buffer flushes, the title's now does too: mode exit
(bodyPresentation.flushEdits/beginEdits), window close and the dismissal path
(CardWindowSession.endSession()), raw-source entry (configureRawSource), the close-time
DirtyBufferGuard modal (attemptSave tries body then title), and the fast-path close gate
(closeAfterFlushing() now checks title.isDirty alongside body.isDirty).
holdsUnsavedContent and settlement carry the title too.
Tests: CardTitleEditSessionTests.swift mirrors CardBodyEditSessionTests.swift (write
gates, normalization and newline-stripping, dirty-buffer-wins, debounce, undo-step
coalescing). CardTitleWriteTests.swift covers commitCardTitle/registerTitleEdit:
byte-identity no-op, empty-removes-key, vanished, the trashed-card-is-still-writable
divergence, a readable-but-uneditable target refusing and bannering, and a read-only
board suspending quietly. CardSessionUndoTests.swift gains coverage that a title edit
folds into the coarse close step alongside a body edit and registers on the window's own
stack, never the board's. RawSourceTests.swift's hand-wired rig picks up the title
session configureRawSource now also flushes.
3148 KanbanTests pass, 0 failures.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
A "Choose Image…" row joins "Other…" beside the background palette, live
only when the Style… popover is aimed at the board (background.image is a
board-root field — lanes and cards carry no such key to write). A standard,
image-restricted NSOpenPanel hands the pick to BoardStore.applyChosenBackground,
which copies the bytes into .backgrounds/ under the file's own name — Finder-
laddered on collision, overwritten in place on a repeat pick — points
background.image at the copy, and leaves background.color exactly as it was,
the same posture Paste as Board Background already carries. The existing
repoint tidy trims a superseded .backgrounds/ file automatically; nothing
about it needed to change.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Actions is gone from the trailing sidebar. Its two rows land on the card
window's toolbar instead: Delete Card is a new default item (trash SF
Symbol), and Reveal in Finder joins the customizable catalog. The sidebar's
final order is now Style, Details, Attachments — no fourth section.
Delete Card is a push button gated by the same read-only predicate the
sidebar button carried, and it fires the identical write
(BoardStore.deleteCard(_:)) — same bracket, same stamps, no confirmation,
matching the delete flow exactly: recovery is the board's trash lane, so
there is nothing here for an alert to guard. It sits behind a trailing
flexibleSpace in the default set, apart from the four creation/view items
ahead of it, the HIG separation Mail.app's own toolbar Delete models —
one-click, no-confirm, recoverable by trash.
Reveal in Finder is catalog-only: it already had a menu-bar twin with no
default chord (File ▸ Reveal in Finder / RevealInFinderCommand), so nothing
was unreachable before this — the toolbar item is Customize's shortcut to
the same computation (CardAttachments.revealURLs), not a new path.
Delete Card needed a menu-row twin of its own before it could sit on the
toolbar at all ("toolbars are pure enhancement: every function they host
already has a menu item + shortcut" — 03-board-ui.md ▸ Toolbar). File ▸
Delete Card is that row: distinctly titled from the board-scope File ▸
Delete (whose title 11-command-nexus.md calls out as the ⌘⌫ chord's
singleton), and deliberately chord-less — an enabled delete-key equivalent
in the card window would steal delete-to-line-start from its text surfaces,
the same reason the board's own ⌘⌫ was never extended here in the first
place. A new small handle, CardWindowActions, carries the wiring through the
focus system the way CardAttachments and CardPrintSubject already do for
their own single-purpose seams — kept separate from CardAttachments on
purpose, since a delete has nothing to do with the attachments section that
type is scoped to.
CardToolbarTests grows the BoardToolbarTests split (defaults vs. catalog,
now that they differ) plus two new suites: Delete Card firing the wired
write under the lock, and Reveal in Finder's enablement mirroring the menu
row's own card-window computation.
05-card-window.md's Actions section and 11-command-nexus.md's File-menu
inventory are now stale; both amendments are owed and tracked on the card's
own thread rather than made here.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Style, Details, Actions, Attachments — the trailing sidebar's own composition
order in the card window, reordered so Attachments sits under everything
else rather than leading it. Section internals are untouched; this is the
VStack's child order and nothing more, so keyboard Tab order and VoiceOver's
reading order follow it for free, and File ▸ Add Attachment… (⇧⌘A) is
unaffected — it opens the panel through the focus system, not through this
view's layout.
Actions still sits above Attachments here: the paired card that removes the
Actions section entirely lands as the next commit, so this one is an honest
mid-flight state — style/details/actions/attachments now, style/details/
attachments once that follow-up lands.
05-card-window.md's sidebar order is now stale (still lists Attachments
first); the amendment is owed and tracked on the card's own thread rather
than made here.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Raw image data on a card window's ⌘V was a keyboard shortcut with no visible
trigger; the sidebar's Attachments header now grows a quiet control — beside
the existing add affordance, present only while the pasteboard holds a
picture this card could take — that pastes it through the exact seam the
retired branch used (ClipboardStore.pasteImage(intoCard:in:), the board's
"Paste Image into Card" row's own call). The file-URL branch stays on ⌘V; a
Finder copy is still unambiguous. CardBodyTextView's paste-yield mechanism
needed no change at all — it forwards by capability, not by picture-specific
logic, so a screenshot ⌘V with the body editor focused is now a genuine
no-op there, served by the new control instead.
The pasteboard's re-read gains a fourth checkpoint — a window becoming key —
alongside menu-tracking, ⌘-down and app activation: a persistent visible
control has to read true continuously while its window is frontmost, not
only at the instant a menu or chord probes it.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Amendments the worktree agents owed but could not write: 03's palette
counts and the symbol full-browser reversal (CoreGlyphs-derived catalog),
05's sidebar visibility ruling, 11's Show Sidebar row and the card
context-menu row reshaped to the four-group structure with its two
flagged gaps (style pre-focus, the disabled Navigation pair).
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
CardFaceView.boardMenu/boardActions restructured to the owner's shape (card
fe66c461): Open/Copy Link/Rename/Style▸(Symbol,Color), a divider, then
Copy/Cut/Paste/Paste Special▸(Paste Image into Card), a divider, then
Navigation▸(Move Left,Move Right), a divider, then Send to Trash. Every row
routes through existing machinery — no new commands.
ClipboardStore gains copy(from:targeting:)/cut(from:targeting:) and their
canCopy/canCut twins, so Copy and Cut can widen to the clicked card exactly
as Delete and Style already do ("right-clicking something outside the
selection acts on what was clicked"), without disturbing the Edit-menu path.
LaneMoveTarget.destination is extracted out of MoveLaneCommands so the
card menu's Navigation rows validate against the identical sole-live-lane
predicate as Board ▸ Move Left/Right. Since a card id can never itself
satisfy that predicate, the two rows are wired to the real store call but
unconditionally disabled — reading the live selection per card face would
reproduce the O(board) render regression isSelected/selectedCount exist to
prevent (contextMenu's builder is not lazy).
Style ▸ Symbol and ▸ Color both open the one existing style popover — no
per-section pre-focus (StyleEditorSession has no such concept, and
StyleEditorView internals are out of scope while another pass redesigns
the pickers). Paste and Paste Image into Card reduce their .disabled
checks to selection/snapshot-free forms, proven safe by construction (a
rendered card face already guarantees a live lane / a live board card).
Journaled on the card: Copy Link kept (shipped same day, not in the
owner's list), "Delete" relabeled "Send to Trash" (board-side move, not
the permanent trash delete), quick-style recents row dropped from this
menu, Navigation's always-disabled rows, and Paste not retargeting to the
clicked card — all flagged needs owner review. DESIGN/11-command-nexus.md's
Card row is owed a rewrite, left for the main session.
Tests: LaneMoveTarget.destination (new), targeted copy/cut (new), plus
existing ClipboardStore/PasteTarget/MoveLane/CopyLink/Trash-menu/PasteImage/
PasteFile/Style/CaretChord/render-performance/equatable-gate suites —
156 tests, all passing.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
`titlebarAppearsTransparent` is not the app's to hold on a scene window: it is
the AppKit face of the tree's resolved `toolbarBackgroundVisibility`, and
SwiftUI writes it on every pass that re-applies a window's configuration. The
board stated nothing, so `.automatic` resolved and each pass put `false` back
over what `HostedWindowController` had set out of band — correct on open,
because the walk lands after the pass that follows window creation, and opaque
at the next one, with nothing to re-assert it since the board's own reading had
not changed. The board now declares the posture where SwiftUI will keep
asserting it; the AppKit write stays as the same value one turn earlier.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The colour combo was a wide two-zone field with a second door onto the Colors
panel; the symbol picker was a small square button with one. Both now subclass
one `ComboFieldControl`, so they are the same width, height, radius and trigger
by construction: click the face for the standalone picker, click the chevron for
the quick list. The symbol face opens a new floating browser over the OS's own
category, ordering and keyword plists out of CoreGlyphs.bundle — searchable,
categorised, trademark-restricted glyphs withheld.
The palette grows twelve to sixteen per table, filling the hue ring's four
widest gaps with lime, jade, indigo and magenta at each table's own saturation
and brightness. That gives the Style… popover's background grid a third row and
the tint grid its third row of four, and both grids gain an Other… row onto the
system colour picker — which the card sidebar's combo has had all along and the
primary styling surface never did. An arbitrary hex already round-tripped; it is
asserted now, including that an unquoted one is a YAML comment and no value.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Follow-up to d0c5461's .backgrounds/ folder: generating, pasting, or
choosing solid now trims the app's own prior file in .backgrounds/ as
part of the same write when it repoints or unsets background.image
away from it — silent, best-effort, never blocking the gesture that
triggered it. A scheduled heal at every board open sweeps whatever
that trim declined or missed: any .backgrounds/ file the board's
current background.image no longer names, announced with a loss-row
notice in the loose-file relocation's own voice. Legacy root-level
references are untouched by both paths — the tidy scopes to
.backgrounds/ only, since that is the one folder the app can prove it
wrote into.
Removal is via FileManager.trashItem, matching the attachment-removal
precedent (recoverable, never a hard delete).
Supersedes applySolidBackground's earlier "facets.png survives on
disk" contract for the settled case: the ruling reads "unsets" as one
more shape of "repoints away from a .backgrounds/ file", so a settled
solid choice now trims the generated picture it displaces, same as
switching producers does. The superseded test and doc comment are
updated to the new behavior; the echo-window (no-reload-yet) case is
unaffected and still leaves the file in place.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The card window's trailing attributes sidebar has always been unconditional
since m6; this adds View ▸ Show Sidebar (a checkmark toggle, ShowComments'
shape) and a matching toolbar item on the customizable card toolbar
(NSToolbar/WindowToolbarController), a fourth default beside Edit Body,
Raw Source and Add Attachment. sidebar.right for the trailing pane; one
shared animated write path (AppPreferences.setShowCardSidebar) both faces
call, structural-voice reflow with a trailing slide-and-fade transition
(Motion.cardSidebarTransition), Reduce Motion respected throughout.
CardWindowMetrics.minimumSize gains a sidebar: Bool = true parameter so a
hidden sidebar shrinks the window's floor, composing with the existing
commentsColumn parameter. The toolbar item's read/write are injectable
closures (defaulted to the real UserDefaults-backed pair) so its plumbing
is testable without touching the developer's own preferences domain.
WindowToolbarController's observation tracking only sees @Observable
reads, so a small HostedWindowController.revalidateToolbar() plus an
onChange nudge keeps the toolbar button's on-state in step with the
View-menu row's write.
Scope held narrowly to visibility, per the card: no sidebar section
reordering, no action-moving.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
DESIGN/15's open-questions bullet claimed the owner's literal word was
"proceed as per your recommendation for v1"; no such message exists. The
rulings were inferred by the implementing session from the card's move to
Approved, and the doc now says exactly that. README gains the 15 row,
11-command-nexus gains File ▸ Import Board… and the three Export rows.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Added .onHover modifier to the collapseButton to explicitly set NSCursor.arrow when hovering over the collapse chevron, preventing the lane resize handle's resize cursor from appearing over the button.
CLAUDE.md bumps to v14: a new Comments section teaches the comments/ mechanics
(chronology-as-ordering, author vs modified-by, .draft/.trash as the app's,
retract-by-follow-up), and a new "Use the thread: journal your work" section
teaches conduct the guide never had — body is the spec, thread is the journal,
post a plan cold-reader-ready on start, decisions as they're made, questions
as comments, re-read before resuming, close with verification. comments/
leaves the reserved-tracker-keys list; the Layout diagram gains comments/ and
AGENTS.md.
The app now writes a byte-identical twin at AGENTS.md, the vendor-neutral name
most non-Claude tools read — same lifecycle as CLAUDE.md, decided and healed
independently per claimed name, both funneling a markerless foreign file to
the single shared CLAUDE.user.md rescue. AgentGuide.install now returns
[Displacement]; IntegrityRules.claimedRootNames and BoardStore.refreshAgentGuide
cover both names; ChangeNarrator's guide-commit path check widens to both.
DESIGN/08-agent-integration.md amends: the agent-guide section gains the
Comments and Use-the-thread bullets and an AGENTS.md twin paragraph.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The image branch's precedence ladder always had a second clause: a file URL on the
pasteboard suppresses it, "a different gesture with a different answer" that the
code deliberately declined rather than guessed at. This fills it in: one or more
file URLs paste through the same `importAttachments` a Finder drop takes — one
collision ladder, one folder refusal (`FinderDrop.partition`), one set of banners
— outranking raw image data riding beside it (a Finder-copied image file carries
both; the actual file lands, not a re-encoded copy of its bytes) while still
deferring to the app's own clipboard type. Both ⌘V surfaces read the same
`ClipboardStore.fileURLPayload`, so the board's fallback and the card window's own
branch stay in step by construction rather than by two hand-kept-in-sync checks.
Fixed a real leak in the body editor's paste yield along the way: `public.file-url`
conforms to `public.url`, which `NSTextView` legitimately reads for a pasted
hyperlink, and `NSPasteboard.availableType(from:)` matches by conformance rather
than exact type — so a Finder copy carrying a generic URL representation beside
its file URL would have been silently swallowed as text and never reached the
window's attachment branch at all. The yield now declines outright on any
file-URL pasteboard before the generic capability check runs.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
New app-written background images (Theme tab ▸ Pattern, and Edit ▸ Paste
as Board Background) now land in a hidden `.backgrounds/` folder at
board root instead of beside index.md, matching the `.trash/` app-managed
pattern. `background.image` stores the qualified relative reference
(`.backgrounds/facets.png`); the resolver needed no change at all, since
it already accepted any relative path inside the board root — the same
mechanism that already resolved `art/backdrops/sunset.png` resolves the
new location for free. `BoardWriter.writeBoardImage` now creates its
destination folder if missing, since `.backgrounds/` won't exist until a
board's first generated or pasted background.
The Finder collision-ladder (`BoardStore.boardImageName`) is rescoped to
`.backgrounds/`'s own contents, and its overwrite-in-place check now
recognizes only the qualified form as "ours" — a legacy bare
`background.image: facets.png` from before this change is read as a
foreign reference rather than migrated, so a regeneration writes a fresh
`.backgrounds/` file and orphans the old one in place, per the no-migration
ruling. The Theme tab's Pattern/Solid mode-detection was updated to
recognize both the legacy and current spellings as the generator's own
output.
The board loader needed no change: `.backgrounds/` is a hidden,
non-UUID-shaped name, and `.skipsHiddenFiles` already keeps every hidden
entry off the lane walk before any name-based exclusion is consulted —
pinned with a new loader test. Deliberately did not add `.backgrounds` to
IntegrityRules' claimed-name/squatter-displacement table: that table
mirrors a specific existing DESIGN ruling this card doesn't amend.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Kanban.xcodeproj must be regenerated (xcodegen generate) before building.
CardWindowMetrics.commentsColumnWidth was a fixed figure between the body
column and the comments pane in the side-by-side layout; it is now only the
resting default. A new CommentsColumnDivider replaces the plain Divider()
between the two panes with a real HStack element carrying a resize-left-right
cursor and a drag gesture, clamped through a new pure seam,
CardWindowMetrics.clampedCommentsColumnWidth — never narrower than the
existing commentsMinimumWidth floor, never wide enough to push the body under
its own bodyMinimumWidth. The drag tracks live in memory
(commentsColumnWidthOverride) and writes AppPreferences.commentsColumnWidthKey
exactly once, on release, mirroring LaneResizeSession's live-track/write-once
split rather than hammering UserDefaults per tick.
Persistence is app-wide via @AppStorage, matching showComments and
commentsBesideBody — the pane's other two layout bits — rather than the
per-card BoardRegistry.cardWindowFrames: this is "how the pane is arranged,"
the same kind of fact those two already are, not a per-card window geometry.
Flagged on the card thread as a call worth owner review.
Six new unit tests cover the clamp's two floors, the degenerate case where a
container is too narrow for both, and its agreement with
CardWindowMetrics.minimumSize at the window's own floor.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
File ▸ Export ▸ writes the frontmost board as Obsidian Kanban Markdown, a
plain Markdown outline, or RFC 4180 CSV; File ▸ Import Board… reads any of
the three back into a fresh board, format detected rather than asked. Every
format encodes order as document position, so an export writes no ranks and
an import mints them in parse order on the ordinary create path. Lossy
exports post a warning-tone loss row naming the comments and attachments the
destination cannot carry. Convert-once: nothing watches, nothing merges.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The editor holds the keyboard from the moment the card window opens, and
NSTextView answers an image-only pasteboard with a disabled Paste row —
so the window's image branch sat one responder below the keyboard and
could never be reached by it. The yield is by capability
(readablePasteboardTypes), the same shape as the editor's existing
refusal of file drops: any text flavor keeps the paste in the editor,
and a pasteboard the editor has no reading of passes paste: to the
responder behind it. Live-probed with real window-server ⌘V chords in
both windows.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Commands validate by conditional responder attachment, so the cached
imagePayload decided whether ⌘V had a responder at all — and the one
pasteboard writer that never deactivates the app, the screenshot hotkey,
changed the pasteboard without any checkpoint firing. Menu validation is
now two concrete moments: a menu beginning to track, and ⌘ going down,
whose beat before the letter is when the observation re-arms the
responder. One changeCount read per check, no timers.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
⌘V grows an image-data branch, below the app's own clipboard format and refused
outright while a file URL is on the pasteboard: a screenshot or a browser's Copy
Image lands as "Pasted Image.png" in the anchor card's attachments/, through the
very import path Finder file drops and ⇧⌘A take — one bracket, one Finder-style
collision ladder, one set of banners, and the same silence a drop's arrival has.
A card window's ⌘V pastes onto its own card; a focused text field still wins the
selector natively. A file-shaped flavor travels byte for byte, PNG preferred when
several are offered; TIFF and BMP are re-encoded to PNG, being interchange
encodings rather than files anyone wants in a folder.
The hero key gets the setter it was born owing: "Set as Hero" on any image row of
the attachment list, "Remove Hero" on the row that holds it, with menu-bar twins
so the context entry is nobody's only home. It writes as a restyle — one key, one
bracket, one invertible step on the window's own stack — and replaces rather than
refusing, because a card has one hero and the row that has it says Remove instead.
Edit ▸ Paste as Board Background is the same payload's other destination, taking
the existing background.image convention at its word: the picture into the board
folder as "Pasted Background.png", the colour subkey untouched, the generator's
overwrite-our-own-name rule inherited and its echo memo taught to tell the two
producers apart.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
A board is a folder, and a folder previews as a folder. `KanbanQuickLook.appex` gives it a
document's preview instead: the board's name, its tint and its symbol, then its lanes in display
order with each one's card count and its first few card titles.
The reading is `BoardOutline` (Kanban/Storage), deliberately not `BoardLoader.load`. The loader
throws on a half-broken board — right for opening one, wrong for pressing Space, where the honest
answer is the part that reads; it visits every card and lists `attachments/` and counts `comments/`
inside each; and it carries trash, tombstone migration and the defect stream, none of which renders.
This walk never throws and is capped at every level (`BoardOutlineLimits`): 12 lanes shown of at
most 100 considered, 6 card titles per lane of at most 200 parsed, counts by readdir-plus-stat up to
2000 per lane and never a parse. It re-derives nothing that decides *what* the answer is —
`FrontmatterDocument` parses, `IntegrityRules.isIdentityShaped` says what a lane or a card is,
`BoardLoader.directoryCandidates` supplies the stray tolerance, `Ranks` supplies display order,
`Palette` resolves colours — only *how far to look*.
The reply is HTML, the one data-based reply that reflows: a Quick Look panel is resized by the
user and a board outline is a wrapping row of columns, so a drawing block baked at a fixed
`contentSize` would be the wrong size a moment later. It gets vector text, its own scrolling and
light/dark for free. The board tint is a wash under the title and a lane's edge accent — never
under text, because a preview has none of `ContrastMath`'s ink-picking machinery and should not
grow one.
The extension compiles `Kanban/Storage` whole, the `KanbanMobile` arrangement — the directory is
one unit in practice, so a narrower list is not on offer. `STORAGE_ONLY` is new: EchoLedger's
consumer sections speak the live store's vocabulary, and the phone's `#if os(macOS)` cannot exclude
them from a target that *is* macOS. Platform, and layer. Nothing else defines it.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Duplicate's own posture — a faithful copy, `.git` the sole exclusion, attachments/comments/trash
carried verbatim — staged to a temp directory (BoardShareStager, ditto-zipped via
DittoZipArchiver) and presented through NSSharingServicePicker (BoardSharePresentation), anchored
to the board window's toolbar or its center. Follows Duplicate/Save as Template's flush-then-
cancellable-copy sequence under the banner's in-progress row (ShareBoardCommand, AppCommands.swift),
menu-validated on focus alone rather than the read-only lock (a share is a read, Print's own
posture) with the one carve-out an open inline title editor still needs. WriteOperation gains
.shareBoard for the banner vocabulary; 11-command-nexus.md's File menu table gains the row.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The owner's 2026-08-08 report ("SF symbols don't render well in the PDF output
of File ▸ Print…") photographed solid dark rectangles where the card icons
belong. The cause is not typography and not the renderer's layout: an
`NSImage(systemSymbolName:)` is a *template* image, a shape meant to be tinted
by the AppKit machinery that draws it. A print/PDF context has none of that
machinery, so the tint lands on the image's whole box instead of through its
coverage — a filled rectangle, measured at 1.000 ink coverage through a real
`NSPrintOperation`.
A second failure hid behind the first: a PDF context is a 1× device, so even a
non-template symbol rasterized at 72 ppi on the way onto the page (13 × 12
pixels for an 11 pt icon) and blurred at any zoom.
Both are the same mistake — leaving work for a context that cannot do it — so
`PrintSymbol` does the work first: the symbol is inked in the line's own colour
(resolved against the paper appearance, since a dynamic colour resolves at draw
time and this drawing happens long before the page exists), drawn into a bitmap
at eight times the point box, and handed over as ordinary non-template artwork.
The page now carries a 576 ppi glyph at 0.277 coverage. True vector was
measured and is not available: `NSSymbolImageRep` rasterizes into whatever
context draws it, the symbols are not reachable as font glyphs by name, and
re-wrapping the image in a PDF representation only embeds the same raster one
level down.
While in there, the attachment's baseline stops being a guess. It was
`font.descender * 0.5` — a constant that knew nothing about which symbol it was
placing, so every icon floated by a different amount. It is now the symbol's own
`alignmentRect`, which is Apple's metric for exactly this: the rect's height is
the font's cap height and its origin is the symbol's baseline within its box.
The forced light appearance moves to `PrintTypography.paper` because two places
now depend on it and must not drift: the page view pins it, and the symbol
raster draws under it.
Lane headings were checked and need nothing — `PrintLane` carries no icon, so
card meta lines are the only symbols a printed document has.
Tests drive the real pipeline: `PrintDocumentBuilder` → `PrintDocumentRenderer`
→ a real `NSPrintOperation` to PDF, then measure the ink on a sheet whose only
content is one icon. The coverage assertion fails at 1.000 on the shipped build.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
README: the lane header's chrome loses its + and gains the collapse story,
card faces gain chips and the hero banner, keyboard navigation gains the
sticky row, the clipboard bullet gains Copy Link, the symbol grid becomes
three per-level shelves, and Printing gets the bullet it never had.
CHANGELOG: everything user-facing since the last documented commit, the
print feature and its entitlement fix folded into one honest entry.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
`CuratedSymbols` was one flat ~65-glyph list serving every style-editor target alike. It is
now three level-specific sets — `boards` (project/container/identity), `lanes`
(workflow/stage/status), `cards` (work-item/content) — each ~30-40 entries, seeded from the
original list and `SymbolPickerCatalog.defaultSet`, reorganized by which level a glyph actually
reads as being about. Overlap is kept where a glyph genuinely fits everywhere (`flag`, `star`).
Wiring:
- `BoardInfoPopover`'s board-glyph `SymbolPicker` now passes `CuratedSymbols.availableBoards`
instead of the picker's domain-agnostic default.
- The card sidebar's `SymbolPicker` (`CardSidebarSections`) now passes
`CuratedSymbols.availableCards` instead of the old flat `available`.
- The style editor's own curated grid (`StyleEditorView`, the Style… popover's only remaining
anchor) reads `CuratedSymbols.availableForStyleEditor(level:spansLevels:)`: a homogeneous
target reads its own level's set, and a target that somehow spans more than one level (today
unreachable — 04-interactions.md's cards-XOR-lanes rule keeps a live selection homogeneous)
reads the three combined, via a new `BoardStore.styleTargetSpansLevels` seam that asks the
question `styleLevel(of:)` deliberately collapses.
- `CuratedSymbols.combined` (the three sets' stable-order union) also replaces the old `.all`
in `SymbolPickerCatalog`'s full-catalog fallback.
- `SymbolPickerCatalog.defaultSet` is kept as the fallback for a caller naming no level (a
future saved-search picker, say) rather than retired.
DESIGN/03-board-ui.md and DESIGN/05-card-window.md's Styling/sidebar prose amended minimally
where they named "the curated set" as a single list.
Tests: three new/rewritten suites in KanbanTests/StyleModelTests.swift (set shape, availability,
overlap, `combined`, the style-editor level/span decision, the board-anchor width tripwire), one
new test in KanbanTests/StyleWriteTests.swift (`styleTargetSpansLevels`), and the old
single-list-pinning tests in KanbanTests/SymbolPickerTests.swift and KanbanTests/CardSidebarTests.swift
updated to the new set names. 2834 tests, 487 suites green (KanbanTests, arm64); one unrelated
flaky failure (RootRecoveryTests.vanishAndReturn under full-suite load) passed clean in isolation.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
A card whose thread holds one comment or more now draws a second trailing chip beside the
paperclip: a secondary-tinted bubble glyph plus its count, shown only when the count is above
zero (design ruling 2026-08-09, card e729e30a). Same styling family as the attachments chip —
caption size, secondary tint, decorative and hidden outright from the accessibility tree — but
this one carries a visible count rather than staying icon-only, per the ruling's own "bubble-style
SF Symbol + count." It sits after the attachments chip at the row's trailing edge, in both the
live title row and the drag replica.
The count is a new `Card.commentCount` field the loader fills with a readdir over `comments/`'s
identity-shaped children that carry their own `index.md` — `BoardLoader.commentCount(in:)`, built
on the same `identityShapedChildren` predicate a trash entry's held-card count already uses. Never
a parse: `.draft` and `.trash/` are excluded for free, the same dot-prefixed hidden-entry skip
`CommentThread.load` documents for both, so the walk stays exactly the O(cards) shape
01-storage-format.md § Enhanced schema already commits to. Because the count rides inside the
`card: Card` parameter `CardFaceView` already takes — not a new parameter of its own — drawing the
chip costs nothing beyond a field read on an already-compared value: no new Observable read joins
the body, and the equatable gate already covers it via `Card`'s synthesized `Equatable`.
The one divergence from the comments pane's parsed count is documented rather than hidden: a
comment folder whose `index.md` exists but fails to parse is a `Stray` the thread read excludes by
opening and rejecting it, a cost this readdir does not pay. The face may then read one comment
high until that folder is fixed or removed — the trade the ruling's "cheap directory-entry count…
not a parse" asks for, over paying full parse cost on every card of every load. Every well-formed
comment, and every card with no malformed one, agrees with the pane exactly.
VoiceOver: `AccessibilityPhrases.cardValue` gains a `comments: Int` parameter, appended after
attachments and before the cut-pending phrase — the same left-to-right order the two chips draw
in, so a sighted read and a VoiceOver read never disagree about which comes first. The trashed
lane row's own call site (an opaque unit with no comments to speak of) passes `comments: 0`.
Docs: DESIGN/03-board-ui.md's card-face section describes both chips and retires the stale "closed
with no growth" sentence, honestly recording the 2026-08-09 growth (the hero banner landed hours
earlier, this chip after it) as exposure of facts the card already carries rather than a body
excerpt. DESIGN/10-accessibility.md's flattened-element sentence gains the comment count.
DESIGN/01-storage-format.md's Enhanced schema paragraph records the chip as shipped. WISHLIST #9
is marked shipped in place — not renumbered, since #10 and #11 are cross-referenced elsewhere.
Tests: CardCommentCountListingTests (BoardLoaderTests.swift) pins the readdir against a synthetic
tree — no comments/ folder, an empty one, non-identity-shaped and index-less strays excluded,
.draft/.trash/ excluded for free, agreement with CommentThread.load's parsed count in the
well-formed case, and the one documented divergence on a malformed index.md.
AccessibilityPhrasesTests covers cardValue's new parameter alone, alongside attachments, and
all three fragments together. ViewEquatableTests pins that a comment landing on a card is a gate
difference. BoardRenderPerformanceTests adds a render-cost guard: one comment added to one card
on a hosted 180-card board re-renders a handful of bodies, not the board.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Root cause of the owner's repro (board window frontmost, File ▸ Print…
enabled, chosen from the menu, alert appears anyway): Kanban.entitlements
carried no com.apple.security.print key. The app is sandboxed, and a
sandboxed NSPrintOperation is denied by the sandbox with exactly this
wording — "This application does not support printing. Please contact
the application's developer." — regardless of which code path invokes
it. Fix: add the entitlement.
Alongside it, hardening for a separate, narrower failure mode that
happens to produce the identical alert text by a different mechanism:
PrintCommand used to disable itself over a window that published
neither a board nor a printable card (welcome, the template chooser,
Settings, the restore-bootstrap window, a card window whose board
hasn't joined). A disabled SwiftUI Button still owns its
.keyboardShortcut, so the unclaimed ⌘P chord fell through to AppKit's
own nil-target printDocument: action, whose stock failure is the same
system alert. The row now claims ⌘P unconditionally in every window;
scope resolves at the moment of the action instead (board, then card,
then a polite "Nothing to Print" / "Open a board or a card to print
it." refusal in the app's own voice). The boolean isEnabled(hasBoard:
hasPrintableCard:) becomes a three-way PrintCommand.resolveScope(...)
-> Scope pure function.
Also implements AppDelegate's application(_:printFiles:withSettings:
showPrintPanels:) — Finder's own File ▸ Print… / drag-to-printer /
print-and-open path was previously unhandled, its own separate route
to the same stock alert. PrintCoordinator.printFiles loads each path
headless through BoardLoader (no store, no window) and either prints
it or gives the same one-sentence refusal; the operation-building code
shared with the in-app path is factored out of run(_:) into
makeOperation(for:showsPrintPanel:) and runOperation(_:session:).
Docs: 11-command-nexus.md's Print row, PrintCommand's and
PrintCoordinator's doc comments, KanbanApp.swift's CommandGroup
comment, and project.yml's entitlements comment all narrate the
entitlement as the actual fix and the scope work as hardening beside
it.
Tests: PrintCommandValidationTests now exercises resolveScope's three
arms in place of the old boolean. A new PrintFinderResolutionTests
suite covers PrintCoordinator.resolveFinderPrint(atPath:) — the one
piece of the Finder half a test can drive without handing AppKit a
real print job — against a real board, an empty non-board folder, a
plain file, and an unsupported schema.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
A card whose `hero:` names one of its own attachments draws that picture as a
banner across the full width of its plate, above the icon-and-title row,
aspect-fill cropped into a fixed 2.75 em band — 36pt at the standard body, and
em-scaled like every other figure the board draws, so it grows with the system
text size and with the board's zoom rather than shrinking against a title twice
its usual size. The figure sits deliberately under the 44pt a plain one-line
card is tall: a hero card should read as a card with a picture on it rather than
a picture with a caption, which is 03's standing rule that the title dominates.
The key's grammar is a **bare filename**, and that is what separates it from the
board background's `image` subkey rather than a nervousness about paths. A board
names a file anywhere under its root, so a path is that key's reading and where
it leads is the renderer's question. A card names one of the files it already
owns — the flat `attachments/` folder the app lists, relocates into, and carries
through every move, copy, trash and restore — so `hero: art/sketch.png` is not an
awkward spelling of a hero image, it is a value the key cannot mean. It therefore
has no reading at all: a value carrying a separator, or spelling `.`/`..`, or
empty, is malformed at the document layer, which renders it as absent and leaves
the coerce tier's trace, exactly as `width: 1.5` does. The bytes stay as written,
the resolver re-checks containment anyway, and the whole degrade family below
that — a name pointing at a missing file, an unreadable one, or one that is not
an image — ends the same way: no banner, no defect, nothing written.
That last promise is about *height* as much as about ink, so the band is given no
height at all until a picture has actually decoded. A card whose hero cannot be
drawn lays out identically to a card with no key, structurally rather than by a
branch somebody has to remember; the price is one settle per hero as a board
opens, and none after that. Everything else the face draws is attached outside
the new stack and is untouched by it — the accent stripe still runs the plate's
full leading edge across the band's corner, the selection and file-hover strokes
still ring the whole plate, the cut and drag dims still cover it, and the drop
model still registers the plate's real height, so a hero card is simply a taller
card the masonry already understands. The trash draws it too, by the one-face
rule.
Decoding is ImageIO's downsampling path off the main actor at a quarter of the
backdrop's pixel budget (`BoardBackdrop.decode` gained the limit as a parameter
rather than being copied), and the results live in one app-wide, deliberately
non-observable cache keyed on path plus the file's date and size. Non-observable
because a tracked write there would invalidate every hero face on the board,
which is the O(board) invalidation this view was rebuilt once already to shed;
each face holds its own picture in view state and seeds it from the cache, which
is also what lets the drag replica — whose preview builder is non-escaping and
cannot await anything — carry the band at the face's real height. Taking a stamp
twice from one URL value turned out to answer with the first read's date and size
however many times the bytes had changed, so `stamp(of:)` now drops its cached
resource values first; noticing a replacement is the only thing a stamp is for.
The face takes the resolved URL as a compared input rather than resolving it, for
selected-ness's reason one axis over: resolving needs the card's folder, which a
face does not know, and finding it from the snapshot would be a board walk per
face. The lane and the trash column each know their own container and compute it
once for the whole strip.
There is no in-app setter this version — the key is written by hand or by an
agent, which is why the guide bumps to v13 with a clause spelling the grammar out
beside the other card keys, and why `attachments/` gets the one-line pointer an
agent that has just written `` will need. "Set as Hero"
from the attachment row is future work, as is the card window and print, which
draw the same model and show no banner today.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Lateral card navigation was pure geometry: the nearest drawn frame in the
direction. That loses the walk in the card's own title — stepping from a
10-card lane's 8th card into a 3-card lane clamps to its 3rd, and coming
back out, "the nearest frame at that height" is the 3rd card's height. The
information the user was walking at stopped being on screen, so no rule
over rectangles could have recovered it.
So it is remembered instead. `TransientBoardState.lateralOrdinal` holds the
1-based position a run of ←/→ started from, counted over the cards the board
is showing, and `NavigationMath.lateralHop` lands each hop on
`min(ordinal, target lane's count)` of the next lane that is showing cards —
collapsed and query-emptied lanes hopped over on `firstCard`'s rule rather
than by the accident of registering no frames. 8th → 3rd → 8th.
Every reset comes from one funnel and needs no enumeration anywhere: the
ordinal is a defaulted `nil` parameter on `select`, so a click, a marquee, a
↑/↓ step, an ⌥-jump, a ⌫ successor, a lane-domain arrow and the reload's
focus recovery all end the run by saying nothing. `resolve` adds the one
rule a value referencing no item can need — the ordinal never outlives the
head it was counted from — while a reload that leaves the cursor standing
leaves the run standing too.
A wide lane's interior masonry columns keep their spatial step and carry the
ordinal through untouched: a column hop is not a lane hop, and stickiness is
lane-granular over the logical order. With no lane in the direction the
geometry has the last word, which is how → still reaches the shown trash.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The lane header no longer draws a new-card button. Every other
creation path is untouched: File > New Card (Cmd-N), Return on a
selected lane, and double-click on lane empty space.
- LaneView: drop the button and its overlay slot, leaving the
collapse chevron as the header's one piece of trailing chrome.
- NewCardTarget: drop the button's click-names-target carve-out over
the Cmd-N target rule (Return on a selected lane is now the only
direct-target path left).
- AccessibilityPhrases: drop the button's spoken label.
- BoardMetrics: drop its width reserve; laneHeaderTrailingReserve now
reduces to the collapse chevron's own reserve.
- DESIGN/03, 04, 10, 11: update the lane title-bar inventory, the
Cmd-N override clause and search-clearing mechanism list, the
accessibility tree-shape sentence, and the pointer-grammar row that
described the button.
- Tests: drop the two pinned accessibility-label tests and the
newCardButtonReserve assertions; retarget the truncation-headroom
test at the now-solo laneHeaderTrailingReserve; fix stale doc-quote
comments and the manual accessibility-verification checklist.
Full KanbanTests suite: 2807 tests, 482 suites, all passing.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Card 737a949f: "Add an option to card context menu to copy a link to
the card folder." Implements the design ruling verbatim.
- New context-menu row "Copy Link" (CardFaceView.boardMenu, board
side only — trash cards are excluded, matching "sole selected live
card"). Writes the clicked card's folder in one pasteboard item
carrying two representations: the file:// URL under .fileURL and
the plain absolute path under .string (FolderLinkPasteboard.swift).
Enabled on a sole selected live card; disabled on a multi-selection
and wherever edit-shaped actions already disable, per the ruling.
Also exposed as a VoiceOver custom action alongside its siblings.
- Menu-bar twin: Board ▸ Copy Link (BoardCommands.swift,
CopyLinkCommand), no default chord — the every-function-a-menu-item
contract in DESIGN/11-command-nexus.md is still current, so this is
the twin that contract calls for, homed the way Open Card/Rename/
Style… already are.
- DESIGN/11-command-nexus.md: new Board-menu row and an updated Card
context-menu row.
- Tests (KanbanTests/CopyLinkTests.swift): the target predicate's
enablement (sole card / multi-selection / lane / trash / empty /
inline-editing), the pasteboard write's exact bytes via a fake
pasteboard (both representations, exact folder URL), and a
disabled-target no-op.
Caught and fixed during self-review: an early version read the
context menu's widened-selection helper (targetIDs) inside the
Copy Link row's .disabled(...), which reads store.selection. Since
.contextMenu's content closure is evaluated on every ordinary body
pass (not only when the menu opens), that resubscribed every card
face on the board to every selection change — the exact O(board)
regression RENDER-INSTRUMENTATION.md's isSelected/selectedCount
split exists to prevent, caught by BoardRenderPerformanceTests and
MarqueeRenderCostTests. Fixed by reading the already-hoisted,
non-Observable `selectedCount` parameter instead, which answers the
same "how many ride along" question at zero extra subscription cost.
xcodegen generate, the Kanban scheme build, and the full KanbanTests
suite (2816 tests) are clean.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
A lane folds to a fixed slim vertical strip carrying its glyph, its card-count
badge and its title turned on its side, and the strip is deliberately not part
of the window's division: the expanded lanes' units divide what is left once
each folded strip's fixed width has come off the top, so folding a lane is a
re-divide trigger of the Show/Hide Trash family — the window never moves and
the siblings grow into what the lane gave up.
The state is a first-class lane frontmatter key, `collapsed: true`, and
document state exactly as `width` is: the files are the board, so an agent
folds a lane by writing one key. Absent means expanded, expanding removes the
key rather than writing `false` (the remove-at-default family beside a
one-unit `width`, the empty rename's `title` and the None well's
`background`), and the lane's `width` rides along untouched so expanding
restores the lane the user had. The read is `width`'s leniency one type over —
a boolean scalar or a quoted boolean word reads as itself, everything else has
no reading at all and renders as expanded, bytes preserved either way.
Toggling is the header's always-visible collapse chevron, the lane context
menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on
the strip; a modified click on the strip stays the ordinary selection grammar,
so a folded lane is still selectable by pointer. The title reads bottom-up and
is justified to the top of the room below the strip's chrome (owner ruling
2026-08-08), truncating against the strip's own height.
While folded the lane draws no cards at all, which is what makes every
exclusion true by construction rather than by a guard per gesture: no card
face means no marquee target and no navigation frame, and no registered grid
means the masonry's drop zones have nothing to resolve against. What did need
code is the half that names absolute destinations — the option-arrow jumps and
the arrow seed scan past a folded lane, the lane domain's down-arrow is inert
on one, and New Card skips it (a selection inside one falls through to the
last-active lane, the stale selection's rule). A drop on the strip appends at
the lane's end, cards and Finder files alike, with an accent edge standing in
for the shadow the strip has no masonry to open; there is no hover-to-auto-
expand yet. Lane reorder works on the strip, and a dragged folded lane carries
its fold, so its shadow and its replica are the strip rather than its units.
The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket,
the same stamp behaviour, the same three do-nothing paths — with two new
`WriteOperation` cases and two new undo verbs rather than one of each, because
a banner or an Edit-menu row that said "resize" after Collapse Lane would name
a control the user never touched.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Diagnosed by sampling a live frozen instance: with a text surface focused,
RedoMenuRow's body reads the routed manager's title, NSUndoManager.canRedo
posts NSUndoManagerCheckpoint synchronously, UndoCommandTicker bumps its
observed revision mid-body, and SwiftUI schedules the re-evaluation whose
own read posts the next checkpoint — the main thread never returns to the
event loop (~99% CPU, app frozen). Board-routed reads never echo, because
BoardUndoManager's overrides answer from the provider without posting —
which is why the board-only live probe (21/21) never met the loop.
The rows now derive title and enablement inside
UndoCommandTicker.silencingReadEchoes, a synchronous main-actor window in
which bump() drops what arrives: a read cannot change the state it reads,
so the echo carries no information and dropping it loses nothing. Genuine
checkpoints — registration closing a group, a crossing — still land.
Three regression tests pin the mechanism, including the asymmetry that
made the redo side the fuel: canUndo answers silently, canRedo posts.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
⌘P had no story: KanbanApp removed the platform's Print row outright on
11-command-nexus.md's "No Print story in v1 (⌘P unused)" line. That line
retires. File ▸ Print… now prints the board in front — lanes left to right,
each lane's cards top to bottom, as a linear document rather than a picture
of the strip — or, from a card window, that card. The trash is unreachable
by construction: it is a sibling container of `lanes`, not a lane.
The rules live in a pure layer nothing AppKit can reach. `PrintOptions` is
one Codable value carrying the printing card's five bullets — which
components (title, icon+labels line, rendered body, comments off by default
with either reading order), page breaks, one base face and size every other
size derives from, and a toggleable running head and foot. `PrintSource` is
what is being printed, frozen at ⌘P so the panel's repeated relayouts and a
board reloading underneath cannot disagree. `PrintDocumentBuilder` turns the
pair into a block list, which is where every decision a rendered page hides
becomes something a test can hold: component order, comment ordering, and
page-break markers that are markers rather than whitespace. Empty is empty
all the way up — a card with nothing to print consumes no page break, and a
lane whose cards all dropped out takes its heading with it.
A page break is a pagination fact, not a spacing one. TextKit has no
page-break character, so `PrintDocumentView` splits the document into
sections at its breaks and flows each into as many page-sized text
containers as it needs: a container boundary *is* a sheet boundary, at any
paper size with any margins. Bodies come from the app's one Markdown pass —
`BodyMarkup.parse` into `BodyMarkupRenderer` — re-faced run by run so the
chosen family reaches the text and fixed-pitch code keeps its own, and drawn
under a forced light appearance so the card window's dynamic label colours
do not print white.
Options ride in the print panel's own accessory rather than a pre-flight
sheet of ours, which buys the system's live preview of the real paginated
document; the preview refreshes through one KVO revision counter rather than
thirteen mirrored properties. Profiles persist app-side in UserDefaults,
never in board files — a print profile is how this user likes to read, not
what a board is (`BoardZoomStore`'s argument). A name is a profile's
identity, folded case-insensitively; "Last Used" is reserved in every
spelling, kept out of the stored list, and captured when an operation
actually ran, so a cancelled print rewrites nothing. Both decoders are
total: one unrecognized key must not cost a user every profile they saved.
DESIGN/11-command-nexus.md gains the Print row and loses the sentence
saying it would never have one.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The card's frozen spec recommended Option C (install the agent guide at
board creation); the owner's follow-up comment extended that ruling to a
second axis — embedded guidelines should update whenever a board opens if
the on-disk version is older than Lanework's, which the Mac app already
does via BoardStore.refreshAgentGuide()/runScheduledHeals(). This card
implements both halves.
- BoardWriter.createBoard now calls AgentGuide.install(atBoardRoot:)
right after seedGitignoreIfAbsent, so every board — Mac- or
phone-created, since KanbanMobile.BoardIndexStore.createBoard calls
this same method — is born with a current-version CLAUDE.md, with no
dependency on a later open. Routed through AgentGuide.install itself
rather than a hand-rolled write, so never-downgrade, the
CLAUDE.user.md rescue, squatter displacement, and the EchoLedger
heal-attribution exclusion all carry over unchanged.
- BoardSession (KanbanMobile) gains a private refreshAgentGuideOnce(),
fired once from open() (already idempotent on the .idle phase),
fire-and-forget through the same CoordinatedFileAccess.write bracket
every phone write uses. Deliberately not a heal scheduler — a
one-shot courtesy check at session open, silent on failure (logged,
never surfaced to lastError or a banner), matching AgentGuide's own
"nothing here is a user-facing event" posture. The type's doc comment
now names this one exception while keeping "no heal scheduler" true.
- project.yml: lifted the KanbanMobile target's AgentGuide.swift build
exclusion (dating to the original mobile MVP, "agents work where the
Mac app runs") — both changes above fail to compile on the phone
without it, since the type simply wasn't in that module. Verified
safe: AgentGuide.swift imports only Foundation, and its one upward
dependency touches only EchoLedger's unconditional recording API,
never the #if os(macOS)-gated consumer surfaces.
Tests: KanbanTests/BoardWriterTests.swift gains
createBoardInstallsTheCurrentAgentGuide, calling createBoard directly
and asserting the guide lands at AgentGuide.version immediately — the
card's own Done-when, and also the phone's creation-time coverage since
it's the same call site. KanbanMobileUITests/AgentGuideUITests.swift
covers the open-time refresh itself, the one piece only reachable
end-to-end from a running KanbanMobile process (no mobile unit-test
target exists): the bundle's fixture board already carries no
CLAUDE.md, so tapping into it and polling disk proves the wiring with
no fixture changes needed.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The card window sidebar's Style section embedded StyleEditorView symbols-only
(showsBackground: false, showsSymbols: true), drawing the curated grid whole
inside the sidebar's own scroll view — a permanently open 5-7 row grid ahead
of every other section. Replace it with the reusable compact SymbolPicker
(already used by BoardInfoPopover for the board's glyph): a single well at
rest, the grid only inside its own popover.
- CardStyleSection no longer instantiates StyleEditorView at all. A new
"Symbol" row mirrors the existing "Background" row's inspector shape
(caption leading, control trailing), wiring SymbolPicker's onSelect/
onSelectColor to icon/iconColor through StyleCommand.apply(...on: undo),
the exact funnel the background combo already rides — so the card window's
undo session semantics (13-native-undo.md) are unchanged.
- The picker is fed CuratedSymbols.available rather than its own smaller
general-purpose default, so a card's curated vocabulary doesn't shrink.
- StyleEditorLayout.sidebar and showsBackground stay in StyleEditor.swift
(still correct, still tested) rather than being cut as dead code — a
separate, larger cleanup this card doesn't make (recorded on the card).
- DESIGN/03-board-ui.md and DESIGN/05-card-window.md: updated the sentences
describing the sidebar hosting the style editor's symbol grid to describe
the compact picker instead.
- Tests: CardSessionUndoTests gains a symbol/tint analogue of the existing
background-combo session-routing test; CardSidebarTests gains a curated-set
tripwire and an updated file-header note.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Comments have been live since the 2026-07/08 build-out (thread column, composer,
.draft, comments/.trash on Mac and mobile), but 01-storage-format.md's Enhanced
schema heading and opening clause still called the feature out of scope. Surfaced
by the done-when audit of the Pipeline card 'Revisit comments — available in all
versions, not teams-only'. Heading, intro, and the comments bullet's parenthetical
now state shipped reality; tracker keys (labels/assignees/due, remote/remote-state)
stay reserved as before.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The right-edge drag's growth was capped at the screen's visible frame,
because each snap tick grows the window; on a window near the screen edge
that left a lane stuck at a tick or two of headroom. Settled 2026-08-08
(03-board-ui.md § Lane, superseding the pathfinder's hard stop): at the
screen the window pins and each further tick re-divides the fixed strip
width across one more unit — siblings compress, the stepper's mechanism
arriving under the drag's fingers. The regimes meet with no pixel jump
(the re-divided standard at the fit IS the frozen standard, by the
exact-fill identity), shrinking mirrors the way back, the rubber band
moves to the strip's own capacity, and a window with no headroom at all —
full screen included — re-divides from the very first snap.
New pure arithmetic in LaneLayoutMath (pinnedStripWidth, resizeStandard,
resizeMaxUnits, resizeWindowDelta, snappedUnits over per-count slots);
LaneResizeSession splits the tick across the regimes and derives its
standard from the live count; the handle and BoardView hand the session
the strip's whole divide. 2709 unit tests green (+11).
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
PRODUCT_NAME: Lanework and PRODUCT_MODULE_NAME: Kanban were the old repo's
arrangement and fell out in the git excision, leaving Debug builds to register
themselves with Launch Services as Kanban.app. KanbanTests points TEST_HOST at
the real product, since XcodeGen derives it from the target name and would miss.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Edit ▸ Undo/Redo become the app's own replaced rows and the board toolbar
pair takes explicit targets, both reading the focused session's
BoardUndoManager through FocusedValues.undoStack (board windows publish the
session's manager, card windows their own) — the nil-target route died with
the SwiftUI window latch, 13-native-undo.md ▸ Rules ▸ command surface,
re-ruled 2026-08-08. The rows enact the routing predicate themselves: text
focus routes ⌘Z to the first responder's own manager, title and enablement
included, re-derived at fire time with a beep for the stale window.
NativeHistoryProvider turns @Observable so both surfaces re-derive on stack
changes; a checkpoint-notification ticker covers plain text managers.
.responderAction leaves ToolbarItemSpec with its only user;
windowWillReturnUndoManager stays wired for AppKit's own asks.
Live-probed on the fixture board (21/21): the row retitles to "Undo Add
Lane" and crosses via real ⌘Z key events, ⇧⌘Z redoes via a window-server
chord, the toolbar pair validates and fires, search-field and body-editor
⌘Z stay text undo with board stacks untouched, and a card window crosses
its own stack with no fall-through. 2698 unit tests green.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The comment thread renders read-only under the card's body (author line, Markdown body through CardBodyView, read-only paperclip rows), read outside the snapshot and re-read on every walk landing via BoardSession.snapshotGeneration. Add Comment posts through the Mac composer's own draft-then-rename bracket — seeding from the card's single synced draft so a thought started on the Mac finishes here — and each row's context menu opens the same sheet in edit mode. Both commit on their trailing button or not at all: the phone's transactional model, dirty-Cancel confirmation and swipe-dismiss disabled while dirty included. UI-tested end to end with disk assertions.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The read view's inline-only AttributedString shim gives way to a real
block renderer over BodyMarkup — the same parse the Mac Preview trusts,
whose swift-markdown dependency project.yml said was already riding
along for exactly this surface. Headings, nested lists with static task
checkboxes, quotes, GFM tables with per-column alignment and clamped
colspans, sideways-scrolling code blocks, literal HTML, dividers, and
tappable absolute links (relative ones stay prose — no folder to
resolve against). The title block sheds its inner padding so its
leading edge sits flush with the body; the tint now grows outward via
a negative background inset instead of pushing the text in. One rich
fixture card body enriched to exercise every block kind; Mac fixture
round-trip suites verified green against it.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
NSWindow latches its own empty undo manager during SwiftUI window creation,
before any delegate of ours can answer, so the nil-target route 13 assumed
never reaches the session's manager. Ruled today: Edit ▸ Undo/Redo become
the app's own replaced rows and the toolbar pair takes explicit targets,
both reading the focused BoardUndoManager; the routing predicate relocates
to the command layer unchanged; the delegate hook stays wired, unrelied-on.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
CardDetailScreen renders title, inline-Markdown body, and a quiet dates
footer; all writing moves to the new CardEditScreen, a full-screen cover
with segmented Details/Body panes. Drafts commit in one perform bracket
on Save only — Cancel guards dirty drafts with a discard confirmation,
and a failed write keeps the sheet and its drafts and raises an alert
instead of dismissing. CardAttributesSection becomes a pure
binding-driven editor with no write path of its own. The UI test walk
crosses the new split, with body-pane and discard coverage, and a
deterministic replaceAllText helper retires the flaky ⌘A select-all.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The sort control stays on the board list alone; lanes and cards return
to the arrangement the ranks state, long-press reorder now ungated
because the rows always show the order a drag would rewrite.
ItemSortOrder leaves with the pickers that needed it.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The excision's follow-up sweep: present-tense prose that still implied an edition axis now reads
correctly under one version ("in every tier" clauses dropped or turned substrate-shaped, the
announcer's "every free-tier bracket today" is "every bracket today" — nothing passes a phrase),
and forward-looking promises pinned to the mooted pro-m1/pro-m2 milestones now name the thing
itself (the change narrator in Kanban/Changes/, the foreign-change journal successor) or fall to
past tense. Kanban.entitlements' network-client comment sheds its "dormant until Pro ships"
framing for the pivot's own reasoning: the key stays because the sync capability to come needs
it regardless. Untouched on purpose: the storage layer's coerce/tolerate/refuse tiers, the
chooser's bundled/user/keyless tiers, verbatim design-doc quotations, and genuine past-tense
record. One dangling reference repaired en route: EndToEndVerification.md cited the long-renamed
InertGitTests. Comment-only throughout; 2,686 unit tests green, unchanged.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
An onMove on both lists, live only while the sort picker says Manual:
the drag mints a midpoint rank between its landing neighbours through
BoardWriter.moveItem's same-parent reorder path, and when a gap is
exhausted the whole container renumbers to a fresh 1024 ladder in one
perform bracket. Neither path stamps modified, so a reorder never
disturbs the Recent sort. UI suite 4/4 in the simulator.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The 2026-08-08 one-version ruling (12-editions.md ▸ PIVOT 2026-08-08) carried out: Kanban/Tier/
deleted wholesale (Tier, ProEntitlement, ProProducts, ProStorefront, the never-rendered
ProSettingsSection) with TierTests and Configuration.storekit, whose project.yml resource entry
and scheme storeKitConfiguration go with it. AppModel loses the entitlement, the currentTier
seam, BoardSession.tier, and the purchase flow's reopenOpenBoards (its only caller was the
storefront); AppDelegate's launch keeps only the appearance application. The three tests
pinning the recorded tier and the reopen are deleted with their subject. The network-client
entitlement stays — the sync capability to come needs it regardless — and the HistoryProviding
seam stands untouched. 2,686 unit tests green (2,707 minus the 21 that tested what left).
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The boards stack becomes the root screen: Settings moves from its own
tab to a sheet behind a leading gearshape button, the sort pickers trade
their text segments for symbols (hand.draw, textformat.abc, clock) with
the words kept for accessibility, and every list now opens sorted by
most recent change. UI tests re-aimed at the gear button and Done-button
scoping fixed for the sheet-over-sheet nav bars — 4/4 green in the
simulator.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
User-ruled 2026-08-08, after the git excision: the base/Pro split is mooted for now. The charter's anchors collapse to a single version (monetization workstream parked, the generosity tension resolved by construction), 12-editions.md carries the pivot note naming what the companion excision removes (Kanban/Tier/, TierTests, Configuration.storekit and its wiring; the network-client entitlement stays for the future sync service), and the README's tiers bullet becomes the one-version statement. Pro returns with the iPhone + ops-based sync capability as a fresh design pass — a reintroduction, not a resurrection. The code excision itself is handed off.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Step 8's verification record: 2,707 unit tests green at close (the golden fixture suite among them, untouched — no git fixtures ever existed); the UI test targets compile; the keeper behaviors — a board beside a real .git opened, watched, and healed byte-identically — are pinned by GitAgnosticStorageTests, FolderWatcherTests, and the gitignore/relocation suites, all green. The accessibility audit could not run to a verdict in this environment (its AX sweep flagged another app's fullscreen video element — the same failure on surfaces the excision never touched); the popover audit's own screenshot shows the strip as ruled: Info, Theme, Sync.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Same segments, same persistence; the picker now rides each screen's
toolbar as a bottom-bar item shown only when there are rows to reorder,
instead of masquerading as the first row of the list it sorts.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Boards, lanes, and cards all show their frontmatter icon leading the
row, tinted through the palette with a secondary fallback; the board
scanner now reads icon and iconColor from the same one-file parse as
the title. Lane and card lists gain the segmented Manual/Name/Recent
control — display-only sorting layered over the rank order, never
rewriting what's on disk.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Step 7 of strategy/01-git-excision.md, the companions. The agent guide bumps to v11: the Git section teaches repo-resident etiquette alone (stage only your own paths, commit your own changes, leave app-maintained files to the app) — existing boards heal to the new text on next open. README re-anchors: the four git feature bullets out, tiers say the complete Mac experience is free, and one bullet states the format's git-friendliness promise. The changelog drops the never-shipped git entries. DESIGN re-rules: 06 retired with Undo routing migrated to 13 (now the sole substrate's doc, seam kept open), 07 retired as written pending the ops-service workstream, 14 retired as superseded record, 12 carries the second pivot note, the index reflects all of it; the charter gets a pointer note (the anchors' full re-ruling stays with the user). InertGitTests renames to GitAgnosticStorageTests — the excision restores its original claim app-wide. And the sweep: ~70 comment sites across 36 files adjudicated against the keeper list, every present-tense description of the excised machinery made past tense or repointed, keepers untouched. 2,707 tests green.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
A segmented control above the mobile board list, remembered across
launches: Name keeps the index's stable title order, Recent sorts by
content-change date descending with undated boards last and ties held
stable against refresh reshuffling.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Step 6 of strategy/01-git-excision.md, the proving step: the two packages and their rationale blocks (including the stale never-runs-in-the-free-app note) come out of project.yml, the project regenerates, and the build resolves clean — nothing living referenced libgit2. Resurrection pins (SwiftGitX 0.4.0, libgit2 1.9.2) are recorded in the plan and at the pre-git-excision tag. 2,707 tests green.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Step 5 of strategy/01-git-excision.md: the seventeen dead engine files and the five remaining git test suites go (InertGitTests stays — the naming footgun is a Storage keeper). Two rescues ride ahead of the delete: HarvestedReceipt relocates to EchoLedger (the harvest surface outlives its git consumer; foundation for the deferred journal), and commentTimestamps joins the narrator it always served. The provider-swap purge test re-expresses over a git-free fake; the duplicate-id ladder keeps every pure historyRank pin and loses only the two ranker-driven ones. Resurrection point: tag pre-git-excision. 2,707 tests green.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
IndieBackup comes out whole: the package, the Backup directory, the Settings section, the .kanbanbackup document type and its onOpenURL restore path. Settings is now the About section alone. The Files-app keys stay — they serve local-board visibility, not backups. Board sharing as a zipped archive is the planned replacement and is deliberately not started here.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Step 4 of strategy/01-git-excision.md, the entangled one: AppModel's makeHistoryProvider collapses to the native provider (the seam stays injectable per the reversibility posture), the session's git state and its wiring go (wireGitUndo, wireBranchSwitching, the card-session staging threading), BoardStore sheds commitSeam and the identity-history ranker (the loader's nil-safe rung now tops out at birth date — today's no-git behavior), SessionSettleGate keeps the gate and inherits the path utility it borrowed, BoardRegistry drops the persisted operation stamp (decode-safe), and the git banner family leaves BannerCenter with its announcer and accessibility phrases. One missed harvest tie severed (the narrator's root subject is its own now). Nothing outside Kanban/Git/ references the stack — proven by sweep. 2,855 tests green.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The Mac set's 1024 master is full-bleed square artwork already, so it carries over whole: alpha-flattened (App Store icons must be opaque) into a single-size iOS AppIcon set, and the target points at it.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
A trailing swipe on a board row opens Board Settings, whose first setting is location: iCloud or Local, with a confirmed move to the other side — destructive-styled only outbound, because leaving iCloud is the direction that sheds protection. The move is setUbiquitous against the real container and a coordinated move under the DEBUG stand-in; evacuation sweeps materialization first and refuses honestly while content is still downloading. The local home is the sandbox Documents folder, published to the Files app, so a local board is still a folder the user owns.
With a second home the iCloud wall softens (user-ruled 2026-08-08): the index always reaches ready, cloud unavailability becomes an inline notice with a retry, creates land locally when there is no account, and LANEWORK_FORCE_NO_ICLOUD makes that state reproducible in tests regardless of the machine's sign-in. Known gap, now user-reachable: backup remains iCloud-only, so local boards sit outside it.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Step 3 of strategy/01-git-excision.md: the popover strip is Info/Theme/Sync, the titlebar widget says the name alone, the card window's History section and its slot go, Board ▸ Pull/Push comes out with the RemoteCommands scaffold, and View ▸ History re-tags from the commit trail to the deferred foreign-change journal. The Sync placeholder re-annotates to the future ops-based sync service. Four git test suites leave with the surfaces they pinned (BoardGitSetup, BoardInfoPopover, BranchSwitch, GitUndo). The git engine still compiles underneath, unreferenced by UI. 2,890 tests green.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Step 2 of strategy/01-git-excision.md: CommitMessageEngine and the composer seam relocate to a neutral module renamed away from commit vocabulary (ChangeNarrator, ChangeNarrationRequest, ChangeNarrating, SemanticChangeNarration, ChangeAuthorship), GitChangedPath extracts from GitCommitOperation as ChangedPath, and the one git tie severs — authorship's foreign case carries a display name, not a GitIdentity. The spec tests transplant as ChangeNarratorTests, alive until the journal work begins. 3,009 tests green.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Under parallel-build load the six-turn settle let the first click land before the masonry's measurement passes did, on the empty-space layer instead of a card — which the dragless layer holds forever in a sterile queue, reading as a dead click. Bisect-verified environmental (same failure at every commit back to the suite's birth); with the settle, click → selection in 41 ms.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The mobile app's first runtime coverage, and its first runtime, full stop: the suite launches the real app over a scratch directory seeded from rich-board.kanban via LANEWORK_LOCAL_ROOT, walks boards to lanes to cards to the card editor, retitles a card and polls the frontmatter on disk until the write lands, then taps through the Settings About section to see the changelog and license actually render. No iCloud account, no metadata query — the DEBUG override is the whole harness.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The About section lands last in the mobile Settings form, mirroring the Mac AboutBox voice: license link, copyright, and a version label that doubles as the changelog link. The changelog is the phone app's own — a separate product with its own 1.0, so the Mac CHANGELOG.md would narrate someone else's releases. The Update Build Info stamping phase rides along so the version line can carry a build number and date.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The dragless empty-space layer's clicks are held forever in a sterile synthetic queue (and injected micro-motion deadlocks AppKit's tracking loop), so the empty-space pins leave the suite; their truth is established on real event streams (2026-08-07). What remains pinned is the original defect's surface: instant card selection, double-click opens without a placeholder, right-click reaching its menu.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
research/08-competitive-analysis.md profiles the direct set (features, pricing, traction, matrices, ranked threats); strategy/01-git-excision.md carries the 2026-08-08 ruling to excise app-managed git, with keepers, harvest, delete list, and sequencing. Both indexed.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
A second product, not a second edition: dev.rzen.indie.KanbanMobile (iOS 26,
iPhone-only) compiles Kanban/Storage as source files, so a format change that
breaks the phone breaks this build the day it's made. Boards live in
iCloud.dev.rzen.indie.Kanban — named after the Mac bundle id so the Mac app can
adopt the container later without a migration; until then the folder is
"Lanework" in iCloud Drive and the Mac opens boards there through the open
panel.
EchoLedger grows #if os(macOS) gates around its three consumer surfaces
(verdicts/BoardDiff, harvest/HarvestedReceipt, comment retirement/CommentPath)
— the recording side BoardWriter stamps compiles on every platform, and the
gates are the seam a future phone verdict surface lands behind. AgentGuide
stays Mac-only.
The phone's watcher is NSMetadataQuery: BoardIndexStore (one query, package
UTI export makes a .kanban directory one item, equality-gated rescans,
download kicks per pass), BoardSession (materialization sweep before every
fail-fast walk, NSFileCoordinator brackets, perform{} = coordinated write then
awaited reload, ParseMemo threaded), CloudHome (off-main container resolution,
LANEWORK_LOCAL_ROOT DEBUG override for simulator work without an account).
Screens: Boards -> lanes -> cards -> card editor, value-routed by ItemID with
every screen re-reading the live snapshot; leading swipe moves a card via
confirmationDialog, trailing swipe sends it to .trash/; the editor commits
title through the Mac's canonical rename path and body through writeBody, with
drafts that survive reloads; attributes are the three typed style fields
(icon, iconColor, background) — labels is a reserved unknown-field key and
deliberately has no editor. Settings carries IndieBackup (backup root = the
container's Documents, restore rebuild = an index rescan, controller
constructed only once the home resolves).
Arbiter: KanbanMobile green for iOS Simulator, Kanban green for macOS, 3006
unit tests / 517 suites passed (PointerLatencyTests excluded — mid-rework
uncommitted in a parallel session).
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
BoardInfoTab gains .sync last in the strip, rendering BoardSyncTabView's one
honest caption. The tab claims the position where 07's remote half will
live without ruling where its setup surfaces land — that stays the open
Redesign card's question.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The titlebar widget becomes a two-line identity block: the board glyph at
22pt spanning both lines, the title over the branch (git-mode only, smaller
and secondary), the em-dash retired. New Branch… returns to the switch menu
behind a divider, revealing an inline name field — the pre-split shape. The
board settings sheet retires whole: add-git and commit identity render
inline in the Git tab's postures (BoardGitSetup.swift), the availability
rule collapses into BoardGitSetupSection.resolve, and Board ▸ Board
Settings… leaves the menu bar. Where 07's remote/credential setup surfaces
land is deliberately left open — filed on the Redesign board.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settings loses its Lanework Pro section and the About box its Pro line —
no surface names or sells Pro while the split is re-decided. The machinery
underneath (ProEntitlement, ProStorefront, ProSettingsSection, the
recorded session tier) stays compiled and tested, documented as dormant
since the pivot. CHANGELOG's two 2.0 entries and README's feature list
drop the Pro-gated framing while keeping the facts.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
HistoryStore.compose(boardRoot📒) returns non-optional and runs for
every session — the nil the gate produced was the only nil it ever had.
makeHistoryProvider is a one-axis decision: git-mode boards bind the git
provider, everything else native, in every tier; Session.tier stays
recorded, dormant. BoardGitSection shrinks to the four mode postures
(.absent and .proPointer die, BoardGitNote and the .git probe with them);
every board carries all three popover tabs (BoardInfoTab.available
retired); the titlebar branch shows on any git-mode board; the settings
sheet and card History section stop reading tier. InertGitTests is
repurposed as UntouchedGitTests — the file layer still never opens .git,
now load-bearing for mode-none boards. The accessibility audit reaches the
settings sheet at last: the fixture board hosts it in every tier, so the
free-fixture disabled-row test becomes an open-and-audit test.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
12-editions gains the dated PIVOT note: git integration is tier-independent
— every tier composes the git stack on git-mode boards — because the
base/Pro split is being re-decided and git isn't going to be it. The
subscription machinery stays built and tested but dormant; the free-only
postures (inert .git, the Pro pointer, the absent Git tab) are retired.
What the pivot does not change, recorded in 12 and 13: git stays opt-in
per board — creating a local repository is the user's choice, never
auto-initialized — and boards without app-managed git keep conventional
native undo/redo. 06 rescopes to every tier; 03's Git tab reads its
same-day membership ruling through the pivot (the strip still asks the
posture, it just never hears absent).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
With branch creation relocated to the settings sheet, a single-branch
board's switch menu opened onto nothing and read as broken (ruled
2026-08-06, built with the Git tab): a bare Text — AppKit's standard
disabled item, read by VoiceOver as disabled text — now says "No other
branches" where the switch entries would be. The filter behind it becomes
the pure switchTargets(branches:current:) seam, pinned by
BranchSwitchTargetTests: current excluded, nil current passes all through,
repository order preserved.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The popover's third tab settles (03 ▸ Git tab, 2026-08-07): BoardGitTabView
takes the parked section — the six-posture matrix, the Pro pointer, the
nested/unverifiable prose, the branch controls, the Board Settings… door —
with the tab label doing the old "Git" header's naming work, and one new
caption for the no-repository posture. BoardInfoTab.available drops the Git
tab exactly where BoardGitSection resolves absent, carrying 12's
absent-no-placeholder rule up to the tab strip; selection resets to Info per
open, now a ruling. Daily face only — the remote block still joins with
pro-m2.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
iconColor stops being hand-written-only (user-ruled, superseding 03's
"schema yes, control no"): it rides StyleCommand.apply → applyStyle as
the third styled dimension — per-dimension no-op skip, one bracket, one
history step, ExpectedField.iconColor for staleness. The SymbolPicker
grows an opt-in colour row (leading None plus seven Palette.foregrounds
hues, None removes the key); the board popover is its one caller. The
window-title widget now draws the board's resolved glyph in that tint
beside the name. Doc realignment filed on the Redesign board (Minor).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
A selection change re-ran every CardFaceView on the board (180 bodies ≈ 85 ms on
the 6×30 fixture, 515 ≈ 233 ms on a real 515-card board, debug): the face's body
read store.selection in three places — isSelected, the drag replica's count, and
the context menu's styleTarget — and Observation invalidates every reader of the
property, past the equatable gate entirely. The band overlay stayed cheap, which
is why the marquee tracked the cursor while the highlight lagged ~0.4 s behind.
Now LaneView and TrashLaneView hoist one selection read per body and hand each
face isSelected/selectedCount as compared parameters; StyleMenuItems takes its
target as a deferred closure; TrashLaneRowView gains the same treatment plus the
Equatable gate it never needed before. Select-one-card: 180 bodies → 1. A
growing band costs the selection's own running size; the real board's crossing
fell 233 → 112 ms — the remainder is lane bodies re-measuring their masonry, a
separate lane-level finding recorded in RENDER-INSTRUMENTATION.md.
Also: select() gains defaultsSoleMember — the marquee's explicit nils never
avoided the sole-member default, so a one-card band acquired a selectionHead and
could scroll the lane out from under its own drag.
MarqueeRenderCostTests pins the shape: redundant samples cost zero bodies, a
growing band pays per crossing, and selectionStillRepaints holds a ≤8 budget.
Model facts off the live snapshot (Lanes, Cards with the trash's freight as a
quiet tail, Attachments — the welcome-count live-only rule) and disk facts off
one background whole-folder walk (File, Files, Size, Created, Modified — .git
and .trash included, so the rows agree with Finder's Get Info), with a Reveal
in Finder link as the door to the folder the rows describe.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The symbol/name header keeps the top; below it a segmented tab bar replaces
the styling embed and the git section, both parked in-file — their seams are
settled, test-pinned design, and the tab sessions rehome surfaces, not rulings.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Measured on real events 2026-08-07, correcting the 2026-08-06 hosted
finding: a bare count-1 tap on LaneView's empty-space layer fires in
~1-3 ms with no drag source at all — the hold that made the empty
.onDrag look necessary was the sterile NSApp.postEvent stream
over-disambiguating. And the provider was actively harmful: even an
empty drag source claims the mouse-drag at threshold, starving the
marquee's simultaneous DragGesture after one sample — the band froze
and the mouseUp never arrived. The layer goes dragless; drags from
empty space belong wholly to MarqueeControl. PointerClick's and the
layer's comments retell the corrected story. Alongside: openCard is
typed @MainActor throughout, which makes the closure Sendable and
lets CardFaceRole carry it under CardFaceView's nonisolated ==.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
View ▸ Appearance (11-command-nexus.md): three radio-exclusive rows,
app-wide, persisted, needing no window in front — the View menu's new
last group. AppearanceStore owns the override's rules (absent key =
Auto, lenient reads degrade to Auto, remove-at-default) with an
injectable apply seam so test hosts never touch NSApp; the one real
apply hands NSApp.appearance its answer in applicationDidFinishLaunching,
the global side effect KanbanApp.init must not carry. The board toolbar
gains its first .picker item — an NSMenuToolbarItem whose rows re-fetch
their spec fresh, checkmark read at menu-open like every other menu row —
and Appearance joins the search field as the second default item,
centered beside it (03-board-ui.md ▸ Toolbar, ratified 2026-08-07).
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
.claude/agents/opus48.md: a general-purpose implementation agent pinned to
Claude Opus 4.8, the capacity fallback for complex coding tasks dispatched
from the main session. settings.local.json — the personal permission
grants — joins .gitignore rather than the history, per its name.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
DESIGN/explorations/board-backgrounds: working material, not a numbered
design doc — the app side of background images is settled, so the
deliverable is purely artwork. The model is base layer × filler layer,
swept across hue strategy, HSB family, filler shape, density, placement
and opacity; the gallery is one self-contained HTML generator, every
swatch procedural SVG off a seeded PRNG so swatch IDs stay stable
references across reloads. Status: first sweep published, awaiting review.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
strategy/: the release-and-distribution effort, sibling to research/ but
different in kind — research gathers evidence, strategy turns it into
positions and plans. The charter records the goal (bootstrapped
sustainability with base→Pro growth in the personal garden first, Teams a
deliberate second act), the two tier anchors, what is already ruled, the
decision surface decomposed into workstreams, and the tensions to resolve.
The positioning line is the inversion: the garden's walls are around the
experience, not the data — closed polished surface, open documented
substrate, the exact inverse of the mainstream pattern and the moat
captive-state competitors cannot cheaply adopt.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
research/: long-term competitive-landscape research as a living body of
work — the kanban market, the adjacent personal todo/task market, the
file-based/local-first niche, and the AI-agent + task-management
intersection, the two axes the bet actually lives on. The first pass
(2026-08) is deliberately broad: a synthesis overview, the mainstream and
native/indie/file-based segments, the todo-adjacent field, cross-cutting
trends and success factors, the agents-and-tasks frontier, and a schema
comparison against Signboard. The overview carries the narrow-pass agenda.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
DRAG-PERF-ANALYSIS.md: a full read of the drag/drop pipeline against
DRAG-REORDER.md's model and RENDER-INSTRUMENTATION.md's measurements,
prioritized and sequenced, nothing implemented yet. The headline: the
per-sample arithmetic is microseconds on any realistic board, and the felt
latency lives almost entirely in the release — a 200ms watcher debounce
plus a whole-strip render pass, with one honesty bug able to stretch a
failed drop into a 1.5-second freeze. P1 is a surgical FolderWatcher
expedite for the echo the bracket already owes (~180ms off every drop),
with the races analyzed benign.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The swatch's padding goes asymmetric and user-tuned — a wider berth at the
sides than above and below, out of the swatch rather than the control's
size — so the colour reads as a bar in the field instead of wall-to-wall.
The field's fill moves to controlColor, the push-button neutral grey, whose
ring stayed legible where controlBackgroundColor's near-black dark-mode
reading drowned it; and the trigger square keeps its own two-point inset,
decoupled from the swatch's padding so the indicator stays a legible
square through any padding tweak the swatch takes.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The whole-lane wash-and-solid-ring scaled its emphasis with the lane's
area: what read as a ring on a card read as a slab on a column. Resettled
as the system focus ring's vocabulary at selection strength — a hairline
accent edge with a soft accent glow bleeding outward, riding the stroke
shape rather than the translucent plate (a shadow behind it would bleed
through as murk), and no wash at all: nothing tints the cards' backdrop,
and lane colour stays the accent band's alone. Under Increase Contrast the
halo yields to the solid full-alpha ring — "strengthens borders" means
crisper, and a blur is the one thing a border cannot become — so the
setting sees exactly the indicator it always has; cards and trash rows are
card-scale and keep the solid ring at every contrast. One selection
vocabulary, pinned by Accommodations.drawsSelectionHalo and its test.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
"At drag start it replaces the item's original space" is now true from the
very first frame: begin takes the run's own resting slot as a seed, set in
the same transaction that lifts the run out. Without it the vacated gap
closed un-animated and sprang back open at the first dropUpdated — a
shuffle carrying no information. The seed bypasses propose deliberately (a
pickup is not a new landing spot, so no alignment tick), and the first real
sample's re-propose of the same slot is the early-out's ordinary silence. A
⌥-pickup seeds nothing — a copy's resting layout keeps the originals in
place, so there is no vacated space to hold. Card pickups compute the seed
through the same LaneView.rendered rules the retargets count in; lane
pickups seed their strip slot the same way. DRAG-REORDER.md's own-slot
paragraph records the rule, and SeededPickupTests pins all four corners.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The measured defect: LaneView's empty-space double-click was a second
sequential .onTapGesture(count: 2) stacked over the single tap, and that
recogniser held every click in the lane — its own empty space and every
card face alike — hostage to the system double-click interval while it
disambiguated (~475 ms click-to-selection on a hosted board). The fix is
structural: the empty-space surfaces live on a background layer behind the
masonry, so a card's click never shares a gesture path with a lane
recogniser; one .onTapGesture branches on PointerClick.count (AppKit's own
clickCount, read the way ClickModifier reads the keyboard) — first click
selects, second creates, Finder's cadence with nothing to disambiguate; and
the layer carries a load-bearing empty .onDrag, because without a drag
source macOS holds primary clicks pending multi-click disambiguation
(measured: never fires alone, ~90 ms with one present). A measured viewport
floor makes each lane's blank space actually belong to the layer — a
ScrollView proposes nothing along its scroll axis, so only an explicit
minimum stretches the content — with the trash column as its twin, less the
padding that sits inside its scroll content. The template chooser's stacked
pair collapses to the same one-recogniser branch, and the attachment rows
move their double-click to a simultaneous gesture (instant there, because
the row's real .onDrag forces immediate delivery). PointerLatencyTests pins
the recovery with synthetic pointer events on a hosted board.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight
rungs, app-wide and persisted (the Show Comments precedent) — a viewing
comfort, not a property of any one board. The level travels as
BoardZoomContext in the environment, injected on BoardView alone so the
banner strip, search bar, sheets and popovers stay at the system size; the
environment is also what carries it through CardFaceView's equality gate,
which compares nothing that moves with the level. Every BoardMetrics figure
follows zoom.bodyPointSize — card and lane chrome, drag replicas and the
count badge, the resize handle, the trash column — and the drop registry
carries the ruler for event-time reads, with the autoscroller's three
reaches turning font-derived (reachSide named as the stripGap it always
equalled). Lanes still divide the window; zoom never moves the window or
its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the
menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung
announces itself to VoiceOver, and the render suite pins both invariants:
a rung repaints every face, a no-op Actual Size repaints nothing.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
background is {color:, image:} and only a mapping at every level; the board's image paints the full window under a transparent title bar, with a thin-material frost strip keeping the chrome legible and the standard accommodations intact.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Redesign card 6d5a97fd resolved off the 2026-08-01 live try-out: leading is
board identity, center is view controls, trailing stays the user's catalog.
centeredItemIdentifiers, not a flexible-space sandwich — centers against the
window, holds as catalog items install, reaches machines with a saved
arrangement; defaultItems and its pinned tests untouched. 03 records the
grammar and the accepted tensions (widget-vs-field budget in narrow windows,
HIG's trailing convention yielding).
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Four rulings close Redesign Contradiction 3452893f (2026-08-06): the in-app
escape hatch is ratified in full, reversing 2026-07-29's palette-only rule —
the combo's Other… opens the system Colors panel, a pick landing on a palette
color stores the name, anything else the hex. Free-picked colors change no
contrast story: they land on the same runtime ink computation hand-written hex
always got (10 amended to say so; no warning surface is owed). Anchor
ownership: the card sidebar's background story is the combo alone — the well
grid's background half stays with the other anchors (StyleEditorView gains
showsBackground beside showsSymbols; the popover's symbol half already went to
its inline SymbolPicker). Quick-style recents stay palette-vocabulary — a
panel pick never enters them.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
SymbolPicker: one well at rest, a 6x6 curated grid in a popover (leading
well = the level default, clearing the key), and an optional search over
the OS's full symbol inventory read from CoreGlyphs metadata. Geometry
font-derived off StyleEditorLayout's base, grid enlarged by a deliberate
1.3x. Wired inline with the rename field in the board info popover
through the StyleCommand funnel; the Title header retires. Curated set
and the search's standing vs 03's full-browser refusal await ratification.
Minor ruled into 04 ▸ Cross-board copy: the Finder volume model — a
mid-drag rename of the source board carries the live drag with it (the
drag holds its source store's identity, never a frozen key, which would
let a board opened at the vacated path compare equal — the worse,
destructive failure). Until the carry ships the residue is recorded:
key follows folder, a mid-drag rename finishes a reorder as a copy —
cosmetic, seconds-bounded. Implementation filed.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Minors ruled into 03 ▸ the git-at-a-glance section: a single-branch
board's switch picker opens onto a disabled "No other branches" row —
teaching why it is empty and where creation went — and the Pro
mode-none section stays as header plus the Board Settings… door rather
than vanishing, the git story keeping its named place.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Minor ruled into 02 ▸ the memoize ruling: a stamp-preserving rewrite is
a decision on file rather than a surprise in the field — git lives with
the same blind spot, the behavior is test-pinned, and the tightenings
(hashing, generation stamps) wait for a real-world defeat.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Minors ruled into 03 ▸ Toolbar: a space-constrained search item
collapses to a button still in the window and ⌘F expands and focuses it
— the strip fires only for a truly windowless field; and AppKit's
overflow row that widens the window is the platform's own second
answer, unsuppressable without destroying honest overflow — two
gestures, two honest resolutions, no contradiction.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Minors ruled into 01 ▸ The decision surface and Skip: (1) depth reveals
in rounds — a defective lane index leaves its subtree unwalked, so
deeper defects surface on the round after its repair, the collect-all
rule meeting an unreadable container; (2) Skip never names the root —
the case-by-case foreclosures are one general rule the loader polices,
a root skip entry ignored and the defect collected anyway; (3) the
skip's carrier is the defect's root-relative path — the string the row
displays and Reveal resolves, exactly what the user consented about.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Minor ruled into 05 ▸ Window: the no-title titlebar collapses AppKit's
title/subtitle pair as one field, so board › lane hides with it. The
window is usually beside its board; title and subtitle keep feeding the
Window menu, Exposé, VoiceOver, and restoration. The live-update wiring
stands — a sidebar placement row is the named candidate if a visible
surface is ever wanted; deliberately not built now.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Minor ruled into 06 ▸ Auto-commit: a reload in flight at bracket close
clears the gate without a started-after guarantee, and the committer
deliberately does not ask — the cadence margin makes the window
academic, the post-bracket reload is unconditional, and the deadline
already accepts a near-covering snapshot; the tree is what gets staged,
so the exposure is message precision, never content. Walk-start
bookkeeping named as the escalation.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Issues card ruled into 10 ▸ audits: the every-surface claim reaches
tier-gated UI via a launch flag honored only when isFixtureLaunch is also
set — the fixture already scopes the app to a scratch container and a
throwaway board, so the override grants Pro on a disposable sandbox and
never over real boards. Manual-checklist-forever would asterisk the
claim; debug-only would leave release unauditable. Partial surfaces pin
the free-reachable state and audit the full one under the override.
(Carries 10's in-flight zoom-control bullet from the parallel session.)
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
The pathfinder surface stack landed in e1ffe71 entangled with staged-index
placement drift: the replica's wash block sat inside replicaHeight (a CGFloat
has no .background — HEAD did not build), the body plate rode outside the cut
dim, the lanePlate property and the DESIGN bullet sat away from their kin.
Pure relocation, no new lines: every block moves to the anchor it was written
for. Verified by an isolated build of this tree.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Gap #12 ruled into 04 ▸ the trash-drop bullet: the refusal fall-through's
honest reading (the column declines to be a target, it does not cancel
the held drag) covers only promises the refusal leaves keepable — lane
shadows keep drawing and an ⌥-copy lands where they are. A standing
proposal naming the trash itself is the promise the refusal just broke,
so it withdraws: the drag goes proposal-less over the column, release
with no proposal cancels in agreement with the empty picture, and
lifting ⌥ re-asks at the same address. Implementation filed on the
Implementation board.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Gap #11 ruled into 02 ▸ One-way flow: between a write's bracket and its
echo the snapshot describes the past, so a guard deciding whether a write
would change disk baselines on the snapshot as amended by this store's
own in-flight writes — a pending-value record of the write path's own,
never the EchoLedger. Relative gestures (step one width unit) resolve
their base against the same amended truth, and the undo step's recorded
prior reads it too. The amendment dies with its echo; failed writes never
enter it. Coalescing weighed and set aside — one gesture, one write, one
commit. Implementation filed on the Implementation board.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Gap #10 ruled into 04 ▸ Search's single-source-of-truth bullet: zones,
shadow, and write all resolve through the same filtered list — the
position the shadow shows is the landing the write performs. The slot's
meaning in the full order is its visible anchor (after the visible
predecessor, before the visible successor at the lane top, true end in a
query-emptied lane; multi-card in proposal order beside the anchor);
hidden cards keep their ranks untouched, and the gesture's meaning
survives the query's clearing. The rendered space includes the new-card
placeholder. Code diverges today — implementation filed on the
Implementation board.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Gap #9 ruled into 06 ▸ Rules: a board-wholesale operation asked from a
form is both rulings at once, and the form rule wins while the asking
surface is up — create-and-switch answers inline in the sheet's Branch
section, a picker-asked switch at the popover's caption, never both at
once (the sheet dismisses the popover). The banner stays the posture for
whatever outlives the asking surface — the form rule's own fallback
generalized. Banner-only-everywhere weighed and set aside.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Gap #8 written into 02 ▸ FolderWatcher's reconciling bullet: the
value-equal skip's structural consequence is that walk-subject consumers
cannot key on the applied-snapshot generation, so the store carries two
counters — applied (the committed-overlay hold's event, unchanged) and
landed (every successful reload, equal or not; failures bump neither).
The choosing rule: anything outside the snapshot, or about the walk
itself, watches landed walks — the comment thread, the comment search
index, the auto-committer's covering gate.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Gap #7 blessed into 01 ▸ Malformed input ▸ Skip: the store retains the
open's skip set and every live reload's walk carries it, so a
still-broken skipped file never re-trips the reload-breakage banner. The
skip names a defect at a path, not the file — a fixed file rejoins
mid-session on its own, and a new defect at an unconsented path is
ordinary reload breakage. The notice stays the open's one-time residue;
consent lapses at close, and the next open meets the surface again.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
Gap #6 ratified into 01 ▸ Malformed input ▸ The decision surface: a root
that is gone/unreadable or is a file throws immediately as a single-defect
failure — nothing to walk, nothing a surface could repair or reveal — so
it retires to the welcome failure row on every open, attended or not.
Judged per failure, not per defect (the 31fee00 refinement, ratified): an
environmental defect among repairable ones still shows the surface, and
the vocabulary keeps the unreachable-root seat for the re-walk that meets
one under a standing surface.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
NSHapticFeedbackManager .alignment at propose()'s change edge — the
one funnel every retarget commits through, whose existing guards
(value-equal refusal, hold freeze) already make firings per-landing-
spot rather than per-pixel; withdrawal stays silent (losing a target
is not an alignment), and the external-file mode gets nothing. Force
Touch hardware only, silent no-op elsewhere; performanceTime .default
syncs the tick to the reflow the change triggers. The performer rides
an injectable seam beside holdTimeout for the same reason it has one.
Drag-perf card 11b85111 — the cluster's last.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
DragReplicaStyle spells the pathfinder's constant ambient shadow once
(black 16%, radius 2.5, y 1.5) and all three replica faces — card,
lane, trashed lane — wear it via one modifier; the ghost fans compose
from the same faces so every copy inherits it, dimmed by its own
opacity. 03 § Motion's "pickup lift (scale + shadow)" supplies the
scale from the system session but no shadow on the preview image — the
replica is the only place it can come from. BoardMetrics.replicaPadding
was already written to keep exactly this shadow from clipping and was
simply unused until now.
Drag-perf card 24397310.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Modifiers were sampled only inside resolveOperation, reached from
dropUpdated and the commit — and drop callbacks arrive only while the
mouse moves, so ⌥ pressed against a still pointer changed nothing
until the next twitch. DragSession now arms a local .flagsChanged
watch for exactly the session's lifetime (begin arms, end stops, the
watchdog guarantees end; ⇧/⌃/caps don't count, a settled hold
swallows) and publishes modifierGeneration; the three retargets record
which window's surface resolved the proposal (RetargetOrigin — weak
registry identity, never board root, so cross-board and two-windows-
one-board both answer correctly), and the hovered window replays that
same retarget with the operation re-resolved FIRST, since the index
space is a function of it. The monitor is injectable — the real one
needs a live event stream no test bundle has.
Drag-perf card d491e7d3.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
DragAutoScroller.run() consumes an AsyncStream of CADisplayLink
targetTimestamps from NSView.displayLink(target:selector:) on the
lane's existing anchor view (which was already the right NSView — no
new plumbing). The stream is the lifetime: cancellation ends the
iteration, termination invalidates the link, the link releases its
target. DragAutoScrollClock keeps the retired loop's two non-trivial
rules — first tick scrolls nothing, no tick integrates past 50ms (a
paused link resumes with the whole gap as its delta) — and the math is
frame-rate independent by test: one second at the edge moves 800.0
points at 120Hz and at 60Hz alike. Run-loop mode .common is
load-bearing: a drag runs AppKit's event-tracking mode.
Drag-perf card 2e08fd31.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
BoardRenderMetrics (DEBUG-only, the pathfinder's counter bag plus a
strip-body discriminator that tells a failed gate from a direct
Observation invalidation) counted at BoardView/LaneView/CardFaceView/
TrashLaneView bodies and MasonryLayout's callbacks. DragSignposts wraps
dropUpdated, retargetCards, commitDrop, and the commit-to-covering-
snapshot release pause; input latency reports honestly against
NSApp.currentEvent's mach base or labels itself base=none — no event
timestamp rides the drop path. BoardRenderPerformanceTests hosts the
real BoardView off-screen: a value-equal reload runs zero lane and zero
card bodies, a one-card edit repaints one card of 180. The lane-body
budget is <= laneCount with the headerInk chain documented and a
two-way tripwire that fails when the fix lands. Methodology in
RENDER-INSTRUMENTATION.md. Release build proves it all compiles out.
Drag-perf card a450ad09.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
LaneWidthHold: after the release the session's frozen standard and the
WRITTEN unit count keep governing the strip until a landed snapshot
carries that width back — dividing the already-grown window by the
stale unit total in between was the visible two-step. A deliberately
separate type from CommittedHold: that hold stands in for an
arrangement and any landing retires it; this one stands in for a value
and only a landing that carries it will do. Echo reads through
LaneLayoutMath.displayUnits so the width-1 key-removal case compares
right; the watch rides landedReloads so a value-equal echo still
answers; width writes now return whether bytes reached disk so a
refused or no-op write dissolves the hold instead of arming it; the
1500ms timeout family covers the rest.
Drag-perf card 231e3693.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Drops @Observable from MarqueeTargetRegistry (MarqueeSession keeps it —
its rect genuinely renders the band). The audit found no body read
anywhere: the begin guard and sample loop read from inside the drag
gesture, the arrows from inside a key handler, so nothing ever needed
invalidating when a frame moved — while every make-room reflow had each
sliding face re-firing onGeometryChange per display frame, each write
paying Observation registrar bookkeeping on top of the reflow's own
render work (the confirmed A/B culprit of 2026-07-31). Write-gating on
drag-active was rejected: a suppressed write never replays, leaving the
band and arrows navigating stale rectangles. A tripwire test pins the
registry against anyone re-adding the macro.
Drag-perf confirmed culprit, card eb7b75ce.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
RestingLayoutCache (session-scoped, @ObservationIgnored on DragSession,
cleared at begin and end) holds each lane's resting layout as ids +
heights — never [Card] — keyed on the hovered board's applied
snapshotGeneration, the registry's new heightsGeneration, the lane, and
the operation-following hidden set. The grid stays event-time on purpose
(autoscroll moves a lane's origin with the snapshot standing still), so
re-grounding rule 1 holds exactly: nothing survives a reload, only the
per-sample repetition goes. The steady-state cost of a hover is now the
containment scan plus four stores and a divide.
Drag-perf suspect #3, card b9f48fd1.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
LaneView and CardFaceView become Equatable and are instantiated through
.equatable(): the strip's body re-runs on every drop-proposal change, and
without the gates that rebuilt every lane and every card face on every
cursor move of a drag. The == compares value inputs and window-lived
collaborator identities; the closures BoardView rebuilds each pass are
deliberately excluded (BoardDropContext.isEquivalent / MarqueeControl.
isEquivalent / CardFaceRole.isEquivalent own that judgment). Observation
reads inside the bodies still self-invalidate — the lane under the drag
keeps re-running; the other lanes stop.
Ports the pathfinder's CardView/ColumnView gating pattern (drag-perf
suspect #2, card cbb6e476).
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
`DragLocality.isSameBoard` resolved symlinks on both URLs, which stats every
path component. It ran five-plus times per `dropUpdated` and again per lane per
body evaluation through `renderedCards` → `hiddenMembers` — order of 50–100
stat calls per mouse-move, and worse on iCloud-backed paths. A pickup stall
sample had it on ~47 of 943 stacks.
`BoardRootKey` mints that canonical spelling once, where the root is, and every
locality comparison downstream is an `==` on two strings. The drag carriers hold
keys rather than URLs, so re-deriving under the cursor is no longer expressible.
`rootURL` keeps the user's spelling — the folder name is the display-name
fallback, and canonicalizing there would visibly rename a board opened through
a pin. The key follows the folder through `relocate(to:)`: a key frozen at open
would collide with a new board opened at the vacated path, and two boards
comparing as one is a cross-board drag silently behaving as a move.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Ruled 2026-08-01: the width the user types in is the em-derived figure in
both of ⌘F's homes; the toolbar field's resting width is AppKit's, outside
the invariant — the transient strip never rests, so it has no collapsed
state to mirror. 03 also records the shipped grow-on-focus behavior beside
the default-item sentence.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The popover/sheet split lands: a board-scoped, titled, sectioned sheet on
the board window hosts everything setup-shaped, opened from the popover's
Board Settings… row and the new Board ▸ Board Settings… menu row. The three
existing setup controls relocate — add-git (whose noteFormVisible lines now
make the sheet the form-anchored failure surface), branch creation (a
standing field; create-and-switch runs the identical settle sequence), and
the commit-identity fields (the 2s visibility-scoped poll rides with them).
The popover keeps the daily face and its postures; its Pro/mode-none
section becomes header + door (.addGit renamed .noRepository). Availability
is derived from the section inventory (Pro + mode none or git), so pro-m2's
sections can't drift from the doors; the sheet's fields join the
caret-chord disable set. The audit suite pins what the free fixture can
reach; the sheet's own audit is manual until a tier override is ruled.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The loader gains a ParseMemo — the previous walk's parsed documents keyed
by root-relative path, trusted on the git-index heuristic (mtime + size,
no hashing) and passed as an input so the loader stays stateless. A hit
skips exactly one file read; schema, order, coercions, dedupe, and every
directory listing run fresh, so memoized and cold walks are output-
identical (golden-corpus equivalence suite). Entries record only past the
schema gate, so a defect can never be answered from the memo.
The store skips the snapshot assignment wholesale when the fresh model is
value-equal — no @Observable churn, no render pass, no snapshotGeneration
bump — and a new landedReloads counter carries walk-completion for the
three consumers whose subject is the walk, not the snapshot: the card
window's comment thread, the comment search index, and the auto-committer's
covering gate (which now counts a completed walk as covering even when
nothing changed). Warnings and defects move on their own equality; failed
reloads bump neither counter. An injectable ParseCounter makes the
single-file-echo claim a test.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The toolbar's search field now lives in AppKit's own NSSearchToolbarItem:
em(17) is the focused width (preferredWidthForSearchField), the resting
width is the item's own, and ⌘F becomes beginSearchInteraction — one call
that expands and focuses. Escape's empty-field exit collapses the field as
the keyboard leaves. resignsFirstResponderWithCancel is off so the staged
Escape survives the cancel button. The item ships its own live overflow row
and an above-.high visibility priority, so both custom-view workarounds
retire. The palette copy stays inert through an enablement round-trip the
item's searchField setter would otherwise overwrite.
The centering half of the card is blocked on a Design ruling (03 settles
"trailing") and is not built; the two-homes width question is filed.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Phase 3 of the decision surface, completing the card (01 ▸ Malformed
input, settled 2026-07-31). An attended open's fail-fast walk transforms
the loading window's content into one aggregated surface — never a
sheet, never a chain: defects grouped by class, each class stated once
with its files listed (Reveal in Finder + Open in Editor per row), a
class-level default preselected, per-item override behind a disclosure.
Only honest choices: YAML and malformed-schema get Editor + Re-check
(Skip below the root); newer-than-app gets Skip alone and blocks the
board at the root; the two root repairs — minted index, schema: 1 stamp
— are defaults. Repair and Open applies fixes in one store-less write
bracket and re-walks: clean proceeds, remainder re-aggregates into the
same surface. Cancel and ⌘W retire to welcome's row; restored opens
never see the surface at all (OpenOrigin rides the PendingOpen carrier).
Skips are per-open consent that rides the session — the store retains
the skip set and every reload passes it — and the opened board posts a
warning-tone notice naming what was left out, each item's Reveal riding
the banner strip's new reveal control. On Pro boards the repair bracket
binds its own EchoLedger, heal-marks everything, and the store adopts it
before the committer starts, so repairs land as one separate commit
authored Lanework Integrity — pinned end to end. Also fixed en route: a
retired loading window left its close interception installed and
returned false from windowShouldClose forever, blocking quit.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Phase 2 of the decision surface: the pre-snapshot loading state ruled
2026-07-29 (02 § Launch and window lifecycle), built. The board window
appears immediately at its saved frame, titled with the registry
record's cached name, its content a centered spinner behind an
injectable ~200ms grace — no skeletons, and the first snapshot snaps in
place. The tree walk runs off-main via BoardStoreRegistry.acquireOffMain
(per-board single-flight keyed by file identity — concurrent opens of
one root share a walk, restoration of many boards is genuinely
parallel), landing in BoardStore's new designated init(rootURL:loaded:);
the self-walking init survives as a convenience for its ~470 callers.
⌘W during the walk is real: configureWindow split into a loading half
(frame restore, frame tracking, close interception — installed before
the walk) and a store half (toolbar, widget, hideTitle, undo — installed
at the snap), and the walk lives in an explicitly held BoardOpenWalk so
the user's close and SwiftUI's teardown end in one cancel().
Cancellation is discard-on-completion: nil from acquireOffMain means
nothing was built, nothing retained, and no open-now flag was ever set.
Failure keeps today's sequence exactly: record the launch failure,
welcome's row carries it, the window retires.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Phase 1 of the decision surface (01 ▸ Malformed input, settled
2026-07-31): BoardLoadFailure aggregates the walk's defects in walk
order — stop-at-first retires. Environmental failures (unreadable root,
not-a-directory) stay immediate single-defect throws: there is no walk
to collect from. A defective root index is recorded and the walk
continues into the children (nothing in the walk consults the parsed
root document — verified); a defective lane, card, or trash-entry index
records and skips its subtree, Re-check's whole-walk re-aggregation
being the designed loop for what hides beneath. load(skipping:) is the
per-open skip channel: a skipped path's item is omitted from the model
and surfaces as LoadWarning.userSkipped; root paths are unskippable by
construction. The reload-breakage banner carries the aggregate ("…and
N more"), single-defect sentences byte-identical to before. Two new
multi-defect fixture boards; suite 2591 green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The titlebar widget grows from a 20×18 chevron into one button saying
the board's name and, on a git-mode Pro board, its branch — click
anywhere across it and the popover opens as before, anchored to the
widget. BoardInfoTitlebarSummary is the pure seam for both strings
(title falls back to the folder name per 01's naming rule; branch only
under pro + git mode, live off the observable HistoryStore.branch).
Board windows now hide the system title display through the same
hideTitle slot card windows adopted — the widget says the name, so the
chrome would only repeat it — while navigationTitle keeps feeding
window.title to the Window menu, Exposé, VoiceOver and restoration.
The widget also refreshes the branch eagerly at appearance: it used to
populate only once the popover had been opened, which would have left
the new branch line empty on a freshly opened board.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The card's name renders as part of the card body, so the chrome said it
twice. HostedWindowController gains a titleVisibility slot on the same
retained-slot pattern as the accessory and toolbar (reapplied at attach,
so the macOS 26 provisional-window swap can't lose it); CardWindowHost
opts in beside installToolbar, board windows keep AppKit's default.
window.title itself is untouched — navigationTitle still feeds the
Window menu, Exposé, VoiceOver and restoration.
Known consequence, probed empirically: under .hidden AppKit collapses
title and subtitle into one hidden field, so the "board › lane"
navigationSubtitle disappears with it — and the details sidebar carries
no placement rows, so a card window currently shows its home nowhere.
Filed on the Redesign board to bless or relocate; 05-card-window.md's
subtitle sentence needs reconciling either way.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The modifier flip (⌥ copy / ⌘ move) now reflows the source board once:
a copy re-admits the dragged originals into the resting layout, standing
dimmed in place, and a move lifts them out as before. This retires
DRAG-REORDER.md's operation-blind carve-out and makes 04-interactions.md's
"originals stay" true in flight, not just at echo. The one seam is
DragSession.hiddenMembers reading the observed operation.
Consequences carried honestly: copyCards now takes its index in the
lane's full rendered space (the zones counted the originals, so the
geometry's number is the writer's number — the old neighbour remap is
deleted); resolveOperation freezes under the committed hold exactly as
propose does, fixing a real bug where a settled within-board ⌥-copy drew
the move arrangement until the echo and visibly re-shuffled. Stationary
flips still wait for the next dropUpdated (their own Backlog card).
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:
- Restore subjects compose the inverse, never nest: crossing "Undo: S"
emits "Redo: S" and vice versa; parity, not stack depth, reads a
legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
tone at failure rank merged with write one-shots by recency; the
postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
(ordered siblings first, folder-name tie-break among the order-less),
schema reads 1, both coerce-tier logged; the root keeps its
requirements. Ranks.resolvedOrders materializes finite ranks so
models and placement math stay untouched; first Writer rewrite
stamps a real rank on touch, placement against an order-less sibling
stamps that sibling inline in the same bracket. Agent guide v10
teaches optional keys and zero-read filing. Hostile-YAML order
shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
matcher (standard semantics, board-root file only), loader consults
it once per walk so matched loose files keep the stray posture;
seeded (.DS_Store + .*.lanework-*) at board creation and template
instantiation, healed in when missing at open — repo-nested
included; empty file honored, existing files never edited; the
committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
derives backing from its own undo expectations, backedContent unions
both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
whole in NativeHistoryProvider.strandedSteps — still backing, retired
only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
bump, 1s bound), and an explicit flush now queues behind an
in-flight one instead of skipping — the audit-caught interleaving
could lose a close flush permanently when the debounce fired inside
the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
sentence: fixed Change-custom-key subjects (the retired named
generic was the only producer), the unbuilt Replace attachment
vocabulary, heal commits now authored Lanework Integrity, the config
reader scopes identity to plain [user] sections, add-git re-runs
detection at create (a stale mode-none could initialize inside the
user's repo), and add-git failures answer at the form or the banner.
Structural residue filed on the Redesign board.
2554 tests / 439 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Repo-nested boards bind native undo in every tier (25d2513): the
no-undo case is gone, makeHistoryProvider answers git or native, and
the native path provably never touches the enclosing repository's
.git. Session undo steps anchor by card identity, never by path
(9119aa1): HistoryAnchor carries the card UUID (plus comment/draft
vocabulary) and apply-time validation resolves the current folder via
the same both-container walk writeCardBody uses — a board-side lane or
trash move no longer stales the coarse close step, while a genuine
field collision still skips it whole.
2448 tests in 423 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.
Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.
2444 tests in 422 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.
2432 tests in 418 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The 2026-07-31 re-ruling: gitless boards bind the native stack in every
tier — a Pro upgrade no longer removes undo from mode-none boards — and
Pro git boards bind the git provider; repo-nested stays the no-undo
case under Pro, while the free tier (which never runs detection) binds
native there too, per 12's inert posture. Add-git now swaps a live
native substrate mid-session: the in-flight stack is cleared with the
discarded provider, the git trail seeds from the root commit, and the
same BoardUndoManager instance keeps nil-target menu validation fresh.
2405 tests in 413 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The trash sorts by modified descending — the arrival rank mint retires
(Ranks.isOrderedForTrash one comparator, loader + merged order agree;
the legacy deleted: migration stamps modified from the tombstone
timestamp where parseable; delete undo steps validate existence-only;
agent guide v8). Trash selection goes kind-blind — ranges, marquee,
Select All, and the successor walk sweep both kinds; the guard moves to
the exits (mixed-payload drop refusal, copy/cut validation). The copy
stamping preflight widens back to comment depth (load-scoped posture —
the board always loads, the gesture refuses whole). Fixes a latent
no-op: trashed-lane drag restore never fired (DragSession.beginLanes
hard-coded the board container).
2403 tests in 413 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Settled from the Redesign Minor card: SHA-256 repos are unsupported,
safely — unopenable ones take the corrupt-repo loud-failure path,
never a silent fall to mode-none; opened ones get hygiene's abstention
by construction (SHA-1 shape filter, empty work list). Recorded in 06.
No code change.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settled from the Redesign Minor card: the hygiene pass never
consolidates, rewrites, or deletes existing packs — one pack per
threshold's worth of objects accrues forever, the never-rewrite
posture's price. Terminal git gc remains the consolidation path.
Recorded in 06. No code change.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settled from the Redesign Minor card: loose-object pack at the 6,700
threshold (git's gc.auto default), 8 s after session activation,
background priority, skipped under contention, at most once per
session, all injectable. Recorded in 06's repo-growth bullet.
No code change.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settled from the Redesign Minor card: identity writes append a plain
[user] section, never editing existing sections or tool-specific
subsections; reads take the last plain-section value, git's own
semantics — so the append always wins without reformatting anything.
Recorded in 06's identity bullet. No code change.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Two Redesign Minor settlements in 06: the popover's name/email fields
re-read repo config at 2 s only while visible (the watcher never
delivers .git, so the resync courtesy needs its own signal; focused
fields keep keystrokes); and the wholesale-bracket failure sentence
now defers to 02's hosted-by-window-of-origin rule — the card window
hosts its own strip, a ⌘Z pressed there fails there.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settled from the Redesign Minor card: popover-asked operations fail
inline at the form; board-wholesale brackets — branch switch and undo
restore named explicitly — fail as banner rows, red one-shot failures
per the same-day banner ruling. A switch's bounded lock wait can
expire long after the popover dismissed. Recorded in 06.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settled from the Redesign Minor card: the stamp clears on successful
abort only. A failed abort keeps it — the stamp is the sole evidence
the leftover is the app's own, and clearing on failure would orphan it
as somebody-else's forever. Recovery retries at next open or the 15 s
paused-state re-read. Recorded in 06. No code change.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settled from the Redesign Minor card: the waiting posture stays calm,
but the wait ends — 30 s (injectable), then the clean-failure path,
tree untouched, lock path named. Only a stale lock ever reaches the
bound; an eternal spinner holding the wholesale bracket is worse.
Recorded in 06's index.lock bullet. No code change.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settled from the Redesign Minor card: no at-HEAD fast path — the
"can't change the tree" proof fails under concurrent writers, and
pending work flushes onto the branch it was made on. One sequence,
no special case; recorded in 06's Branch switching intro. No code
change.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settled from the parked Redesign Minor card, with the user: 02's
hang-avoidance doctrine and 06's ordering guarantee genuinely conflict
at noteWillWrite, and the guarantee wins — rare, bounded, load-bearing,
reentrancy-safe only in the synchronous shape. Full semantic
composition stays on the path: the commit preserving somebody else's
version keeps its real subject. Carve-out recorded in 06.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settled from the Redesign board's Gap card: the failure class gains a
message-carrying GitOperationFailure shape beside the BoardWriteError
one — error tone, failure rank, BannerCenter phrasing. A failed undo
restore, branch switch, or pull/push presents as the failure it is,
never as a warning-tone loss row; recovery notices stay warning-tone.
Code realign pending (both reportFailure wirings post loss rows today).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settled from the Redesign board's Gap card: crossing a restore commit
post-relaunch emits the inverse prefix ("Undo: S" crossed -> "Redo: S")
rather than stacking "Undo: Undo: S". Semantically exact and caps
prefixes at one; cosmetic-only mislabel accepted for foreign commits
whose subjects happen to open with a prefix. Code realign pending in
the restore-subject composer.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Settled from the Redesign board's Gap card: crossing the parentless root
commit could only restore the empty tree, and the root is a baseline,
not a change-set. Undo bottoms out at the initial board state; the
reseed walk stops in the same place. The shipped GitHistoryProvider
already conforms.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Add-git seeds a minimal .gitignore (.DS_Store) before the initial
stage — the seed rides "Initial board state" and .DS_Store never
enters history; an existing .gitignore (or a directory wearing the
name) is left alone forever, adoption and repo-nested seed nothing.
GitHousekeeping is the periodic loose-object repack: filesystem
enumeration of objects/<2hex>/<38hex> (never git_odb_foreach, which
would rewrite the whole database into a fresh pack each pass),
git_packbuilder_insert one oid at a time, additive pack write — and
deletion only after each oid is re-verified against the written
pack opened as a standalone one-pack odb with no loose backend. Any
failure returns before deleting; the worst case is a stray pack.
Nothing prunes, expires, or consolidates — existing packs
accumulate, recorded as the accepted cost of never rewriting
storage the app didn't write. GitHousekeeper schedules it: git's
own 6700 threshold, 8s after session activation (outlasting the
launch catch-up), background priority, skipped under pause states,
held locks, or an in-flight commit, never retried — the next open
tries again. Free tier composes none of it.
20 new tests: full-odb equality, per-oid survival, identical walks,
byte+mtime-identical refs/HEAD/working tree, whole-.git identity on
every declined pass, and deleting-never-forgets. 2394 tests / 412
suites green; InertGitTests untouched. Closes pro-m1-git-undo.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
GitBranchSwitcher holds 06's sequence as one object: settle editors
explicitly (SessionSettleGate — Save All applies raw buffers with
validation and a refused buffer cancels the whole switch; Discard
reverts buffers AND reconciles the session folders against HEAD;
never silent), flush the pending auto-commit, stamp intent in the
per-board registry, bracketed safe checkout (git_checkout_tree
GIT_CHECKOUT_SAFE + set_head — no path passes FORCE, abort
included), one reload via the async wholesale bracket (failed final
reload engages the existing read-only lock), reseed undo/redo from
the new HEAD with redo empty, clear the stamp. Create-and-switch
keeps the full sequence — the tree-cannot-change proof fails under
concurrent writers. Lock contention shows the 02 in-progress row's
waiting state ("waiting for another writer's git lock"), bounded at
30s then failing cleanly naming the lock path.
GitOperationStamp + GitOperationRecovery: the own-leftovers rule as
a pure conjunction — pause state AND matching stamp = the app's own
interrupted operation, aborted to the pre-operation state with a
banner, stamp cleared on success only; either alone defers to the
pause-and-defer stance. Checked where the committer starts.
BoardGitControls replaces the read-only branch line: branch picker,
inline create-and-switch, the abnormal-state pause note in 06's own
words with controls dimmed, and commit-identity fields that read and
write repo-local .git/config (derived default as placeholder, never
value; unfocused resync, focused keystrokes kept; 2s poll while
visible — .git is watcher-filtered by design).
Also fixes a shipped bug from the undo card: plan(reconciling:)
matched card ids as path prefixes, so the reconcile branch was inert
on every board (<lane>/<card> never matches a bare id) — a session
file the restore diff couldn't name (attachment, comment, draft)
survived Discard and landed in the next flush's commit. One shared
component-exact folder-name resolver now serves both Discard paths;
noteDiscarded takes cardFolderName; regression test verified failing
against the pre-fix code.
41 branch tests + the regression; 2374 tests / 409 suites green;
InertGitTests untouched.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
GitHistoryProvider is the second HistoryProviding implementation:
its stack IS HEAD's first-parent ancestry, reseeded on load (redo
empty), re-synced to HEAD before every crossing so agents'
self-commits become the top and ⌘Z steps back exactly one commit;
any arriving commit clears redo (a heal-only window deliberately
does not). Restores are forward commits through the ordinary
signature path — GitRestoreOperation materializes only the
current-vs-target diff as working-tree writes and resolves no
reset/checkout symbol at all; heal commits are transparent
in-session (pointer passes over, restores exclude heal-owned paths,
identity carried on landed windows via PlannedCommit.kind →
GitLandedCommit). Subjects "Undo:/Redo: <crossed subject>"; menu
labels never nest in-session; the root commit is not a step
(crossing it would restore the empty tree).
Provider binding flips: makeHistoryProvider(store, tier, git) —
free binds native everywhere, Pro binds the git provider on git
boards and NOTHING on mode-none/repo-nested (the pair disables
through existing validation); add-git mid-session live-binds via
HistoryStore.didAddGit → bindHistoryProvider (the flip only ever
adds).
SessionSettleGate is the reusable Save All / Discard / Cancel step:
restores whose diff touches an open Edit session or raw-source
buffer gate on it (Save All applies with validation — a refused
buffer cancels the whole restore focused on the offender; Discard
reverts via CardBodyEditSession.discardBuffer and reconciles against
the working tree, deliberately skipping the second flush); untouched
sessions ride through undisturbed. Built for the branch-switch card
to reuse. BoardStore gains the async performWholesale sibling.
CardHistorySection fills the m6 EmptyView slot: read-only, newest
first, follows the card across lane moves by folder-component match
(the UUID is the identity — no rename detection), absent off git
mode and off Pro.
2332 tests / 403 suites green; InertGitTests untouched.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
CommitMessageEngine replaces the interim composer as the wired
default: a pure total function from two snapshots + changed paths to
a message. Full vocabulary — Add / Delete / Move / Rename / Edit /
Restyle / Resize / Reorder over cards, lanes, board; Attach / Remove;
Repair for the duplicate remint (detected as a heal-classed
rename-paired arrival whose id the previous snapshot never held —
the loader withholds duplicates, so the shape is a bare arrival);
the trash triple by diff shape alone (into .trash = Delete, out =
Restore, leaving the tree = Permanently delete); Relabel / Assign /
Set due date plus the named generic for custom keys. Plural folding
with shared destinations, implied events as body bullets never
subjects, ~40-char subject truncation, "(untitled)". Bookkeeping
(sequence-preserving renumbers, stamps, backfilled kind) composes
nothing. Non-snapshot paths compose path-shaped events — CLAUDE.md
reads "Update agent guide (vN)" via the marker line (the m10 card's
deferred bullet lands here), everything else "Update '<path>'".
The comment verb family per 01's ruling (comments shipped, so 06
gains the verbs): Comment on / Edit comment on / Delete comment on /
Draft comment on / Permanently delete comment on '<card>', grouped
one event per comment folder, classified ahead of the model-silence
rules, card title resolved from either snapshot. GIT_DELTA_ADDED is
surfaced as GitChangedPath.isArrival — post vs edit is unanswerable
from snapshots that exclude comments by ruling. A card moving with
its thread swallows the comment events (implied-events one level
down).
The previous snapshot is HEAD's tree, materialized per flush into a
temp dir (index.md blobs in full, other blobs zero-byte — the model
reads attachment names, never bytes) and re-parsed through the one
BoardLoader; never a value carried forward. changedPaths is a hard
filter per split commit, which also earns the stage-around and kills
phantom events. Launch catch-up and foreign windows compose through
the same engine.
48 new tests (35 pure + comment family + engine-level); 2293 tests /
394 suites green; InertGitTests untouched.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Every settled change on a git-mode board commits, debounced 2s past
drag/typing churn, staged whole-root with .gitignore respected.
GitCommitOperation reaches the vendored libgit2 directly (same 1.9.2
pin SwiftGitX resolves — importable, not duplicated) for
signature-capable commits; add-git's config materialization is gone,
identity resolves at commit time (repo-local config, else derived
default) per the 2026-07-31 ruling in 06. CommitAttribution
classifies per file off EchoLedger receipts: user identity on
app-mediated windows, Lanework External <[email protected]>
on foreign, the modified-by refinement (<slug>@agents.lanework
.invalid) when every foreign file agrees, heal-marked receipts split
into their own commit — window split foreign → heal → user.
Edit-session granularity: ~700ms saves stay uncommitted, staging
excludes open session folders (closure-resolved so mid-session moves
stage around the new location), session end nudges the debounce so
each session lands exactly one body commit. Flush-before-overwrite
gates on known-foreign windows and commits synchronously ahead of
the write; close/quit flush the pipeline via CloseFlushCoordinator's
committerFlush. index.lock backs off briefly then re-debounces
silently; clean tree no-ops; genuine failures ride the standing
history-suspension banner and retry next debounce. Abnormal repo
states (detached HEAD, merge/rebase/cherry-pick in progress) hold
the engine with a 15s re-check; unborn HEAD commits "Initial board
state" whole-tree; dirty tree at open catches up through the same
engine. Message seam (CommitMessageComposing) ships interim — the
semantic composer is the next card.
Discovery diffs HEAD against an in-memory index with rename
detection (git status alone never pairs a bare mv), and a failed
survey reads as "could not look", never "nothing changed".
46 new tests / 8 suites, all real repositories via bundled libgit2.
2240 tests / 383 suites green; InertGitTests untouched.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The pro-m1 foundation card. SwiftGitX 0.4.0 (bundled libgit2, the
pathfinder's pin) joins the one target; new Kanban/Git/ holds
BoardGitMode (pure nearest-.git-wins detection, .git-as-file counts,
NSString ancestor walk), HistoryStore (@MainActor @Observable;
compose() is the tier gate — free tier gets no object, no detection,
no stat), GitRepository (scope-confined SwiftGitX handles: create =
init + HEAD forced to main + whole-tree "Initial board state" commit;
branch reads incl. unborn/detached; path-history ranks), GitIdentity
(derived default as a pure function + repo-local config reader — not
libgit2's merged ladder), and GitPathHistory (Mutex-guarded lazy
ranker). beginSession composes the git state beside the tier and
feeds BoardStore.makeIdentityHistoryRanker; git-mode loads pass the
git-backed IdentityHistoryRanker to BoardLoader. The popover's git
slot resolves a pure five-way matrix: free tier unchanged (absent /
BoardGitNote), Pro mode-aware — Add Git on mode none, honest prose on
repo-nested, read-only branch line on git. Provider binding
unchanged: both tiers still bind native until the undo/redo card.
42 new tests across 8 suites, all repositories built through bundled
libgit2; InertGitTests untouched and green. 2194 tests / 375 suites.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
DESIGN/14-git-operations.md distills the settled conclusions from the
pathfinder's AI-ANALYSIS-git-operations.md so pro-m1 has a citable
in-repo source: the forward-restore model (C3/C9 — restores are new
forward commits, never reset, never force; pull's rebase of unpushed
local commits is the one deliberate rewrite), C8 as later design
settled it (no sidecar state, reseed from HEAD's first-parent
ancestry, history browser out of scope with the per-card History
section as the one carve-in), and honest call-outs of the three
leanings 06/07 settled differently (C1 substrate, A1 iCloud dual
mode, B6 cadence-as-divergence-budget). Resolves the corpus's
outstanding git-operations-doc tbd.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Replaces the pathfinder-inherited round-robin deal (child i -> column i % C)
with contiguous column segments: base = n/C, the first n%C columns take one
more, and logical order runs down each column before crossing to the next.
Only the geometric mapping changes -- ranks, selection flatten, and VoiceOver
order are untouched, and MasonryPlacement stays the single placement function
both the Layout and the drop model replay.
Why: an insertion under round-robin shifted every later card across columns;
under the column-major deal later cards slide within their column and at most
one card crosses each boundary, so the drag reflow is far calmer. Drop-slot
math gets simpler too -- a column's cards are one contiguous range, a
non-final column's tail is now a genuine mid-list position, and only the last
column's tail means append.
DropSlotMathTests recomputed and extended (46 -> 50): the uneven-fill deal,
boundary positions, the shared tail/head boundary index, and a placement/
drop-model shadow-agreement check. DRAG-REORDER.md and DESIGN/10 amendments
are listed for ratification, deliberately not edited here.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The per-element .accessibilitySortPriority read slots.count from inside the
ForEach closure, re-running the whole slots -> renderedCards chain (an O(n)
card filter plus the session's hidden-member resolution) once per element --
O(n^2) per lane body. A drag pickup runs a synchronous whole-board layout
inside the drag-start nested run loop, which multiplied this into a stack-
sampled ~700ms-1s stall between mouse-down and the visible lift. Hoisting
the evaluation into a local makes pickup effectively immediate.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The card window recomposes into three componentized panes (body,
comments, attributes) with two mounts — beside or body-over-comments
at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is
one persisted app-wide bit, no content-derived auto-show; File ▸ Add
Comment flips it on and focuses the composer. The thread renders
author lines, edited markers, card-subset Markdown bodies, and
read-only Quick Look chips under a count header with the sort-
direction control. The composer edits comments/.draft/ on the slow
cadence (blur, close, quit, ~30s interval), Escape only moves focus,
⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms
debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start
bytes, close flushes. File drops within either authoring surface
carve out of the window-wide card default into that surface's
attachments/; paperclips cover the no-drag path. Close flush runs
inline flush, then draft save, then the comments/.trash purge;
open sweeps crash residue.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The kind: comment field table lands in IntegrityRules (the per-kind
hook's first exercise), CommentThread reads one card's thread
window-scoped (the board walk stays O(cards)), and CommentWriter gains
the five gestures: draft save, post (rename .draft to a fresh UUID,
created/modified restamped in the bracket), edit, delete into
comments/.trash/, and the purge with its crash-residue memo. Post and
delete register move-based undo steps; draft saves, edits, and the
purge deliberately register nothing (13's no-capture rule). Copy
boundaries strip comments/.trash, carry .draft verbatim, and remint
threads; comments graduates to a displacing claimed name, with .draft,
.trash, and a comment's attachments claimed one level down.
CommentPath classifies changed paths into the 06 verb family for
later announcer/composer wiring.
One stated narrowing pending a ruling (filed on the findings board):
the copy transaction's refuse-whole preflight stays cards-and-lanes —
an unstampable copied comment copies verbatim with a log line, because
comment defects never refuse.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Phase 3 of the one-app pivot (DESIGN 12 ▸ The entitlement / Distribution,
ruled 2026-07-30; card c3a3ddd5). New Kanban/Tier/: Tier (.free/.pro —
deliberately no .lapsed case; unsubscribed and lapsed are one state) and
the pure decision Tier.resolve(from:now:) over SubscriptionFacts
(expiration + willAutoRenew), unit-tested through all five named states:
free, active, lapsed, offline-grace, never-online.
The facts are a persisted cache (standard defaults), not a live view:
StoreKit ages an expired subscription out of currentEntitlements locally,
so an offline device and a real lapse are indistinguishable from that
property alone — the cache holds the last answer, empty entitlements
read as silence, and holds end only on a definitive answer (revocation,
or the subscription-group status read Settings performs). That is 12's
offline-grace trade, resolved toward the paying user.
ProEntitlement is the local adapter (currentEntitlements +
Transaction.updates, started from launch, never from a test host);
ProStorefront holds everything networked (product load, purchase,
AppStore.sync) and only the Settings section ever constructs one — the
split is the enforcement of "never network on the open path".
beginSession reads the tier once at composition; BoardSession.tier is a
let with no path back in, so a lapse never rebinds an open session.
makeHistoryProvider now takes the tier; both tiers bind the native stack
until pro-m1 builds the git provider — the seam's consumer is named, not
invented early.
Settings gains the Pro section (subscribe with localized price, manage,
restore; a quiet unreachable line, no indefinite spinner) — the third of
the exactly-three Pro mentions; the About line gains its "…in Settings"
pointer now that there is a Settings to point at. A successful purchase
or restore offers once to reopen open boards (close + reopen through the
ordinary paths). Configuration.storekit wired into the scheme's run
action for ASC-free exercise; RELEASE.md gains the pro-m1 store-side
steps and the rule that the product must not be configured before then.
1901 tests in 319 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Phase 2 of the one-app pivot (DESIGN 12 ▸ App-side state, re-ruled
2026-07-30; reworks 566deab). AppGroup retires; what remains is
AppStateHome — ordinary sandbox Application Support as the one home for
the registry, clipboard staging and template stores, keeping the
unit-test-host redirect (the test host is the app and would sweep real
state). Scalar defaults return to UserDefaults.standard.
BoardRecord's per-edition grant slots and openNow flags collapse to one
bookmark + one isOpenNow; the legacy-key decode and adopt-in-memory
paths go (nothing shipped with group-era records), while the founding
four-keys-required / defaults-for-everything-since decode policy stays —
a bookmarkless record decodes as the born-orphan row rather than
quarantining the list. needsReopen and the pre-anchored re-grant panel
are removed whole: the only state that flow served — a record granted by
a sibling sandbox — is unrepresentable now, and a dead bookmark of our
own was already the orphan case by explicit comment. The
indexOfRecord path fallback dies with it; path is never a key again.
The cross-process freshness stamp (mtime+size re-read) and
BoardEditionPresence with its popover "Also open in…" line retire; the
clipboard prune keeps its atomic .sweeping/ claim-then-delete, reframed
for crash residue and open -n copies rather than sibling editions. The
application-groups entitlement key is gone.
1880 tests in 317 suites green (13 cross-edition tests retired with
their subject).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Phase 1 of the 2026-07-30 one-app pivot (DESIGN 0bec9a6, card c3a3ddd5):
the KanbanPro target, LaneworkPro scheme, KanbanProTests module-alias
bundle, KanbanPro/ source root and scripts/verify-editions.sh retire
wholesale. project.yml reads as a single-target file again (anchors
inlined, header rewritten in tier vocabulary).
The edition twins merge: EditionTypes -> PasteboardTypes (one
UTType(exportedAs:) home — the one app owns the family types),
EditionAbout -> AboutBox (the quiet Pro signpost survives as the About
box's one line; "…in Settings" deferred until the StoreKit phase gives
it somewhere to point). InertGitTests drops its Base prefix — the
inert-.git posture is unconditional app behavior, unsubscribed and
lapsed being one state.
Entitlements gain com.apple.security.network.client, declared now and
dormant until Pro's remotes use it; no keychain access group. The App
Group key deliberately stays — it goes with AppGroup.swift in phase 2,
since pulling it first would silently drop the app into the fallback
container. README/RELEASE.md build-and-pipeline prose updated to the
one-record world; the subscription story lands with phase 3.
1893 tests in 322 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Phase 2 completes the lanes-in-trash card. TrashEntry merges the
trash's two kinds by rank in exactly ONE place (ItemPath.resolve's
own merge deleted in favor of it — the three-merge-points finding
shrinks instead of growing). TrashLaneRowView renders the opaque
row — tertiary plate, level-default lane glyph never the lane's own
icon, title + card count, no accents, no expansion; the column badge
counts rendered rows. Selection grammar: kind-homogeneous trash
selections — ranges skip the other kind, ⇧-extension stops at the
kind boundary, plain arrows walk the merged order, marquee stays
card-only (now load-bearing: rows register frames for arrows),
Select All card-scoped; successor-on-purge crosses kinds like
navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop
accepts lane sessions (drop on shown trash deletes), restoreLanes
routes a trash-sourced strip drop as an arrival-ranked within-board
move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque
lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as
the root — a same-board restore looked like an import and would
have reminted the lane it was restoring (pinned by test). A11y:
row = one flattened "title, deleted lane, N cards" element with
Delete/Reveal actions; BoardDiff crossings read lanes as
deleted/restored, shown-trash churn digested at row level. Agent
guide stays v7 — the literal already teaches lanes-trash-by-move
and kind stamping; drift-guard pins those lines. README trash
paragraph notes lanes.
Both schemes 1893 tests / 322 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Phase 1 of the lanes-in-trash card (2026-07-29 ruling, docs led the
code): lane delete is a move into .trash/ with the subtree intact,
arriving at top trash rank — no destructive delete remains outside
the trash.
TrashedLane opaque unit (id/schema/title/order/heldCards) beside
trash cards — deliberately not a Lane, so no card-shaped surface can
believe an empty subtree. Loader's trash walk trusts the kind VALUE
(lane → opaque unit w/ held-card count counted at the loader's own
unit; card → ordinary card; absent/unrecognized → UUID-children
shape, empty-kindless falls to card per 01's honest limit). Writer:
moveIntoTrash generalized with kind passed never derived (an empty
lane would re-derive as card), deleteLaneToTrash mints against the
whole-container rank ladder. Retired: migrateTombstonedLane (lane
deleted: now ignored — loads live, bytes inert, tolerate-tier
warning), removeLane, captureSubtree/recreateSubtree and the
subtree-snapshot machinery. Undo inverse = move back to captured
strip position, redo replays at captured trash rank. Purge walks
lane subtrees; TrashModel.Freight phrases confirms with lane freight
("…and its 5 cards"). ItemPath gains .trashLane; resolve interleaves
the trash by rank; SearchFilter matches lane rows by title only.
Trashed-lane card windows dismiss and pending cuts void via the
ordinary vanish rule — no new plumbing.
Phase 2 (rendering, selection grammar, drag, a11y, agent guide)
follows. Both schemes 1858 tests / 318 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Delete Immediately is removed entirely (ruling ae1dd96, Redesign card
d40bfac1): the delete vocabulary is purely staged — board → trash,
trash → permanent (confirmed on no-git boards), Empty Trash for bulk.
Gone: File ▸ Delete Immediately (⌥⌘⌫) and its validation, both
⌥-alternate context rows (card + the permanently-disabled lane row),
the VO custom action, BoardStore.deleteImmediately,
TrashModel.canDeleteImmediately, TrashConfirmations' .purge action
(zero surviving callers — trash-side Delete always used
.deleteTrashCards), and the pinning tests. purgePrompt drops its
now-single-purpose container parameter (.trash is the only surviving
caller). BoardWriter.purgeItem survives — create-undo rollback still
needs it — with its comment rewritten. README's trash paragraph drops
the ⌥⌘⌫ sentence.
The other 2026-07-30 rulings (2ec2c95 registry freshness stamp,
c741b02 unified-log-as-coerce-consumer) required no code changes —
already conformant.
Both schemes 1844 tests / 318 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 findings-resolution ruling (Redesign card c70d7a72): the
guide-contents section now states what the shipped guide teaches NOW,
normative against the literal, edited in the same change as the literal.
The hand-maintained per-version bullets drifted within days of v6
(claiming kind and lane-trash teachings the shipped body lacked) — the
README deferred-list failure class; version history lives in git.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-30 design pass (with the user), superseding the 2026-07-27
two-app split and the 2026-07-29 App Group ruling. Rulings:
- One Mac App Store app (dev.rzen.indie.Kanban, free, 2.0 updates the
existing record); Lanework Pro = auto-renewable subscription (StoreKit
2) unlocking the git tier; libgit2 compiles in dormant. The KanbanPro
target, module-alias tests, verify-editions, UTI/About/Types twins all
retire. Teams stays deferred — probably a separate app, and never on a
shared app group.
- No grandfathering: 1.x's free git users get the inert-.git posture;
unsubscribed and lapsed are one state — histories freeze, resume on
subscribe via the committer's catch-up commit.
- Entitlement = local read of StoreKit's signed transaction store at
board-session composition; never a network call on the open path;
subscribe effective at next open (purchase flow offers reopen); lapse
never interrupts an open session; expiry-while-offline with auto-renew
on holds in grace until a real refresh.
- App Group removed wholesale: registry, clipboard staging, template
store, defaults return to ordinary Application Support; grant slots
collapse to one bookmark, open-now to one flag; freshness stamp,
pid-liveness line, group-id provisioning risk all dissolve.
- Quiet signposts carry over; Settings gains the Pro section (subscribe/
manage/restore) as the third named Pro surface.
12-editions.md rewritten as the tiers doc; edition vocabulary swept to
tiers across 00-13, README, WISHLIST.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-30 findings-resolution ruling (Redesign card 8fec5d7d): the
structured log line is the coerce-tier Defect stream's whole surface
this version; promotion to a heuristic heal or notice is deliberately
investigation-driven — a no-telemetry app has no other observer to
build for. An in-app diagnostics surface becomes WISHLIST #11 with the
typed stream as its ready plumbing. Code already conforms.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-30 findings-resolution ruling (Redesign card 89862cef): the
mtime+size stamp the App Group implementation introduced (566deab) is
the designed mechanism, not an invention — checked before every registry
read and mutation, re-read on change, write on top of the other
edition's latest state. The microsecond check-to-write window stays
last-writer-wins, costing at most one convenience field. Deliberately no
NSFileCoordinator and no per-record split. Code already conforms.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-30 findings-resolution ruling (Redesign card d40bfac1): Finder's
⌥⌘⌫ answered disk-space pressure boards don't have, and it was the one
gesture reaching unrecoverable straight from the board. The delete
vocabulary is now purely staged: board → trash, trash → permanent
(confirmed on no-git boards), Empty Trash for bulk. Docs drop the File
menu row, both ⌥-alternate context rows, and the VO custom action; code
removal rides the session's realign card.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Nine rulings land as code. Reorders don't stamp — one container-change
predicate (WriteOperation.rewritesOrderOnly): within-container reorders
and the renumber rescale rewrite only order, while cross-lane, cross-board,
and trash moves stamp modified and clear modified-by; no trash special
case exists, and the m8 undo inverses conform through the same seam.
Copies are transactions: the root-strict/nested-lenient split retires for
a whole-subtree stampability preflight that refuses loudly naming the
offender, and every item-level copy severs remote/remote-state at every
level (whole-board forks carry them verbatim). Paste refuses, never
degrades: the embedded-index.md materialization and its loss row retire;
a missing staged snapshot produces nothing and posts an error-tone
one-shot named from manifest metadata. Coerce-tier fallbacks log through
the Defect stream with path context attached loader-side. Displacement is
level-uniform: a file squatting attachments inside a card heals by the
same rename ladder as board-root squatters; comments stays tolerated.
Delete Immediately joins card and lane context menus as Delete's
⌥-alternate with its own VO custom action, routed through an explicit
container so the menu target outranks standing selection. Agent guide v7
teaches the stamp discipline and the card-level attachments claim, and
sheds two stale v6 lines (lanes trash now; kind is taught). Verified
conformant, unchanged: edition-aware Undo/Redo disable, trash marquee
full-height backdrop.
Both schemes 1854 tests / 318 suites green; verify-editions 30/30.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
User-ruled 2026-07-30: only within-container order-only rewrites (a reorder
among a lane's siblings, a lane reorder on the board, the renumber rescale)
skip the modified stamp and the modified-by clear. A move that changes the
item's container — cross-lane, cross-board, into or out of .trash/ — stamps
and clears both: which lane a card lives in is state, where it stands in
line is presentation. The trash move stops being an exception and becomes
the container rule's plainest instance. 01 scope/modified-by/Ordering/
Deletion/comments, 03 Trash, and 08's v7 bullet updated.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
macOS keeps Saved Application State per app, and on macOS 26 its mere
existence — even describing zero windows, which repeated dev kills
guarantee — counts as "a restored session": SwiftUI then treats every
scene's defaultLaunchBehavior as moot and presents nothing. The app
launched as a windowless shell with no way back, since windowOpener is
captured by the first scene that appears — so Open Recent, the
re-grant Grant click, and Dock reopen all silently buffered or
no-opped. Proven by -ApplePersistenceIgnoreState YES presenting
correctly on the same state; with the fix, welcome presented 3/3
consecutive plain launches.
Three changes:
- App.init registers ApplePersistenceIgnoreState — restoration is the
registry's job (02 § Launch and window lifecycle), every scene
already declares restorationBehavior(.disabled), and AppKit's layer
was pure liability. Registered before NSApplicationMain runs, which
is what makes a registration-domain default early enough.
- The restore bootstrap presents at every launch as the app's one
reliable presenter; welcome is never system-presented (.suppressed)
— the pass opens it when nothing else lands on screen.
LaunchPlan.presentsBootstrap retired.
- captureWindowActions returns the replayed Finder-open count so the
pass counts those as opens — a cold document launch doesn't get
welcome stacked beside its board.
Both suites green, verify-editions 30/30.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Latent since the m7 split: base's Info.plist exports the three family
pasteboard types (cards, lanes, clipboard) and Pro's imports them, but
the shared constants declared all three UTType(exportedAs:) — a claim
of ownership the system checks against the running app's plist. In Pro
that claim is false, and the first touch (the clipboard type, via the
launch sweep) raised a runtime fault that blocked board loading under
the debugger.
EditionAbout's twin-file pattern, applied to an initializer: the
constants move to Kanban/App/EditionTypes.swift (exportedAs) with a
KanbanPro/Edition/EditionTypes.swift twin (importedAs, identifiers
verbatim); project.yml excludes base's copy from Pro. Type identity is
the string, so payloads cross editions unchanged. Verified: Pro
fixture launch opens the board with zero UTI warnings; both suites
green; verify-editions 30/30.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
No board window has shown its NSToolbar or the titlebar board-info
chevron since m6: on macOS 26, SwiftUI stages a scene's content
against a provisional window, then dismantles and re-makes the
window-sensing background view while moving content into the real
window. HostedWindowController.detach() treated that dismantle as the
window's death and discarded the held titlebar accessory and toolbar
controller — so the real window attached moments later to empty slots.
Observed live via instrumented launch: install arrives with no window,
detach follows (slots cleared), then the real window attaches.
detach() now removes the chrome from the window but retains the slots
— their lifetime is the controller's, not the sensing view's — and
attach() reinstalls whatever is held. Verified on a fixture launch:
the board window carries its toolbar and the leading accessory.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Every edition declares group.dev.rzen.indie.Kanban and homes its
app-side state there from day one (12-editions.md ruling 2026-07-29):
- AppGroup namespace: container resolution with per-edition fallback
when unprovisioned, shared UserDefaults suite, edition identity, and
a unit-test-host redirect (the test host IS the app — its launch
sweep and recents refresh must not touch the real shared container).
- BoardRecord: bookmark/isOpenNow replaced by per-edition grants and
openNow keyed by bundle id; hand-written Codable keeps legacy keys
decoding (adopted in memory as the running edition's slots, upgraded
on first save); every other field stays common.
- RecentBoard gains needsReopen: no grant of ours but somebody's —
first click runs an open panel pre-anchored at the recorded path,
prompt "Grant"; recordOpen mints this edition's slot onto the
matched shared record (path fallback only after identity fails and
only against records holding no grant of ours, so re-granting never
forks the record).
- Cross-edition freshness: stat-cheap mtime+size stamp re-reads the
registry when the sibling edition wrote it, so one edition's save
never erases the other's records wholesale.
- restorables() filters on this edition's open-now flags; the board
popover gains BoardEditionPresence ("Also open in Lanework Pro"),
pid-liveness-checked so crash residue never lies.
- Clipboard staging store moves to the group container; the sweep
claims doomed trees by atomic rename into .sweeping/ then deletes,
so the sibling's concurrent sweep is a non-event.
- Template store re-homed to the group container per the 09-templates
re-ruling; scalars (quick-style recents, window size) move to the
shared suite.
- verify-editions.sh: 30 checks (each edition carries exactly the
family group). No pathfinder 1.x migrator: 1.x predates the
registry; state starts fresh in the group container.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Designed with the user 2026-07-29, closing Redesign card 82e2de6c (both halves). The card window recomposes as three componentized panes — body, comments, attributes — with a layout option (side-by-side default, body-over-comments for narrow displays; app-wide) and the thread visible through body Edit in both mounts. The column auto-shows when the thread is non-empty; sort direction flips at the header; the composer sits at the newest end. The composer edits comments/.draft/ — the user's idea, replacing any app-side draft store: one draft per card, ordinary comment schema with attachments, excluded from the thread, riding git/copies/trash, joining the claimed names; slow save cadence composing "Draft comment on 'X'"; posting renames to a fresh UUID and restamps created/modified in one bracket. Inline edits are body-edit sessions in miniature; no prompts anywhere. All editions ship comments — only tracker sync is Teams (matrix updated). A11y shapes in 10; menu, grammar, context-menu, and configuration rows in 11.
Board: Redesign card 82e2de6c → Resolved; Implementation card "Build comments" filed on Backlog (post-2.0).
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The crash-class gap the integrity design pass found (DESIGN/01 -
Fractal layout rules; 02 - Live-reload resilience): the loader had no
board-wide dedupe at all, so two hand-copied folders sharing a UUID put
two equal ItemIDs into one snapshot - which SwiftUI's ForEach does not
tolerate. Built to the day's re-rulings, both landing mid-flight: the
user-gated Repair banner retired (176c852 - the heal runs silently) and
the container boundary became the first tie-break (f153e79 - the
visible card never loses to its own trash ghost).
IntegrityRules.dedupe (pure, occurrence list in, verdict out): group by
canonical identity, collapse case-spelled twins first - spellings with
a live occurrence outrank trash-only spellings, then canonical
lowercase, then lexicographically first; losers are silent strays
(LoadWarning.caseTwinIgnored - spelling artifacts, never reminted) -
then earlier-occurrence-wins across the surviving spelling's folders on
a four-rung ladder: live-before-trashed, git path history rank, FS
birth date (nil is no comparison, never .distantPast), traversal
order. Occurrences are exactly the identity-bearing folders: lanes,
cards, .trash entries - a UUID-shaped folder under a card is content.
BoardLoader walks lanes as WalkedLane and builds Lane values only on
the far side of the verdict, so a withheld card can never reach a
snapshot; a name-only gate keeps the healthy-board cost at one
dictionary pass, no disk reads. Withheld subtrees are still walked - a
hand-copied lane's nested cards are their own withheld occurrences,
reminted at the finest grain like the import boundary would have. A
withheld trash entry's trashKinds reading leaves with it. The git rung
is a seam (BoardLoader.IdentityHistoryRanker, one closure keyed by
root-relative path) because base links no git machinery - base injects
nothing and falls through; pro-m1 owns the ranker (card annotated).
The heal: Defect.duplicateIdentity (signature duplicate:<path>:<id>)
rides HealScheduler as the fourth scheduled heal, ordered last among
the content heals because a remint renames folders and would stale the
paths the same load handed the relocation and migration.
BoardWriter.remintDuplicateIdentity re-verifies twice at write time -
the folder still carries the losing identity AND something else still
does (the vanished-duplicate race no-ops from either side) - then
renames to a fresh v4 minted against the whole board's identity bag.
A rename and nothing else: no index.md opened, no modified stamp, no
modified-by clear; the receipt is heal-marked (pro-m1's committer
splits it out, named by 06's kept Repair verb); no undo step - heals
are not gestures. The notice is the design's own sentence ("Repaired
duplicate id - 'Fix login'"; several fold to a count), a loss row on
the relocation's reasoning; WriteOperation.repairDuplicateID carries
the failure mirror.
Fixture repair rode along: duplicate-order-tie-break.kanban had a lane
and its own card sharing a UUID - a genuine duplicate the new pass
correctly withholds; the folder rename landed in feae6d0, the matching
test constant lands here.
66 tests added (DuplicateIdentityTests: the ladder rung by rung, the
straddles, withheld-lane subtrees, remint idempotence and races, the
one-heal-cycle window, all phrasing). 1804 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Designed with the user 2026-07-29 (Redesign card 82e2de6c, storage half; UI half remains open). A comment is a UUID folder under comments/ holding index.md + optional attachments/ — a card's anatomy one level down, every fractal rule applying verbatim. Field table: kind: comment, lenient self-reported author (content, not overlay — survives app writes), created/modified; no title, no order. Threads sort chronologically by created (ranks rejected: independent clocks across machines and trackers), flat this iteration with nesting deferred. Comment defects never refuse the board — stray posture at worst. Delete skips the trash, undoable without confirm. Copies carry the thread, reminted, stripping remote/remote-state so two local comments never claim one remote object. Comments are window-scoped, outside the board snapshot — the walk stays O(cards); the committer already protects them via whole-root staging, and composer/announcer describe foreign comment changes by path shape. Count chip on faces filed as WISHLIST #9. Schema edition-blind.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Stated 2026-07-29: when duplicate occurrences straddle live and trashed, the live one keeps the identity regardless of age — a restore done as a copy must never see the restored card withheld in favor of its trash ghost; the silent heal remints the trashed occurrence. Uniform across content duplicates, case-twins, and trashed lanes sharing a live lane's UUID.
Board: Minor card 94086f7b → Resolved. Minor lane now holds only tombstoned sweep cards.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The below-last-row rubber-band ruling (Resolved card 4ff6537d) survived two trash-section rewrites in code but not in prose; 04's origin clause now states the extent — full column height, card and lane rows alike, no dead zone.
Board: Minor card c9c4de2f → Resolved.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Two stale rows left by the trash rewrites: the ⌫ grammar row now names Delete staged by place instead of the retired tombstone model, and Select All carries 04's container-boundary rule.
Board: Minor card cb3bc015 → Resolved.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
User-ruled doctrine: a defect with a reliable heal heals and moves on (non-blocking notice at most); only an unhealable defect blocks, and blocking means a decision surface — load pauses, the user chooses the fix, the fix applies, the load proceeds. The loader collects every fail-fast defect in the walk rather than stopping at the first; one aggregated surface, never a modal chain. The surface's design (decision vocabulary, aggregation, reconciliation with the welcome failure row and the loading state) is filed as its own design pass — Redesign card a82a19f1 atop Issues to Resolve.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
10's inventory claim was right; 11 gains the rows Finder-style — hold ⌥ and Delete becomes Delete Immediately, surfacing as its own VO custom action per 10's cut. The trash needs no alternate since its Delete is already permanent.
Board: Minor card a69e5985 → Resolved.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The parenthetical predated the edition split and read as base never having working Undo/Redo. Now conditioned on the provider: Pro disables on no-git and repo-nested boards; base's native stack serves every board, disabling only under locks and empty stacks.
Board: Minor card 7e43562e → Resolved.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Two rulings (2026-07-29). Copy flows that stamp descendants preflight the whole subtree and refuse loudly naming the offender — the nested-uneditable silent skip is reversed; nothing partial ever lands, and post-preflight failures clean up wholesale per the construct-then-clean precedent; whole-board forks stamp nothing and stay exempt. No-sensible-reading fallbacks now log field, path, and raw text as coerce-tier Defect-stream entries — the one silent-and-unlogged recovery joins the family posture, and the stream is where real-world shapes get promoted to heuristic heals. The doctrine both rulings flow from is now stated in the taxonomy preamble: leniency recovers recoverable issues through reliable service-owned heuristics, never accepts loss that could surprise the user, and "proceed partially, lose a little" is never a verdict.
Board: Minor cards 70b3e4ba + fcc5a70b → Resolved. Code realign queued: stampCopiedDescendant leniency → subtree preflight; FrontmatterFields fallbacks → Defect stream.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Ruled 2026-07-29, new block in 12: Pro claims the .kanban default on first launch once (the purchase is the consent; a manual reassignment back is respected; base never claims). The same board open in both apps is the designed foreign-writer story — base has no git so there is no committer contention, no board-level lock (files-first stays absolute), no gate; open-now flags go per-edition on the shared record so restoration stays edition-local, and the popover gains a pid-liveness-checked "Also open in Lanework Pro" line. The clipboard staging store homes in the App Group container, making cross-edition copy/paste full-fidelity; the degraded fallback remains for genuinely missing snapshots.
Board: Gap card 7a244f9f → Resolved (Gaps lane now empty); App Group Implementation card gains the flags/staging/handler bullets.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Ruled 2026-07-29: heal-class commits (known in-session by heal-marked EchoLedger receipts) never become undo steps — the stack pointer passes over them and restores exclude heal-owned paths, so ⌘Z never reverts a repair and never re-arms the scheduler; naive acceptance would trap a ⌘Z run on an ever-renewing heal commit, and suppression would create standing invalid states. Post-relaunch reseed stays sidecar-free, so old heal commits bounce once — restore plus fresh heal commit, the fresh one transparent, the run continuing past it. Accepted family-wide; the agent-guide quirk becomes the rule's oldest case.
Board: Gap card b444bc5c → Resolved. Pro-era implementation (pro-m1).
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Ruled 2026-07-29: board windows exist before their first snapshot. Immediate window at the saved frame with registry-cached chrome, quiet spinner after a 200ms grace (no skeletons), first snapshot snaps in, ⌘W cancels the walk as an ordinary close. Restoration goes parallel — all flagged windows at once, walks independent, slow volumes never serialize the launch. Failures keep the settled welcome row surface; the loading window retires rather than morphing into an error display. No info row — the loading state is the surface.
Board: Gap card 4d95f269 → Resolved.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Extends the claimed-names displacement to a card's reserved child names: a wrong-kind node on attachments displaces by the same ladder, scheduled class, and notice, retiring the imports-fail-one-gesture-at-a-time mode one level down; the displaced file then rides the loose-file relocation into the real attachments/ — the heals compose. comments stays a tolerated stray until the tracker era consumes the name, per the load-bearing-now timing principle.
Board: Gap card 64a1c409 → Resolved.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Ruled 2026-07-29: every edition declares group.dev.rzen.indie.Kanban, and the board registry plus its Application Support peers home in the group container from base 2.0 onward — no migration, no ordering dependency, and the entitlement ships in base's first build since base ships first. A paying upgrader launches Pro onto their full recents. Stated caveat: security-scoped bookmarks never cross sandboxes, so the list transfers while access re-grants per board — per-edition grant slots on the shared record, open panel pre-anchored at the recorded path. Teams joins the same team-scoped group later with zero ceremony. Clipboard staging store stays put pending the both-editions-installed clipboard ruling.
Board: Gap card a925e530 → Resolved; Implementation card a647aba2 filed atop Backlog (before-base-ships constraint).
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
General ruling (2026-07-29): app-initiated covers every reliably healable repair — unambiguous, content-lossless — because heals are auditable: Pro's separate heal commit is the durable trail, base happens quietly, and a heal that changes user content announces with a warning-tone notice, never a consent gate. The user-gated class stays defined but has no current members. Duplicate-id healing goes silent under Lanework-owns-the-board (free to re-mint UUIDs at will): no condition banner, no Repair button — a scheduled heal remints withheld occurrences (memo-guarded, disk re-verified, no-op on vanished duplicates) and posts "Repaired duplicate id — 'Fix login'". Winner rule unchanged: earlier commit on git boards, filesystem birth date then traversal order without history. 06 keeps the Repair verb as the heal commit's name; 13 moves the remint outside undo with all heals. Dissolves the Repair-has-no-Nexus-row contradiction — no command, no row owed.
Board: Contradictions card ce5e7f60 → Resolved (lane now empty); Implementation card 50ed2b5d (Active) retitled "Dedupe duplicate ids and heal them silently" and realigned mid-flight.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The rule 01 ▸ Deletion and 03 ▸ Trash cited is now stated (ruled 2026-07-29): order is logically the container's property, stored as an attribute in each member's file for implementation convenience, so reorders, cross-lane moves, and renumber rescales rewrite index.md without touching content — no modified stamp, no modified-by clear. The trash move is the genuine exception: deletion is an edit to the item's story, stamping and clearing both, feeding the future age-based auto-purge. Considered and declined: stamping the container's own modified on reorder — fair reading, pragmatically risky to enforce, nothing reads it.
Board: Contradictions card 30c8f207 → Resolved. Code realign (Writer stamp suppression, agent guide v7) queues for the session-end realign card.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The 2026-07-29 lanes-trash-too ruling never swept the docs last touched 07-28. 11 ▸ Delete drops "lanes delete physically" for the card-or-lane move (freight-counting confirms in the trash); Delete Immediately widens to any card or lane selection; 06's recoverability exception covers both kinds and drops the retired 13-native-undo citation; 07 reads card and lane deletes; 02's welcome-counts exclusion restates as items in .trash/ (trashed lane subtracts from the lane count, its nested cards from the card count — the sibling Minor's ruling folded in). Context-menu row "Trash cards" renamed "Trash selection" on the same basis.
Board: Contradictions card 89a625bf + Minor card 488dc2c2 → Resolved.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The 2026-07-29 integrity design pass, consolidated (DESIGN/01 -
Validation and healing; DESIGN/02 - Components): IntegrityRules
(Storage, pure) is the one home for the identity predicate and
canonical form (BoardWriter.canonicalIdentity deleted, ItemID and the
loader forward to it), the per-field rulebook, uneditable shapes,
per-kind index validation, the reserved-name tables, and the trash
kind discriminator (values trusted - kind: lane/card explicit,
unrecognized falls to shape). LoadResult's ad-hoc channels fold into
one typed Defect stream (looseCardFiles / legacyTombstone /
claimedNameSquatted, per-defect heal signatures); the old accessors
survive as computed views.
HealScheduler (LiveStore) states the six-step heal pattern once -
resting-clear, lock gate, isWritableFile gate (now covering all four
heals), signature memo armed-before-attempt with explicit
clear-on-success, disk re-verify in each write half, one banner-posture
table (BannerCenter keeps all phrasing). The three hand-rolled healers
run on it with behavior preserved - including the
relocation-notice-despite-partial-failure quirk, deliberately. Heals
run at the reload tail AND at registry acquire, closing the
migration-never-fires-at-open asymmetry. Displacement runs first: a
squatted .trash would otherwise fail the migration and arm its memo
against an unchanged picture.
Claimed-name squatters (ruled today, 62c47a2) displace by the shared
Finder-style rename ladder - preserved verbatim, symlinks moved as
links, nothing stamped; AgentGuide's untouchable-skip upgrades to
displace-then-write, the CLAUDE.user.md-taken skip stands. kind stamps
on every create and backfills on any index rewrite via the on-touch
seam (placement resolver stamps nothing when the parent is unknown -
a guessed kind is worse than an absent one; board-root writers declare
theirs). Heal writes mark their EchoLedger receipts (inert in base;
pro-m1's committer will split them into their own commits). The
renumber ask-renumber-ask-again two-step is one shared helper, adopted
at all nine call sites.
69 tests added. 1738 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
A real agent incident (2026-07-29): moving cards with mv <lane>/* swept
the lane's own index.md along with the card folders, overwriting the
destination lane's identity file and leaving the source lane index-less
(the Implementation board briefly lost its lane titles; restored from
its auto-commit history). Both guide generations taught the correct
named-folder form but never said why it is load-bearing.
v6 adds the one-folder-at-a-time bullet to Moving and reordering:
a lane folder holds its own index.md beside its cards, so a glob
sweeps the identity file with them. Boards heal to v6 on their next
open per the first-line marker rule. The guide tests now derive
their expected marker and current/newer versions from
AgentGuide.version instead of a hardcoded literal, so the next bump
cannot silently break them.
1669 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
User ruling on the squatted-.trash gap: a wrong-typed node on an app-claimed
name (a file or symlink squatting .trash, a directory or symlink squatting
CLAUDE.md) is an invalid artifact, not a resident — a scheduled heal moves
it aside via the Finder-style rename ladder, preserved verbatim, with the
relocation-style notice. Upgrades the agent guide's untouchable-skip to
displace-then-write; the CLAUDE.user.md-taken skip stands (destinations
don't cascade-displace). Until the heal lands the loader keeps the
empty-trash read and a racing delete fails as a one-shot — a one-reload
window, not a standing state.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
User ruling on the integrity pass: a scheduled heal's batch never folds into
a commit carrying anyone else's work. The committer's two-commit split gains
a heal class keyed by heal-marked EchoLedger receipts; the composer stays a
pure diff reader and names heal commits from their own diff shape. On-touch
heals ride their host write's commit (the backfilled kind joins the
bookkeeping no-event class), inline heals batch with their gesture, and the
user-gated Repair already commits alone.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
User addendum to the integrity pass: kind moves from the lane table to the
common frontmatter table — board/lane/card at creation, comment reserved;
value-trusted where consulted, consequential only inside .trash/ today,
carried everywhere for consistency. A missing kind backfills on touch via
the integrity service. Guide v6 teaches kind on every object.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
01 gains Validation and healing (five verdicts: refuse / refuse-writes /
tolerate / coerce / heal; heal axes consent x timing incl. the inline mode;
write discipline decided by the existing stamp rule; disk re-verify, memo
discipline, defer-under-lock). kind re-ruled: any object may carry it, the
value names the kind, trusted where consulted, no stripping; backfill is the
service's on-touch heal, on-touch only. 02 gains IntegrityRules +
HealScheduler components — rules and policy consolidate, enforcement stays
with loader/writer.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Records the channel decision: the Mac App Store, as version 2.0 of the
existing dev.rzen.indie.Kanban record - the 1.x app already listed
there under the Lanework name. The question the card held open was
settled by three prior rulings (the edition split's separate App Store
targets with base keeping the shipping bundle id, the 2.0 marketing
version, and the account's App-Store-shaped tooling); RELEASE.md
records the reasoning and why notarized-direct is not pursued for 2.0.
Pipeline carried over from the 1.x repo: scripts/release.sh (archive +
destination=upload export, --platform macos reminders in its epilogue),
ExportOptions-macOS.plist, .env.release.example (+ .gitignore entry for
the real credentials). scripts/metadata/ holds the fresh 2.0 listing -
rewritten description/what's-new for the rewrite (plain-files boards,
live outside edits, card windows, trash, undo, templates, agent guide,
accessibility), carried keywords/URLs/categories/pricing (free)/
availability (ALL), and review notes updated to the 2.0 UI. All fields
within API limits; asc-metadata --dry-run validates end-to-end against
the live record (version 2.0 would be created; pricing and availability
already correct). Local Release archive verified: Lanework 2.0 build
165, signed, correct identity.
Remaining steps are deliberately user-gated at RELEASE.md's PUSH POINT:
screenshots need a real display, and everything past the archive writes
to the live record of a shipping 1.x app.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The golden-path XCUITest suite, adapted to current rulings where the
card body had gone stale: trash flows follow the materialized-trash
grammar (no Put Back, restore is drag or cut/paste out), git flows are
pro-m1 scope and skipped, and fixtures extend m11's in-container
--ui-test-fixture-board mechanism (the sandbox forbids the card's
--open-board path handoff) with exact-match variant flags: standard
(the three-lane audit board), large (8 lanes x 40 cards for
masonry/reflow), malformed (BoardWriter-built board with one card's
index.md overwritten to unterminated YAML, opened through the ORDINARY
path so the failure is the loader's own).
EndToEndFlowTests: create card/lane, inline rename, coordinate drag
across lanes, cut/paste, undo/redo of a move, delete-to-trash /
show-trash / restore-by-cut-paste / Empty Trash confirm - all asserting
on lane accessibility labels. FailFastLaunchTests: welcome appears, no
board window ever, a welcome row carries the loader's sentence naming
the file; byte-fidelity of the malformed board pinned unconditionally
in KanbanTests plus an identically-refused relaunch. Performance:
launch metric plus explicit wall-clock gates (30s launch / 5s Show
Trash on 320 cards) since XCTest baselines don't travel. Powerbox
panels (template save panel, Duplicate fallback, Open) are documented
as manual in EndToEndVerification.md, not faked.
The smoke test now launches on the standard fixture (it launched bare
before, opening the developer's real boards); README's everyday test
command scopes to -only-testing:KanbanTests.
Suite compiles on both schemes (build-for-testing verified); flows
await a real display + automation permission to execute - run
instructions in KanbanUITests/EndToEndVerification.md. +8 unit tests;
1669 green both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The replica framed to a font-derived nominal width, so faces in wide
lanes' interior masonry columns and narrow lanes dragged a preview of
the wrong size and the cursor could sit over empty space beside it.
CardFaceView's existing onGeometryChange now reports size, not height
alone - height still feeds LaneDropRegistry, width feeds the replica -
and BoardMetrics.cardReplicaWidth(measured:bodyPointSize:) keeps the
old figure as the documented not-yet-laid-out fallback. Same row, same
paddings, same lineLimit at the same width means the same height, so
the preview is now an exact overlay of the face it left.
Manual verification pending alongside the lane grab-point card: 3x vs
1x lanes, interior masonry columns, window re-divide, trash rows,
large text sizes, drag-at-creation fallback.
3 tests. 1661 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
SwiftUI's .onDrag(_:preview:) exposes no anchor or grab-point API and
lays the preview centered over the grabbed view, so a full-height lane
replica grabbed by its ~1.5-line title bar hung half a lane above the
pointer. DragPreviewAnchor is the pure fix: transparent one-sided
padding computed so the padded image's center IS the replica's title
bar - the system's centering then lands the bar under the cursor and
the body over the lane it was lifted from. LaneView measures the real
bar height in the geometry observer it already runs, accounting for the
accent band when the lane's color resolves.
Pixel-exact grab preservation would need re-homing the whole gesture
stack onto an AppKit beginDraggingSession path; declined for a polish
card - this gets the cursor onto the grabbed bar under either of
SwiftUI's possible placement rules. Manual verification pending (no
display here): grab specific bar pixels, colored + uncolored lanes,
large text sizes, multi-lane fan, cross-board drop geometry.
9 tests. 1661 green on both schemes with the paired card-width fix.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
App identity for both editions: the three-lane glyph
(rectangle.split.3x1.fill) over a gentle gradient - teal for base
Lanework, dark graphite for Lanework Pro - rendered as 1024 masters and
packaged down the full macOS ladder into each target's own iconset,
replacing the borrowed pathfinder icon.
The About window arrives via IndieAbout (0.2.x): icon, copyright,
version/build/date read from the Info.plist that update_build_info.sh
already stamps on every build (CFBundleVersion 160 = git commit count,
BuildDate, BuildHash - verified in the built product), the version line
opening the bundled CHANGELOG.md, and the new ISC LICENSE.md one
document link away. Both files are authored to the Apple
inline-Markdown subset (no # headings, single-line paragraphs) and
bundle into both editions.
CHANGELOG.md replaces its placeholder with the real 2.0 first-release
notes - one sentence per user-facing feature.
The About box is also an edition seam: base's EditionAbout carries the
one-line Pro signpost 12-editions.md allots ("Lanework Pro adds
git-backed board history and sync."), and the Pro target compiles its
own signpost-less twin instead of that file - the no-#if file-level
doctrine applied to a string. README gains the identity bullet.
1649 green on both schemes; verify-editions.sh 26/26.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
User-proposed and ruled 2026-07-29, retiring the design's sole
destructive delete: lane delete becomes a move into .trash/, exactly as
cards move. Full rationale on the Redesign board's Resolved card;
implementation is post-m12 ("Lanes delete into the trash" on the
Backlog) — the docs deliberately lead the code until then.
- 01: kind: lane always present on lane frontmatter (created with the
lane, healed on touch, never stripped) — discriminates lane from card
in the flat .trash/, where an empty lane is shape-identical to a
card; legacy deleted: on lanes is now simply ignored (no migration,
no notice — backward compatibility deliberately not funded).
- 03: trashed lane = opaque dimmed row with card count, restores whole
or purges whole; confirms count lane freight; fixed 03's own copy of
the "native undo restores a permanent delete" contradiction.
- 04: delete staged by place for lanes too (board→trash no dialog,
trash→permanent confirmed); lane drop on shown trash deletes;
kind-homogeneous trash selections, ranges skip by kind; ⌘X/⌘V restore
rides the existing lane-paste rule.
- 10: trashed lane is one flattened element, never a container.
- 13: lane delete inverse = ordinary move back; recreate-from-capture
retires — no destructive delete remains outside the trash.
- 08: agent guide v6 — lanes trash by move, kind: lane taught.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Closes the gap found at m10: enterUnwritableLock existed with zero call
sites. WritabilityProbe classifies the cause volume-first - a board on a
read-only DMG is also permission-denied by access(2), and "you don't
have permission" would send the user to a Get Info panel that cannot
help - with a pure classify(volumeIsReadOnly:isWritable:) truth table
and a two-syscall probe that rebuilds its URL to defeat NSURL resource
caching. ReadOnlyLockReason.unwritableLocation now carries the cause;
BannerCenter phrases the two ("this board's volume is read-only" vs
"you don't have permission to change this folder").
The probe wires once in BoardStoreRegistry.acquire, immediately after
the store loads - every open path funnels through it, and running
before the loose-file relocation and agent-guide hooks makes the
skipped-with-log guide write true by construction (its isWritableFile
pre-check demotes to second line of defense). The board still opens:
lock, not refusal.
The reconciling re-probe is now symmetric per 02's settled text - a
volume gone read-only mid-session raises the lock at the next probe
(sibling locks settle first, so a root returning read-only lands the
honest lock); the stale "deliberately one-way" comment and its pinning
test are gone. Save as Template's carve-out predicate extracted to a
testable allowsSave (behavior unchanged); Duplicate stays disabled.
11 tests added. 1649 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
User-ruled 2026-07-29: the ledger builds now in base, pre-release
(DESIGN/02 - Components - EchoLedger; DESIGN/10 - Live board
announcements). Receipts drop inside BoardWriter's four disk primitives
(atomic replace, folder move, removal, attachment copy) into a @TaskLocal
ledger that BoardStore.performWrite binds for the bracket's duration -
no call-site bookkeeping, and performWholesale deliberately binds
nothing per 02's bracket exemption. Classification is a pure function of
two snapshots: an item whose folder, index.md bytes, or attachment
listing differs is an observed change; disk matching the receipt is
app-mediated (receipt consumed), no receipt or mismatch is foreign.
Byte-identical foreign overwrites classify app-mediated (unobservable,
accepted); a foreign edit over a fresh app write classifies foreign.
The announcer now consumes per-file facts on every reload origin - the
WatchOrigin gate is gone (ReloadFacts.origin removed outright; nothing
read it after the gate fell). Reconciling sweeps announce their
receipt-less findings as foreign, closing both interim holes
(debounce-window absorption, reconcile silence). The vanishing-focus
rung gates on the ledger too: "deleted externally" would be a lie about
an app-mediated delete, and the subject's own verdict decides.
Divergence flagged: attachment imports hash the landed file right after
FileManager.copyItem rather than during the copy (the bytes do not
stream through the app); an unreadable read-back records nothing, the
direction that biases toward foreign.
30 ledger tests added, announcer suite reworked to the ruling. 1638
green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Three ruled behavior changes (DESIGN/10, resolution session 2026-07-29):
- The board-change digest covers the trash while View > Show Trash is on:
BoardDiff.between gains includingTrash, keying its card index by
ItemPath so foreign purges, restores, and Empty Trash join the digest;
crossings of the trash boundary still read deleted/restored, never
moved, on both sides of the toggle. BoardStore.land passes the store's
own isTrashVisible - no new injection seam.
- A vanished head with surviving co-selection is still named: naming and
recovery are independent axes, so BoardAnnouncer's vanished-focus rung
fires on all branches while the survivors-veto now gates only the
recovery half (recovery implies vanished, no longer both-or-neither).
- Banner-row buttons are literal FKA Tab stops: BannerRow.controls is
the row's testable button inventory, BannerRowView renders from it
with .focusable() on each button; the combined VoiceOver element stays
unconditional - custom actions and Tab stops are independent surfaces.
19 tests added, 2 expectations updated to the rulings. 1607 green on
both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
User-ruled 2026-07-29: the ledger's construction slot returns to base
("right first") rather than riding with pro-m1's auto-committer. 10 now
pins that the announcer consumes per-file facts on every reload origin —
reconciling reloads' receipt-less changes classify foreign and announce
(the launch-catch-up doctrine applied to speech), closing both interim
holes: debounce-window absorption and reconcile silence.
Implementation filed as "Build the EchoLedger" (order 65920) on the
Backlog; pro-m1's auto-commit card annotated to consume, not construct.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Settles the m11-accessibility findings and two undo-doc findings from the
Redesign board (rationale per card in its Resolved lane):
- 13: permanent delete stays non-undoable — the trash-section clause
contradicting Rules is amended; staleness validation reads disk, not
the snapshot (blessing the implemented false-skip fix).
- 10: VO-Space toggles on any selectable element; the digest covers the
trash while shown; a vanished head with surviving co-selection is
still named (naming and recovery are independent axes); banner-row
buttons are literal FKA Tab stops; lane-title landmark+heading
doubling accepted; trash visibility wording pinned; the custom-action
cut confirmed; banner transitions are origin-independent; the AA
obligation binds the primary label tier.
- 03: the palette AA claim rewritten to the real mechanism — one
ink-selection code path for palette names and hex, pinned by
PaletteContrastTests in both appearances.
Code-conformance residue filed as "Realign code with the 2026-07-29
accessibility rulings" atop the Implementation Backlog.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The automated half: AccessibilityAuditTests runs performAccessibilityAudit
over all eight surfaces DESIGN/10 names — board with trash hidden and
shown, card window in Preview/Edit/raw source, welcome, template chooser,
board popover. One audit per test, .all audit types, no issue handler —
nothing waived; a future false-positive excusal must match one element on
one surface with its reason beside it. Navigation is menu-bar titles and
the arrow grammar; no accessibility identifiers added to production code.
The suite launches with --ui-test-fixture-board: the sandbox forbids
handing the app a temp-folder path (no bookmark behind it), so the flag
carries no payload and the app builds a known board inside its own
container through the ordinary BoardWriter door — three lanes, six cards,
a rich Markdown body with attachment, one card already in .trash/ — with
the registry redirected to the same scratch directory so audit runs never
pollute real recents. LaunchPlan replaces the restore Bool (welcome /
restoreBoards / uiTestFixture, fixture wins outright), decided once in
KanbanApp.init and dispatched by RestoreBootstrapView; pure and pinned by
UITestLaunchTests, and the fixture itself is materialized and read back
through BoardLoader in units — the only proof available headlessly.
The manual half: KanbanUITests/AccessibilityVerification.md is the one
document — the audit suite at the top (it needs a real display and
Accessibility permission), the per-release VoiceOver smoke script with
expected utterances quoted from AccessibilityPhrases, and the
consolidated m11 checklist from all four implementation cards.
1588 unit tests green, UI target compiles, both schemes build. The audit
run and smoke script await a real display — the manual pass is the
user's.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
DESIGN/10's ≥ 4.5:1 rule gets its owner. ContrastMath is the pure seam:
WCAG relative luminance (piecewise sRGB linearization), symmetric
contrast ratio, source-over compositing (an #RRGGBBAA board colour
resolves over the window background of the active appearance), and
inkChoice — native label if it clears AA, else the other appearance's,
else the higher ratio with meetsAA false (a mid-grey hex can max out
below 4.5 against both 85%-alpha labels; the app paints the best
available rather than overriding the user's colour). BoardTextInk is
the board's application: the decision is a ColorScheme, not a Color —
the text on the board fill is a hierarchy (.primary/.secondary/
.quaternary), and overriding the subtree's scheme moves the whole
vocabulary coherently. Recomputed on appearance change by construction
(read in body); label/backdrop colours resolve inside the asked-for
appearance, Increase Contrast variants included.
Two render sites — the only board text that sits on the user's colour:
the lane header (lanes draw no plate; title, icon, badge, rename field
and the + button land directly on the board fill) and the trash header
(its wash is ~5% effective alpha). Menus, popovers, and drag replicas
deliberately stay native; card faces carry their own opaque plates.
The card's premise fell during implementation: 03's "palette pairs
AA-verified at design time, pinned by a computed-contrast unit test"
was false — no such test existed, and the m4 path drew the native label,
failing AA in one appearance for all twelve wells (obsidian in Light
Mode: 1.0:1). Palette names now route through the same ink selection
(paintedColor delegates to Palette.nsColor — one predicate with
BoardView's paint decision), and PaletteContrastTests pins that the
chosen ink clears AA for every well in both appearances — plus
theNativeLabelIsNeverEnough, which would have failed on the m4 code.
Filed on the Redesign board for ratification. 1579 unit tests green,
both schemes build.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Full relative text scaling per DESIGN/10: BoardMetrics is the board
strip's geometry as a pure function of the body point size
(CardWindowMetrics' twin) — lane plate/header/band, card
corner/stripe/padding, masonry spacing, the drop model's nominal card
height, resize-handle geometry, trash hatch pitch, and both window
floors all derive from an em; CardFaceMetrics folded in. The two fixed
font sizes (welcome brand/glyph) went relative; the toolbar search
field is 17 ems like the transient bar's. The no-horizontal-scroll
invariant is pinned by test at six text sizes by twelve lane counts.
Accommodations is Motion's sibling for the visual settings: Increase
Contrast adds a flat point to strokes (monotone, hierarchy-preserving),
gives borderless card/lane plates a resting separator hairline, and
takes faded accents to full alpha; Reduce Transparency turns the
transient search bar's glass solid and does the same for the alpha
washes that composite over a user-chosen board background (trash plate,
hatched header, drag shadow). Reduce Motion audited — every animated
surface already routes through Motion with a reduced variant; no gaps.
Full Keyboard Access: the template chooser's tiles were pointer-only —
now focusable, arrow-navigable (clamped, StyleWellGrid's rule), Space
picks, Return stays the sheet's default action, focus names the
selection one-way. The board's single tab stop shows its focus ring
under FKA (focusEffectDisabled inverts). Style editor verified already
conformant. Edge accents verified text-free; trash hatch pitch now
font-derived so it still reads as hatching at large text.
1549 unit tests green, both schemes build.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The board speaks when files change under the user, per DESIGN/10 § Live
board announcements. BoardDiff is the pure snapshot summarizer (identity
sets for cards/lanes added/edited/moved/deleted — ids, not tallies, so
pro-m1's semantic commit engine can build on it; edited = rendered
content only, moved beats edited, implied events don't steal the
subject). BoardAnnouncer is the decision seam: focusOutcome computes the
vanishing-focus sentence and the walk-up-then-sideways recovery (next
lane by order, else previous, board container only when none remain,
never the trash); speech(for:) is the one-sentence precedence ladder —
raised condition > bracket completion > cleared condition > vanished
focus > digest — foreign-only for the last two rungs, so app-mediated
echoes stay silent.
BoardStore.land assembles ReloadFacts and posts exactly one sentence per
reload through the injectable announce outlet (AccessibilityAnnouncer,
medium priority, never interrupting). Selection recovery layers on top
of ItemReferenceSet re-resolution — survivors veto, the emptied
selection lands on the vanished item's lane and re-arms ⌘N's active-lane
memory. performWholesale(announcing:) arms a completion phrase consumed
by the closing reload — nil on every base bracket today; pro-m1 fills
git phrasings. Locks raised outside the reload path (vanished root,
unwritable location) announce through the same ladder, and the banner
strip is a labeled "Board status" container whose row labels are the
announced sentences (AccessibilityPhrases.bannerLabel — one string for
eye and ear).
Announcements classify at reload granularity (WatchOrigin) as a
deliberate interim: DESIGN/02's EchoLedger (per-file classification, the
announcer's specified input, git-free) was scheduled with the
auto-committer that the edition split moved to pro-m1 — filed on the
Redesign board for a ruling. 1533 unit tests green, both schemes build.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The board window's accessibility tree per DESIGN/10: lanes are containers
labeled "<title>, lane, N cards" (filter-aware count = renderedCards, the
badge's own collection); cards are one flattened element each — label =
title or the untitled placeholder, value = attachment count + "cut,
pending paste", selection via trait; face icon, stripe, and paperclip are
decorative and hidden. Masonry never leaks into traversal: slots carry
order-keyed accessibilitySortPriority, so a wide lane reads by card order,
not column-major. Lane titles carry the heading trait for the rotor.
VO-Space is the ⌘-click analogue routed through the existing
BoardStore.click funnel (SelectionGrammar stays the single answer for
toggle and container-boundary rules) — cards and lane headers both.
Context-menu rows double as custom accessibility actions, each calling
the same private method as its menu row so the surfaces cannot drift;
trash cards expose Delete and Reveal in Finder and never Open. The trash
column is pinned last via sort priority 0, its label/value re-routed
through the new AccessibilityPhrases seam; toggling trash visibility
posts a one-line announcement from the store seam (both command faces).
The invisible lane-resize drag strip leaves the tree — the stepper and
menu items are the accessible width path.
AccessibilityPhrases is the pure vocabulary seam (labels, values, plural
folding shared with TrashModel.phrase), pinned by its own test suite.
Both schemes build; 1466 unit tests green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The v5 guide prose, verified two ways. A scripted walkthrough gave a
fresh agent nothing but the guide and a demo board: it created a card
(fresh lowercase UUID, correct bottom rank), moved one to a lane top
with modified and modified-by re-stamped, deleted one into .trash/,
attached a file into attachments/, picked `fern` off the palette table,
and quoted a colon title — and the resulting board loads through
BoardLoader with zero warnings. The walkthrough's one finding is fixed:
the trash-arrival rule now reads formulaically ("smallest order minus
1024") instead of the spatially ambiguous "below the smallest order".
Content drift-guards join the suite: every palette name the app resolves
must appear in the guide (a Palette rename now fails a test instead of
teaching agents dead colors), the rewrite's conventions are present by
name (.trash/, attachments/, modified-by, CLAUDE.user.md, the
stage-only-your-own-paths rule), and the pathfinder's retired vocabulary
(media/, tombstones) cannot resurface — the only deleted: mention is the
warning never to write it.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The app-owned agent guide at every board root (DESIGN/08 ▸ The agent
guide): version-gated by a first-line marker (v5, superseding the
pathfinder's v4 guides on real boards), rewritten when missing or older,
byte-for-byte untouched when current or newer. A markerless CLAUDE.md is
displaced to CLAUDE.user.md when that name is free — never clobbered —
and the guide write is skipped with a log when it isn't. Symlinks,
folders, and read-only volumes are skipped in silence; the write rides
performWrite's bracket as an app-mediated Writer operation (new
WriteOperation.agentGuide), so the echo lands appMediated and the
Pro-era committer can attribute it honestly later.
Hooked at store acquire (beside the loose-file relocation, after the
watcher exists) and on every successful reload — the guide self-heals
from foreign deletion or rollback, pre-wiring 06's acknowledged undo
bounce. The refresh memo arms before each attempt and clears on a
successful write, so a failing write can't hot-loop and a foreign
deletion stays healable. First-line-only marker parsing (no Regex);
guide content is one swappable literal, finalized under the next card.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The chooser completes its three tiers: bundled by template order, then
keyed user templates, then keyless boards by display name — and a
malformed user template still lists, by folder name with the loader's
own sentence on the row, never failing its neighbours. The store is
re-scanned on every presentation and on app activation, the Reveal
round trip made honest without watching a folder 09 deliberately
leaves unwatched; Reveal lives in the chooser's header and mints the
store on first press. Save as Template repeats Duplicate's sequence —
progress row with Cancel, flush, detached cancellable copy — through
the engine: mint the store, read the next user order before the copy
can count itself, Finder-ladder the name, copy excluding .git and
.trash/, then stamp the whole template: mapping on the landed copy
through updateIndex, with no bracket because the copy lives outside
every watched board. Folder attributes deliberately don't carry — the
one lock the command stays live under is the read-only-DMG one, and
carrying its mode bits would mint a read-only template in the user's
own store; the command gates instead on the real hazard, unsaved card
content. A signpost names the template only when the ladder renamed
it. One name ladder now serves Duplicate and the store.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The pathfinder's catalog transcribed verbatim as real board folders —
lane titles, icons, palette colors, board icons, and blurbs read
straight from its BoardTemplate.all, every color already in the
palette, no invented fields, no starter cards it never had. Basic had
drifted from the source in the engine card's version: it carried
icon/iconColor keys the pathfinder's key-less Basic never wrote
(visually identical via the fallback, byte-level wrong) and a
rewritten blurb — both restored. Orders run 100 to 1000 in inventory
order, Basic first; fresh lane UUIDs, gapped ranks, one consistent
timestamp; the discovery test now pins all ten slugs, lane counts,
Basic's primacy, and distinct ascending orders through the real
loader.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
A template is a board folder the ordinary loader reads — no second
schema, no Swift catalog. BoardTemplate became exactly that: a loaded
BoardModel with chooser-facing derivations, the lane-title stub gone.
TemplateEngine instantiates by the copy-remint-restamp walk: .git and
.trash excluded at top level only — both names mean something at a
board root and nowhere else, and .gitignore must survive — every
materialized folder reminted, created/modified stamped fresh (born
today, not forked), modified-by cleared, the template: key carried
inert, the blurb and style inherited, and loose card files normalized
at this import boundary per the paste precedent so a new board never
opens with a notice about a mess its own birth made. Legacy deleted:
keys copy through verbatim to the one migrator — stripping would
resurrect, skipping would destroy. Atomicity is construct-then-clean:
a sibling temp can be sandbox-refused and a cross-volume rename is
just a second copy, so the call removes what it created on every
non-board exit and never touches an occupied destination. The
cancellable per-item walk extracted into BoardTreeCopy serves
Duplicate and instantiation with two parameters — top-level exclusions
and folder-attribute carriage, the only axes they differ on.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Phase 3 finishes the pivot at the surface. One card face serves two
containers: CardFaceView extracted with a role — board or trash — so
stripe, tint, chip, selection stroke, cut dim, marquee registration,
and drag are shared by construction, the trash side differing only in
its absences: no Open, no rename, no Style, no file-hover highlight,
and a Delete that goes through the confirmation host. The column
rewrote around the lanes' own single-column masonry so drag reflow
reads as positional slides; chrome stays the hatched header, symbol,
and count — 11 gives Empty Trash to the File menu alone. Two real
grammar bugs die here: plain Backspace on a trash selection purged
without the confirmation the menu raises, and the context menu's
Delete resolved against the standing selection, so right-clicking a
trash card under a board selection silently did nothing — it now
stages the clicked set explicitly. Open, Rename, Style, and Empty
Trash validation became testable store seams; the column is one named
accessibility container of ordinary card elements. The tombstone era
is swept: deleteItem, restoreItem, stripTombstonedChildren — dead
since lane copies stopped nesting trash — the restore verb, the
unreachable put-back banner row, and every quasi-lane doc comment.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The tombstone model is retired wholesale: deletion is a move into the reserved
<root>/.trash/, always entering at the top (rank minting — no deleted: key, no
timestamp sort), with modified stamped for the future age-based purge. Lanes
are never trashed — lane delete is physical, native undo in-session the net.
Put Back is gone: restore is drag-out or cut+paste (⌘X now enabled in the
trash as the keyboard restore path). Trash cards are ordinary cards — search,
selection, clipboard, and rendering uniform — and one container boundary
replaces the effective-liveness, ancestor-walk, and kind-homogeneity machinery
across 02 and 04. Delete stages by place (board moves, trash deletes
permanently under the recoverability confirm); legacy deleted: keys migrate
without destroying (cards relocate, lanes return live, notices). Templates
exclude .trash; Duplicate carries it; the agent guide teaches delete-as-move.
Materialized reserved lanes are named as a pattern — .archive is WISHLIST #8,
and the backed-out release-presentation question is marked open in 03.
Ruling: Redesign board Resolved card e8f1a6c2; 19 mooted tombstone-era rulings
moved to the board's new Mooted lane.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
Phase 1 of the trash pivot: the file format learns .trash/. The loader
parses the reserved root container — cards only, one shared parseCard
for both containers so fail-fast, attachments, and verbatim documents
are literally the same code; absent means empty; symlinks and
lane-shaped nestings fall out as strays by construction. BoardModel
grows snapshot.trash as a plain rank-ordered card list — the container
has no identity to carry. Legacy deleted: keys keep flowing through
the retiring flag path so every tombstone consumer stays green, and
are additionally reported through LoadResult.legacyTombstones in the
loose-file idiom for phase 2's migration scheduling — nothing vanishes
from view before its folder has actually moved, which is also 01's
lock-deferral posture. Writer primitives land value-passing: move to
trash with caller-minted rank and the deliberate modified stamp,
tombstone migrations that surgically remove the key, physical lane
removal, per-card and whole-container purge that leaves strays
verbatim, and byte-faithful whole-subtree capture/recreate for lane
undo. Board-wide identity now spans the trash, so an import colliding
with a trashed UUID remints instead of colliding. The watcher already
delivered .trash events — isGitInternal tests a component, not a dot —
now stated and pinned rather than relied on.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The command surface was already almost entirely platform machinery —
this card proves it and pins it. Headless probes established that
NSWindow.validateMenuItem answers enablement AND rewrites the row title
from the delegate-supplied manager, so 'Undo Move 3 Cards' flows step
phrase to Edit menu with no code of ours; under the lock the rows dim
and keep their names, the correct reading of the-stack-survives. The
toolbar twins validate through validateUserInterfaceItem, which never
touches labels — 03's static-label exception proven rather than
asserted — and their specs' enablement abstention is pinned so nobody
later adds a second, disagreeing answer. The one link a headless run
cannot close is the nil-target key-window resolution itself: standard
responder-chain behavior with none of our code in it, left as the
manual check. Base-edition 'disabled without undo' scaffolding is
reworded away — every base board has undo now. New suites cover the
trash's two doors (delete-then-undo byte-identical to Put Back's
effect), position-preserving restore of a middle card, and the
capstone: five gestures forward, five presses back to the origin
board, five forward again, the menu phrase asserted after every press.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Every crossing validates its expectations before writing: each step
carries per-item HistoryExpectations — folder, effective ancestor-walked
liveness, and exactly the fields the gesture set — and a mismatch pops
the step, posts the signpost ('Undo skipped — Fix login changed outside
Lanework'), and falls through to the next. Validation reads disk, not
the in-memory snapshot: the snapshot is by construction one reload
behind every app write, so a rapid second undo would false-skip against
the pre-state — disk is what current can honestly mean at press time.
Stale and failed part ways: a stale step is one the board moved past,
so dropping it loses nothing; a failed one is refused by a usually
momentary condition, so it stays put and the crossing stops with only
performWrite's own error row — which forced the provider off
NSUndoManager onto two plain arrays, since a popped group cannot be put
back. The read-only lock disables Undo/Redo through the adapter while
the stack survives to resume on clear. Delete and restore validate
presence alone — a machine timestamp is not a decision — and a
malformed field matches nothing, since it is a shape the app never
writes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The store is the Writer boundary, so it computes and registers
inverses: a weak history sink bound at session composition, one
HistoryStep per gesture at exactly the brackets that were already one
performWrite each — multi-card moves, style batches, width pairs, and
multi-row restores each undo as one plurally-titled step, and the Edit
session registers once at the flip from the bytes disk held before its
first landed write, debounce ticks registering nothing. Crossings run
through performWrite, so an undo brackets the watcher, echoes through
the reload, and reaches every window; every closure captures values,
never snapshots. The inventory follows 13 exactly: moves return to
origin lane and order, renames restore or remove the title key,
restyles and resizes restore field values or absence, tombstones and
restores swap with captured timestamps, and an undone create is a real
removal — no trace — with redo re-materializing the same UUID from
bytes captured at gesture time. Purge, attachments, repair,
bookkeeping, checkbox flips, raw Apply, and the whole arrival family
register nothing, each exclusion documented where it lives. Step names
speak 06's verb vocabulary through the new HistoryPhrase.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The provider seam 12 promised: HistoryProviding speaks 13's vocabulary
— register a HistoryStep (bare 06 phrase plus undo/redo closures
returning applied or skipped), canUndo/canRedo, action names, clear —
and no UndoManager type appears anywhere in it, proven by a fake that
satisfies the seam with counters. The base provider wraps a private
UndoManager with groupsByEvent off so coalescing stays the Writer call
site's decision; undo re-registers the reversed step from inside the
undo, which makes a stale-skipped step vanish for free and the
crossing loop fall through to the next. BoardUndoManager adapts the
protocol to the responder chain — a stackless UndoManager subclass
answering from the provider — so Pro's git provider inherits menu
enablement, dynamic titles, and the nil-target toolbar pair by binding
the protocol. One stack per board session, born in beginSession,
cleared in the close flush; every window over the board answers it
through windowWillReturnUndoManager. Headless probes shaped the
routing: a real NSTextView's own manager wins natively, but a field
editor's does not — BoardUndoRouting answers the per-window text
manager while any NSText is first responder, so a search-field typo
never crosses a board step.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
12's settled ruling supersedes the m4 placeholder: on an ordinary board
the git section is simply absent — the popover is complete in itself —
and on a board carrying an inert .git it shows only the calm one-line
note, 'This board has a git history. Lanework Pro works with it.' The
detection is a pure one-line seam checking the board root at popover
open, deliberately non-live: .git is filtered from the watch by design,
so there is no reload to hang a live fact off, and a quiet signpost
self-corrects on next open.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Two app targets from one source tree — no build flags, no #if in
shared code: an edition difference is a file one target compiles and
the other does not. Base keeps everything it had (dev.rzen.indie.Kanban,
minimal entitlements, AppIcon); KanbanPro compiles the same sources
plus the reserved KanbanPro/ root (Git/, Remote/, Auth/ land with
pro-m1 — libgit2 deliberately not added yet), adds network-client and
its keychain group, and hand-writes its Info.plist with the UTI block
verbatim — base exports the type, Pro imports it, one format either
app opens. The unit-test sources compile twice, once per host, with
Pro's module aliased so 56 test files keep @testable import Kanban
unchanged; scheme Kanban stays the muscle-memory command and
LaneworkPro joins it. InertGitTests pins the base posture with bytes
and mtimes — a full editing session over boards carrying realistic
.git trees at root and nested in a card leaves all twelve entries
untouched, and moves and copies carry them verbatim.
scripts/verify-editions.sh proves the rest: 26 checks over signatures,
symbols, entitlements, identity, and the shared UTI, discounting
Xcode's test-host exceptions by name rather than silently.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
NSToolbar through the existing HostedWindowController rather than
SwiftUI's toolbar — for reasons that are contract, not taste: 03's
transient-search clause is a decision over the toolbar's current
contents, which NSToolbar publishes and SwiftUI's API cannot answer;
Undo/Redo are the system's nil-target responder-chain actions so the
toolbar items validate exactly as the menu rows do (disabled on base
boards, alive in m8 unchanged); and the search item hosts the real
NSSearchField with explicit first-responder control. Customization is
all system furniture — Customize sheet, drag rearrange, display-mode
popup, overflow, autosaved per window kind. Board default: the search
field alone, trailing; catalog adds New Card, New Lane, Undo, Redo,
Show Trash, every action extracted from its menu command so no second
predicate exists. Card default: the Edit Body / Raw Source toggles and
Add Attachment, mirroring their commands' own predicates live via
observation tracking. Removing the search item keeps the promise —
⌘F surfaces the same field as a transient strip under the title bar,
persisting until the query clears, and an overflowed item that cannot
take the keyboard falls through to the strip too.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The sidebar completes: the shared style editor gains a second anchor —
StyleEditorLayout carries the geometry (the popover keeps its settled
268/14/7/8 untouched as the default; the sidebar packs columns to its
width with no inner scroller) while every well, the batch display, the
arrow grammar, and the one applyStyle bracket stay the shared
component's. The card anchor is fixed, not tracking: the target is
this card, and the fate walk retires the window when the card goes.
Details renders every unknown frontmatter key read-only in file order —
Card.document already carried them — showing the author's own bytes
where the raw span is a value and the engine's rendering for block
scalars and empties; reserved enhanced-schema keys are ordinary
unknowns, and no keys means no section. Actions: Delete rides the same
tombstone bytes as Backspace and drop-on-trash through a one-line
seam, says nothing about selection, and lets the fate walk dismiss;
Reveal in Finder resolves through the attachment scope so the two
paths cannot disagree. History reserves its m7 slot without drawing a
header no base board can honor.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The card's complete file inventory: compact QuickLook-thumbnail rows
over Card.attachments — no reference tracking, subfolders tolerated
and unsurfaced — with a quiet header add affordance and the drop hint
empty state. The whole window is the file-drop surface, Edit mode
included (the editor's drag types were already filtered; now tested),
sharing the board's folder-refusal semantics literally: FinderDrop
moved verbatim into its own file so both windows run the same
partition and loss row. Dragged text still lands at the caret and is
inert elsewhere — the window delegate accepts file payloads only.
Rows open on double-click or Return, drag out their file URL, and
Remove is a bracketed write through FileManager.trashItem — the system
Trash, never a hard delete, returning the in-Trash URL so the promise
is testable; the attachment listing is the guard, so traversal and
subfolder names refuse in one line. Keyboard-native per 05: the
section is one Tab stop, arrows walk rows by name, Space toggles the
shared QuickLook panel, Backspace removes. File > Add Attachment
(shift-cmd-A) comes alive through the same import path.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The escape hatch: View > Raw Source (opt-cmd-E) unmounts the whole
content area for the literal on-disk index.md in a plain monospaced
editor with Cancel/Apply. Raw source is window-level state, not a third
body mode — entry rides setMode(.preview), which flushes the Edit
session by construction, then reads the file fresh; exit reveals
Preview, and an empty body after Apply does not reopen Edit (openIfNeeded
already ran). Apply validates the proposed bytes through the loader's
own card checks — parseDocument's strict UTF-8/BOM rejection, schema,
order — deliberately skipping the uneditable-shape refusal, since a
flow-mapping card is exactly what the hatch repairs; invalid bytes
alert in place with the loader's own error and no bracket opens. The
write is byte-for-byte with no modified stamp and no modified-by clear,
per 01's explicit carve-out — the verbatim contract outranks stamping —
and identical bytes write nothing. Escape cancels, cmd-Return applies,
toggle-off applies too, and cmd-E disables while raw is active via a
testable predicate. Tombstoned targets refuse as vanished: a foreign
delete is never reverted by a stale buffer.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The editing surface: the same hosted TextKit-1 text view gains an
editable branch with a per-keystroke line-scanner highlighter — chosen
over a parser re-parse because a mid-typing buffer is usually invalid
Markdown and 05 wants the delimiters themselves dimmed; apply only sets
attributes, so presentation-never-transforms is structural. Saves ride
a ~700ms injectable debounce through BoardWriter.writeBody —
toggleTaskMarker's idiom widened to the body span, frontmatter bytes
untouched, refusing to write when disk already holds that body, which
enforces all three gates (untouched, reverted, echo) at the layer that
owns the bytes with one isDirty predicate above it. Mode grammar lands
whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape
returns, and every flip flushes first; window close flushes through
the existing retry/save-copy/discard modal, and the dismissal flush
deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always
follows the snapshot, the buffer only when clean, both surfaces render
the buffer. Undo is the editor's own session-scoped NSUndoManager;
endEditSession names the pro-m1 one-commit-per-session boundary.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The card body's resting state: swift-markdown (pinned 0.8.0, smart
typography off — Preview renders the bytes on disk) parsed into a pure
BodyMarkup model with UTF-8 source offsets, rendered on one hosted
TextKit 1 NSTextView — chosen because find-in-text is NSTextFinder,
checkbox clicks reuse AppKit character hit-testing, links are .link
attributes, and NSTextTable's automatic layout is exactly the
columns-sized-to-contents rule. The GFM subset renders per 05; HTML
stays verbatim code-styled text; relative images resolve against the
card folder while remote URLs are never fetched, drawing a quiet chip
instead. Task checkboxes are live: a click flips exactly one byte
through a fresh-read, refuse-uneditable, stamp, atomic-replace write —
the app's only offset-addressed write, so a moved target refuses as
staleTarget and what the user saw decides the direction, netting one
toggle on a double-click. Empty bodies open in Edit per CardBodyMode's
opening rule, applied once; the Edit surface itself stays an honest
read-only stub until its card. FindCommand prefers the card body's
find over board search when a card window is focused.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The settle rule 3f4125e introduced — at release the shadow swaps for the
dropped card(s) drawn in place immediately — is withdrawn on review. The
board returns to the presentation the committed-overlay hold has always
had: the shadows keep standing at their landing slot and the originals
keep their lift until the echo reload lands, whatever the operation was,
and the real faces arrive with the snapshot that carries the write.
Gone with it: the DropLanding/DroppedItem seam and the three landing
accessors, the hold's landing/operation payload and the removesOriginals
and keepsIdentity halves it decided, the pickup-captured titles a
cross-board arrival's face needed, LaneSlot.dropped with DroppedFace and
DroppedCardFace, the strip's dropped-lane run, and the trash column's
settled tombstone rows — the same presentation at the delete gesture's
end, which goes for the same reason while the drop-to-delete write and
its shadow row stay exactly as they were.
Kept: the hold itself, which predates the commit, along with the 1500 ms
failed-write timeout, its injectable duration and its extracted expire,
and the guard that keeps a settled release past retargeting. The tests
pinning those stay; the ones asserting what the landing slot drew are
gone, and the suite now pins the hold's own claim — the arrangement does
not move at the release.
The pause between the release and the echo is therefore an open design
question again, filed on the Redesign board.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The m4 scene plumbing was already honest — one WindowGroup value per
CardWindowRef enforces one-window-per-card, and CardWindowFate's
ancestor walk answered dismissal — so this card fills the window: a
two-column shell whose body column takes all resize flex and whose
sidebar width derives once from font metrics (26 characters of average
body advance plus em gutters), the five 05-ordered section headers as
placeholders, and the card body as selectable plain text until Preview
mode lands. The fate walk now returns a CardPlacement (card + lane), so
one pass answers both liveness and the live board › lane subtitle; a
board rename lands for free through displayName. Card windows remember
their frames per card in the board record (case-folded id keys,
unchanged-writes-nothing), restoring instead of cascading; only
unremembered cards take the last-used size and cascade. Store
acquisition stays gated on liveStore — a card window never opens a
board — and the close-flush hook stands with nothing to flush until the
Edit-session card.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
01's Lanework-owns-the-board carve-out: a regular file beside a card's
index.md belongs in attachments/, and the app moves it there. The
loader detects read-only — a new LoadResult.looseCardFiles channel,
separate from the stray-tolerance warnings because it says the opposite
thing — skipping directories, symlinks, hidden entries, and the
reserved names compared case-insensitively (on APFS, Index.md IS the
index). The relocation rides one performWrite bracket at the tail of
every successful reload, which makes lock deferral free: the reload
that lifts a read-only lock is the reload that relocates. A
lane/card/filename memo keeps a failing relocation from hot-looping —
one one-shot, then silence until disk changes. The notice rides the
loss-row class, phrasing folded by BannerCenter (one file, one card's
files, a multi-card sweep), naming original filenames per the
importAttachment rule. Paste normalizes at the import boundary: staged
snapshots' loose files land in the pasted card's attachments silently,
every arrival path declaring its side via an explicit
normalizingLooseFiles parameter — drag paths decline and fall back to
the destination's own carve-out. checkIsCardFolder closes the hole
where a lane's notes.txt would have been relocated: card depth is
exact, UUID under UUID.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Creation now clears the search by mechanism, not gesture: one seam
(noteUserCreation) states 04's rule once, called from the placeholder
funnel, paste — cards and lanes, after the staleness guard so a stale
paste clears nothing — and Finder file-drop creation; the attach path
deliberately doesn't clear, and cross-board arrivals and New Lane stay
outside the seam (a transfer isn't creation; a lane can't be born
invisible). An open inline rename now survives the filter hiding its
card: the model already kept the editor, but the field renders in the
card's slot, so renderedCards keeps the renaming card's slot exactly as
long as the editor is open — the query stands throughout, and commit or
Escape lets the predicate apply in the same pass. Verified conformant
and newly pinned: query-emptied lanes keep their slot with a 0 badge,
pasteboard staleness (takeover before paste and mid-staging both no-op),
out-transition reachability and the strip pre-divide hold by
construction with comments citing their rulings.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
04's settled clauses were mostly shipped already — the create landing
resolved through DropSlotMath.cardSlot with one nominal shadow per
importable file — but a release on the lane header fell through to the
card zones, which clamp inward, so a scrolled lane could propose behind
the header stripe. FileDropZones now folds header, attach hit-test, and
card-slot resolution into one pure seam asked in that order, the header
answering topmost per the ruling; lane headers register their frames
for it. FinderDrop.shadowCount names the floor-at-one rule. New tests
pin the header boundary, a differential against cardSlot's own zones
(same zones, not similar), and a store-level differential proving a
file landing takes the very ranks a card move there takes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
04's ruling makes the drag the pointer's delete gesture: the shown
trash column accepts live same-board card drags, the shadow pinned
topmost — honest, since the trash sorts by deleted newest-first — and
release tombstones through the same write path as Backspace, extracted
so the two gestures cannot drift. DropTarget grew a container case for
the quasi-lane (it has no lane id by construction); lane drags,
cross-board arrivals, option-copies (re-checked at release, the one
input that can flip without a callback), trashed-side payloads, hidden
trash, and the read-only lock all refuse — and a refusal falls through
to the strip retarget, never cancelling the drag. The settle draws the
tombstoned rows in the trash under the cards' own GUIDs, so the echo is
an invisible content swap and nothing winks out for a round trip.
Selection needs no surgery: the reload's resolve rule ejects tombstoned
members as the vanish it is, pinned by a test contrasting both gestures.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
03's resettlement reverses the pathfinder carry-over: the
selection-keyed dual presentation proved undesirable, so a card has one
presentation — selection changes styling, never geometry, and the
masonry never reflows on click. Deleted the carousel view (page dots,
glass underlay, scroll-tick monitor), the QuickLook thumbnail cache
(sole consumer), the pure paging/suppression rules, and the
sole-selected animation key — Motion now keys transactions on the
search query and the drop proposal only. The attachment chip stays as
the face's whole attachment story; viewing media is the card window's
job. No carousel state had leaked beyond the view layer.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
03's sharpened settle rule: rendering the arrangement means rendering
the card — at release the shadow swaps for the dropped card(s) drawn in
place immediately, the appear never waiting for the echo reload. The
committed hold now carries the landing (ids, payload titles, operation)
and surfaces read one DropLanding seam: within-board moves draw the
real faces at their proposed slots under the arriving card's own key,
so the echo is an invisible content swap; cross-board card arrivals
draw payload-titled faces keyed positionally, so the echo reads as an
ordinary arrival. Cross-board lane arrivals deliberately keep their
shadow until the echo — a lane's face is a whole column with no honest
payload equivalent. The 1500 ms failed-write timeout is now seamed
(injectable duration, extracted expire) and pinned by tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
04's settled ruling: the attachment model is flat top-level files, so a
drag containing only folders never engages — no highlight, no proposal,
the standard incompatible-payload cursor — and a mixed drag proposes for
its files only, importing them at the drop while a loss row names the
skipped folders. Hover reads the providers' registered types (anything
conforming to public.directory refuses, packages included); commit
re-partitions authoritatively from the filesystem, so a synthetic
payload that hides its type still can't land a folder. The create path
now only ever fires with at least one importable file — the
mint-fail-remove dance is gone from the folder case and stays reserved
for genuine mid-batch failures.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
02 rules the trashed side has exactly one definition: the set with
trash rows — a card carrying its own deleted: under a tombstoned lane
is in neither universe, so an anchor or selection can never survive on
an item that renders nowhere. The membership rule now lives once, in
Liveness.walk, and ItemReferenceSet.idUniverse, TrashModel.entries,
paths, and emptyTrashTargets all derive from it — the old
lane-OR-card logic that admitted subsumed cards to the trashed side is
gone, and the two universes deliberately no longer partition the
board. Put Back, Delete Immediately, and Empty Trash outcomes are
unchanged: a tombstoned lane still moves and purges whole, its nested
tombstones with it.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Rulings written in place: the drop settle yields to the system drag session
(fly-to-slot deferred to WISHLIST #7) and the held proposal's home is the
app-wide DragSession — rendering the arrangement means rendering the card at
release (03, 02); folder drops refuse at hover with loss-row reporting, created
cards land positionally, N shadows, header resolves topmost, shadow-only
feedback (04); the banner vocabulary gains the warning-tone loss row class
(02); all user-initiated creation clears the search (04); loose files beside a
card's index.md relocate into attachments/ with notice — files-only carve-out,
paste normalizes at the boundary, the agent guide teaches it (01, 04, 08);
dropping a card on the shown trash deletes it, shadow pinned topmost (04);
query-emptied lanes keep their slot, the out-transition stays reachable, an
open rename survives the filter, pasteboard staleness accepted (04).
The face carousel does not carry over: the pathfinder's selection-keyed dual
presentation proved undesirable — one presentation per card, selection changes
styling never geometry; the attachment chip and the card window are the whole
media story (03, 01, 10, 11; the same-day carousel settlements superseded on
their Resolved cards).
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
The copy now walks the source tree item by item, checking cancellation
between items, and the in-progress banner row carries its promised
Cancel — a cancelled duplicate removes the partial sibling and never
happened (DESIGN/03 > File menu). A sandbox permission refusal of the
silent Finder-style sibling falls back to an NSSavePanel pre-filled with
the parent folder and the copy name — the panel's grant is the sandbox's
own answer; cancelling the panel cancels quietly, and non-permission
failures keep the ordinary one-shot banner. Refusal classification is
deliberately narrow (NSFileWriteNoPermissionError itself, no underlying-
chain walk) so an unreadable source never masquerades as a destination
refusal. Directories are created writable first with mode and timestamps
restored after the subtree lands, so a read-only source folder can't
strand its own copy. BoardDuplicatorTests grows from 9 to 18 tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
02 ratified a warning-tone class for non-failure losses — content that
didn't arrive though nothing failed: a degraded paste, folders skipped
from a Finder drop, their future kin. Loss rows take the one-shot's
dismissable-untimed lifecycle (a loss the user didn't notice is the
harm) and rank below the true failures, above commit and attachment
notices. BannerCenter grows LossBanner, postLoss, and the
skipped-folders phrasing; the degraded-paste notice moves off its
signpost onto the new class, ending its too-quiet ranking.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The committed placeholder's lane slot now takes the arriving card's
identity — .awaitingArrival keys as card:<id>, so when the echo reload
lands the ForEach sees one persisting element whose content swaps from
stub to card face instead of a removal and an insertion with two
scale+fade transitions (DESIGN/02 > overlays: the handoff must read as
one arrival). The awaiting face renders the card's exact chrome from
shared CardFaceMetrics, so any residual branch crossfade is between
pixel-identical renderings; the editing phase keeps its constant key so
typing identity holds. Slot position math is untouched.
CreateHandoffIdentityTests pins the key derivation.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The trashed-side gesture surface was sized to the rows' fitted height, so
blank column space below the last row couldn't arm a marquee — a dead
zone the board side doesn't have (DESIGN/04 > The trash: the column's
gesture surface is full height). The rows content now takes
maxHeight: .infinity like LaneView's scrollableCards, the identical
structure in the identical position, so the band arms from anywhere in
the column.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
application(_:open:) forwards every Finder-delivered URL to
AppModel.openBoard(at:) — the exact path welcome and File > Open use, so
a Finder open gets the same registry record-before-load, recents stamp,
already-open-focuses-its-window dedup, and row-level failure surfacing
on welcome (DESIGN/02 > Launch). A cold Finder launch can arrive before
any scene has captured the window opener; openBoard now buffers such
URLs and captureWindowActions replays them once opening is possible.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The 2026-07-28 realign card asks the four caret chords (Move Left/Right,
lane-width pair) to yield to any focused text control. The seam already
conforms — caretChordsYield covers the board popover, the search field's
first-responder tracking, and card windows publish no scope — but had
zero regression coverage. CaretChordTests pins the rule: scopeless
windows never yield, either surface suffices, and inline editors remain
acceptsBoardMutations' concern, not this seam's.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The strip's standard-width division read the drop registry's strip
frame during body evaluation, but the registry is deliberately
unobserved (it exists for event-time reads): the first body ran
against a zero frame, every lane collapsed to the 1pt floor, and
nothing invalidated the view when the real measurement landed — the
board stayed corrupted until an unrelated re-render. The layout now
reads the GeometryReader's live proposal (reactive by construction);
the drop delegates keep reading the registry at event time.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The card face's in-place expansion (03-board-ui.md § Card face): a card
with attachments, as the sole selection, grows a paged media band below
its title; every other state stays compact behind the quiet paperclip.
- CardCarousel owns the pure rules: the sole-selection predicate decides
by identity (a sole-selected lane matches no face, no snapshot walk),
and expansion is suppressed outside the animation key while a rubber
band is active — a band names a set in progress, so carousels neither
flicker nor animate under it.
- QuickLook thumbnails generate off-main into a per-window cache keyed to
survive reselection, with the Finder-icon fallback while loading and
for non-previewable types; pages ride the platform paging behavior,
dots (glass underlay, solid under Reduce Transparency) click to page,
and a local wheel monitor turns a discrete tick into one clamped page —
precise trackpad pans fall through untouched.
- The expansion animates under Motion's new carouselExpansion transaction
keyed narrowly on the sole-selected card; Reduce Motion goes instant.
Drop-slot math and the marquee read the expanded height for free — both
re-register on every size change.
928 unit tests (24 new). m5-interactions complete.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The remaining rows of 11-command-nexus.md § Context menus, every entry a
twin of an existing command path, never a parallel implementation:
- Card: Open (the double-click's own openCard closure, always the clicked
card alone), Rename (Board ▸ Rename's beginRename path), Style… and the
quick-style recents (already present), Delete (File ▸ Delete's
store.delete on the standard widened target — selection when the clicked
card is a member, else the card alone).
- Lane (one menu, header and empty space): Rename and Delete join the
existing Style…/recents/Width rows, in table order.
- Trash entries and welcome recents verified already exact against the
table; the attachment row's menu is marked for m6 beside its command.
- File ▸ Reveal in Finder gains its board-window scope, the branch the m4
comment deferred here: the selection's folders on either side of the
trash boundary — enabled under every lock, inspection being a read — or
the board root with nothing selected; a selection resolving to no
folders disables rather than guessing.
904 unit tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Nexus parity, audited row by row (11-command-nexus.md § Menu commands) —
every already-built item's title, chord, and placement matched exactly;
this pass fills what remained:
- The future-window rows, present with stable titles and validation-driven
disablement until their milestones fill the actions: Save as Template
(m9), Add Attachment… ⇧⌘A, Find Next/Previous ⌘G/⇧⌘G, the View-menu
card triplet Edit Body ⌘E / Raw Source ⌥⌘E / History (m6), Board ▸
Pull/Push (git milestones) — one shared disabled-row shape in
FutureCommands.swift so later milestones only flip validation.
- No Print story in v1: the print group is removed.
- Help carries the Nexus's one remap-teaching line — Customize Keyboard
Shortcuts…, opening System Settings' Keyboard ▸ Shortcuts extension
directly (the modern extension URL, verified to launch the appex).
- The launch-restore decision now runs through a pure, tested
AppModel.shouldRestoreAtLaunch gate; the Settings pane's caption rides a
proper Form section footer.
904 unit tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The board's live title+body filter per 04-interactions.md § Search:
- SearchFilter — a pure value folding the query once (case- and
diacritic-insensitive substring, locale-stable); title OR body matches,
attachment filenames never searched; only the literal empty string is
inactive.
- One universe: the filter threads through SelectionGrammar's order lists
as a defaulted parameter, so ranges, Select All, arrow navigation, the
marquee, drop zones, count badges, and the shown trash all read the same
filtered set by construction; lanes are deliberately never filtered out
(an emptied lane keeps its slot with a 0 badge). Hidden cards leave the
selection through the existing constrain primitive, run on every query
change and as the last line of the reload resolve; the delete successor
is filtered so ⌫ never selects a hidden neighbour.
- The field: an NSSearchField-backed toolbar item (the toolbar's sole
default item); Edit ▸ Find ⌘F focuses it through a focused-value
presentation; stock field-editor dispatch — Return swallowed, Tab is the
keep-filter path to the board, board commands stay enabled except the
caret-chord pair, now one shared caretChordsYield expression.
- Escape is staged: clear the non-empty query (focus stays), hand an empty
field back to the board, clear an active search from board focus —
before Escape's clear-selection meaning.
- Creating a card clears the search (the placeholder funnel); a rename
deliberately gets no carve-out; filter reflow rides the content spring
keyed narrowly on the query.
903 unit tests (24 new).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
⌘X/⌘C/⌘V for cards and lanes per 04-interactions.md § Clipboard:
- ClipboardStore stages full folder snapshots eagerly at the gesture into
Application Support (at most the current copy; sweep at launch and on
each copy purges what the pasteboard no longer references; a copy made
before quitting pastes whole after restart) and writes the pasteboard a
JSON manifest — every entry embedding its index.md, lane entries their
cards' too — plus plain-text titles.
- Cut is Finder-style deferred: items dim in place off pendingCut, void on
pasteboard takeover (changeCount, no timers), source-board close, or
per-item external tombstoning; the first armed paste moves the surviving
originals whole (tombstoned interior cards land in the destination's
trash), a second paste materializes copies from staging.
- Paste anchors by the shared flatten-order rule (NewCardTarget's anchor,
extracted); a tombstoned selection never anchors; lane paste reaches the
right end and stays enabled on a zero-lane board; paste into the source
board is the within-board lane duplicate; copies keep created, take
fresh GUIDs, and strip tombstoned cards; trash-sourced copies strip
deleted: at materialization; ⌘X is disabled on the trash side.
- A degraded paste is loud, never silent: staging gone → the embedded
index.md fallback lands content-intact, attachments absent, and a
BannerCenter-phrased row names what was lost.
- The standard Edit items validate through conditionally-attached
onCommand handlers, so AppKit's enablement mirrors the availability
predicates; text fields keep their own clipboard while focused.
879 unit tests (68 new).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Files from Finder land on the board per 04-interactions.md § Drag & drop:
- Dropped on a card, they copy into its attachments/ (any type, multi-file),
the face highlighting while hovered; the hovered card is resolved by
hit-testing the same analytic masonry frames the card zones are built
from, so attach-beats-create adds no drop region and cannot drift from
the dispatch.
- Dropped on lane empty space, one card per file — filename minus extension
as the title (a blank stem omits the key), fresh GUID, rank at the drop
position through the ordinary insertion machinery, the file attached —
all in one bracket; a failed import removes the just-minted card, so
creating-then-abandoning never leaves an empty card behind.
- Every board drop surface now declares .fileURL beside the two board
types (the single-target-dispatch rule); tombstoned surfaces are inert;
file sessions ride a distinct session mode with their own watchdog and
no hysteresis, leaving the board-drag machinery untouched.
- Also: four empty fixture directories pinned with .keep files so git
preserves them, and a test-only visibility fix in DragSessionTests.
811 unit tests (12 new).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The second half: system drag sessions over phase 1's model, per
DRAG-REORDER.md and 04-interactions.md § Drag & drop.
- Card faces, lane headers, and trash rows drag as NSItemProvider sessions
(two exported UTTypes, JSON payload in flatten order, plain-text titles as
the secondary representation) — replacing m4's custom lane-reorder gesture
and trash drag-out wholesale; the app-wide DragSession carries the members,
the frozen dragged sizes, the live proposal, and the effective operation.
- Three drop delegates (lane masonry, strip, window fallback), each accepting
both types and routing internally per the single-target-dispatch rule; the
cursor is the physical mouse converted to strip space; proposals come from
DropSlotMath with hysteresis threaded through, and the lane-strip proposal
clamps in front of the shown trash.
- Locality picks the default — move within a board, copy across, the badge
tracking live; ⌥ forces copy (ignored on within-board lane drags), ⌘
forces move; trash rows restore within their board (positional), copy out
across boards by default, ⌘ forcing the true restore-move.
- N contiguous shadows with reflow keyed on the proposal; the
committed-overlay hold renders the dropped arrangement until the reload
echo lands (1.5 s dissolution deadline for refused writes); the
re-grounding trio: geometry re-derives per render, proposals re-validate
by liveness at release, an emptied drag cancels itself.
- Edge autoscroll (ticking driver over DragAutoScrollMath, re-targeting per
step), the mouse-up-gated late-event cleanup, and the polling watchdog —
the pathfinder's lifecycle traps, ported.
- Store: moveLanes and multi-card restoreByDrag join the one-bracket drop
commits.
784 unit tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
BoardRecord carries icon/iconColor; recordOpen/recordClose stamp them with the
display name, and a displayStateDelegate on the store (wired in BoardWindowHost
beside onFrameChanged) syncs all three through BoardRegistry.syncDisplayState on
every successful reload — welcome rows now wear the board's own icon and follow
in-app renames live. recordOpen now runs before the load with the folder name as
a brand-new record's provisional display name, so a first open that fails
fail-fast still lands in recents carrying the failure row-level (02's rule); an
existing record's cached name survives a failing retry, and the welcome fallback
list remains only for failures naming no record at all.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
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
DESIGN edits landing from the parallel session, honored:
- Caret chords yield to any focused text control (04 ▸ Grammar): Move
Left/Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ now disable while the board
popover is open — its fields are the one non-inline text surface a board
window has today; the search field and card-window fields extend the rule
with their own cards.
- The width pair batches over a multi-lane selection (03 ▸ Lane, the styling
precedent): each selected lane steps one unit through one bracket
(stepLaneWidths); floor members hold on a decrease; the context-menu
stepper stays single-lane by nature.
- A width write landing on 1 removes the width key (03 ▸ Lane, the
remove-at-default family), every mechanism alike — writeLaneWidths is the
one commit point; a hand-written width: 1 is preserved by the unchanged
guard until the app itself next edits width.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md
travels with it, rewritten for lanes, the interior masonry, multi-drag,
cross-board sessions, the re-grounding trio, and the committed-overlay hold):
- DropSlotMath — resting-layout zones from analytic lane arithmetic and the
pure masonry placement (MasonryLayout now lays out through the same
MasonryPlacement the drag reads, so geometry cannot drift), span-capped
triggers sized to the dragged run's future footprint, hysteresis holds with
the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold.
- DragAutoScrollMath — the activation bands and velocity ramp, pure.
- The drop commits, one performWrite bracket each: moveCards/copyCards within
a board (insertion ranks touch only the dragged cards; renumber fallback);
receiveCards/receiveLanes/receiveRestoredCards on the destination store for
cross-board copy and ⌘-move with the import-boundary remint, lane copies
stripping tombstoned cards while moves carry them; restoreByDrag is now
positional, writing order only when the drop names a new one.
Gestures, sessions, previews, and delegates are the second half.
773 unit tests (87 new since the keyboard grammar).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The board's fixed grammar keys and the menu-backed chords of
04-interactions.md § Keyboard, per the Command Nexus inventory:
- Spatial arrow navigation (NavigationMath.nearest over the marquee
registry's frames — one geometry source), walking across interior
masonry columns, lanes, and into the shown trash; ⇧-arrows extend via
the same range function as ⇧-click and go inert at the liveness and
kind boundaries; ⌥-jumps with the ⌥↑ lane-domain escalation and ↓
descent; the empty selection seeds at the first lane's first card;
selection scrolls into view.
- selectionHead — the navigation cursor beside the anchor, set by every
click, moved by every arrow, dropped by the reload vanish rule.
- Board ▸ Open Card ⌘↩ (the one command enabled mid-edit: commits the
placeholder or rename and opens), Move Up/Move Down ⌥⌘↑/⌥⌘↓
(within-lane sort, gather-then-step, rank-permuting writes in one
bracket), Move Left/Move Right ⌘←/⌘→ (sole lane, one slot, never the
trash) — all validating and acting off one shared answer.
- Delete now selects the Finder-style successor sibling from the
pre-write snapshot, so repeated ⌫ walks down a lane; external
vanishing still only shrinks the selection.
- handleReturn rejects modified Returns; the trash column renders
eagerly so every row stays registered for navigation and the marquee.
686 unit tests (27 new).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The full pointer grammar of 04-interactions.md § Selection, stated once
as a pure function (SelectionGrammar) and reached through one store
funnel from every click surface — card face, lane header, lane empty
space, trash row:
- Plain click replaces and anchors; the lane surfaces (header and empty
space alike, per the settled one-lane-click-behavior rule) toggle off
on a sole-membership repeat.
- ⌘-click toggles within a homogeneous set; crossing any axis — cards
XOR lanes, live XOR trashed, card entries XOR lane entries in the
trash — degrades to a replace, so no click can produce a mixed
selection.
- ⇧-click ranges from the anchor in the (side, kind) order list: flatten
order for cards, lane order for lanes, the trash's deterministic sort
filtered to kind — the pointer twin of the keyboard's boundary rule
(the keyboard goes inert, the pointer skips).
- The rubber band (MarqueeSession/MarqueeMath) arms from lane empty
space, the board backdrop, and the trash column; side frozen at the
origin, trash bands homogeneous by topmost kind, frames self-registered
in strip space, geometric begin guard, never animated.
- Fast plain double-click opens the card window (⌘↩'s pointer twin);
Select All answers the standard Edit menu item via the responder
chain, trash- and kind-respecting.
- The range anchor lives in TransientBoardState beside the selection and
obeys the same reload vanish rule.
659 unit tests (28 new in SelectionGrammarTests).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The welcome window becomes the real thing: Xcode-style, hidden title
bar with background drag, branding and actions left, recents right —
rows carrying the board symbol, name, location, and the registry's
cached lane/card counts (stamped at close, never a scan at welcome
time), sorted by last opened. Launch failures surface row-level per
02: a failure joins its recents row as a warning caption, an
unresolvable bookmark renders unavailable with Forget its one
affordance, and only a failure with no row to carry it falls back to
a compact list; a board opening again heals its row. New Board
(Opt-Cmd-N) opens the Pages-style template chooser — shipped with
the single Basic template and the m9 seams marked — flowing through
the save panel into createBoard/createLane and straight into a board
window. Open Recent gains its submenu with Clear Menu (byte-identical
to forgetting every row, pinned by test), and File > Duplicate forks
the frontmost board to a Finder-style copy sibling: pending work
flushes first through the close flush's step two alone (sessions stay
open — 09's stated exception), every GUID and tombstone carries (the
whole-board carve-out from copies-remint), and the copy opens in its
own window while the original stays put. 36 new tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
One named home for every curve and duration — the two-voice split 03
fixes (snappy structural: drag reflow 0.18, delete 0.25, lane resize
0.2; smooth content-reflow 0.28), the scale-and-fade appear/disappear
transitions (cards 0.8, lanes 0.9), and a Reduce Motion variant on
every accessor (animations go instant, transitions go crossfade). A
post-migration grep holds the invariant: zero motion literals outside
Motion.swift. The animate-vs-snap split lands where Lanework's
one-way flow puts it: the store's reload seam. An app-mediated echo
applies its snapshot inside the structural transaction; foreign and
reconciling reloads — and every bracket-ending wholesale reload —
assign bare, because live-reload is the board becoming what's on
disk, not an event to perform. A window that merged foreign events
into an app-mediated span animates, deliberately: the ratified merge
rule makes it indistinguishable from a pure echo, and a test pins
that reading so a future mixed case fails loudly. The lane-reorder
reflow keys on the drop proposal alone (pointer tracking stays 1:1),
the resize session freezes its Reduce Motion answer at drag start so
the unit tick and the window resize can never disagree, and the m5
drag / m5 search / m7 undo voices are named seams waiting for their
call sites. 13 new tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The corpus just ratified the plural case: a multi-selection anchors
at its last member in flatten order (lane order, then card order —
the multi-drag order, the same anchor paste will use), so creation
follows the last selected card or appends to the last selected lane
instead of falling through to the last-active lane. One display-order
walk keeps the resolver pure; the sole selection is now just the
degenerate one-member case.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Deletion becomes a two-stage, Finder-style story. File > Delete and
plain Backspace tombstone the live selection; View > Show Trash (no
chord — Shift-Cmd-T stays with the system's tab bar) reveals the
quasi-lane: trailing, one fixed width unit consumed only while shown,
hatched dimmed header, count badge, no new-card button, exempt from
resize and reorder alike. Its contents are a pure view over the
snapshot — the deterministic sort (deleted newest first, folder-name
ties, unparseable stamps oldest) interleaves card rows with a
tombstoned lane's single entry, whose count names what Put Back
returns; the ancestor walk is absolute, so an own-flag card beneath a
tombstoned lane has no row and recovery is deliberately two steps.
Put Back twins Delete on Cmd-Backspace with validation enabling
exactly one; restore fidelity is byte-perfect because nothing ever
moved. Delete Immediately confirms exactly where loss is real (every
board is mode-none today; the predicate names the git carve-out for
m7), Empty Trash always confirms with the true whole-board count,
and dragging a tombstoned card onto a live lane restores it there —
positional drops and cross-board locality arrive with m5's machinery.
The banner's delete phrasing drops "move to the trash" per the naming
constraint: board deletion says Delete, "Move to Trash" stays
reserved for the system Trash. 47 new tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The window-title widget arrives as a leading titlebar accessory — a
quiet chevron on board windows only, installed and removed by the
window controller's own attach lifecycle — anchoring the one
board-level surface as a transient popover (the board window
deliberately grows no toolbar item for it). Inside: board rename
editing frontmatter title only (the folder is never renamed; an empty
commit removes the key and the window title falls back to the folder
name), the embedded shared style editor permanently targeting the
board, and the labeled Git section that this milestone only reserves
— a mode-none explanation and a disabled stub where m7's add-git,
branch, remote, and authentication controls land. Cmd-I (File >
Board Info) toggles it per window through a focused scene value,
kept apart from board-scoped transient state since a titlebar
popover belongs to one window, not to the board. Escape reverts a
dirty rename field and falls through to dismiss otherwise; a foreign
rename resyncs the field only while unfocused. 12 new tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
One style-editor component, anchor-agnostic: a background grid (None
well plus the 12 palette colors) and a curated symbol grid (the
pathfinder's five-dozen set, leading well removing the icon key for
the level default), selection-aware across cards, lanes, and the
board itself. Batch edits compute per-dimension state — uniform,
mixed (no well selected), or an off-palette value labeled verbatim
outside the grids — and choosing a well applies to the whole target
set as one write bracket, skipping no-ops per field. The popover
tracks its target set live per the freshly ratified rule: targets
re-resolve by UUID on every reload, a vanished target leaves the set,
an emptied set dismisses the editor, and nothing ever silently
retargets to the board. Anchors landing now: Board > Style
(Opt-Cmd-S) and the card/lane context menus, which also carry the
quick-style recents row (app-wide, persisted, capped at six, None
never recorded) and the lane's width control twinning the menu
chords. The styling system's other two renders arrive with it: a
lane's background paints the C7 top-edge band, the board's paints
the window content background — malformed values paint nothing and
stay byte-identical on disk. 31 new tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The design corpus ratified that ranges are part of a sensible reading:
an exact-integer width below 1 now coerces to 1 read-side (bytes
untouched) instead of reading as malformed — the width division must
never see a zero or negative unit — while a non-finite order (.nan,
.inf) is now the same loud malformed-order rejection as a non-numeric
one, guarded at the single point where the double arrives so loader
and Writer inherit it together. The symlink-never-traversed rule
turned out to be already enforced (the loader has filtered symlinks
ahead of the directory check since the first commit); it and the
copy-preserves-the-link-verbatim behavior are now pinned by tests,
alongside the two hostile shapes the corpus names (width: 0,
order: .nan). Five new tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The card face becomes real: leading SF Symbol (card default doc.text,
tinted by a valid hand-written iconColor — schema yes, control no),
title or the quiet untitled placeholder, and a quiet paperclip when
the card has attachments — title-only by design, no body excerpt.
Color is the settled K1 edge accent, not a fill: background paints a
4pt stripe down the left edge, resolved through the ported pathfinder
palette (12 icon tints + 12 backgrounds carried over verbatim, plus
raw #RRGGBB[AA]); anything unresolvable paints nothing and stays on
disk exactly as written. The snapshot now carries each card's flat
attachment names — the loader's one read inside a card folder, shared
with the Writer's listing so the m5 carousel and m6 sidebar can never
disagree on order (Finder order, the Writer's existing comparator).
The face keeps its top-aligned structure so the sole-selection
carousel can expand inside the card without moving masonry neighbors.
18 new tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The lane title bar becomes real: leading SF Symbol (hand-written names
render leniently, unknown ones fall back to the level default), title
or secondary untitled placeholder, a quiet count badge that counts
exactly the cards the body renders (so the m5 search filter is
followed by construction), and a new-card button. The whole bar is
the reorder drag surface — no grip — with click-vs-movement splitting
select from drag; a pure proposal function maps the drag to an
insertion index and release commits through the Writer's same-parent
degenerate reorder, compacting and retrying when midpoint precision
runs out. Clicking never edits: inline rename is Return on the sole
selected card or Board > Rename for either kind, a third transient
editor beside the placeholder that tracks its target by UUID, commits
on focus loss, discards silently when the target vanishes, and
removes the title key on an empty commit. The new-card placeholder
renders at last — the settled Cmd-N target rule (pure, tested) files
it after the anchor card, at a selected lane's bottom, or into the
last-active lane; Return commits and re-selects the lane, Cmd-Return
also opens the card window, and a failed create discards the overlay.
New Card / New Lane / Rename land in the menus with focused-editor
and read-only validation; rename gets its own WriteOperation case in
the banner vocabulary. 59 new tests.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
The lane strip replaces the placeholder board: window width divides
across the lanes' width units (no horizontal scroll, no minimum width,
degenerate compression accepted), a lane of n units flowing its cards
into n round-robin masonry columns via a measurement-cached Layout.
Width has two deliberately opposite controls, both landing here: the
right-edge drag (ported verbatim from the pathfinder's ColumnResize)
freezes the 1x standard at drag start, snaps between integer widths
with the asymmetric shadow-leads tick and 10pt re-entry, grows the
window one standard width per snap so siblings keep their exact
pixels, and rubber-bands at the screen's visible frame — uncapped
otherwise; the Increase/Decrease Lane Width items (new Board menu,
Cmd-Opt-arrows) are the stepper's keyboard face and re-divide the
existing window width instead, never touching the window. Width
changes write through the new .resize WriteOperation ("Couldn't
resize…" in the banner vocabulary, which grows with the surfaces by
design); malformed width values render as one unit and stay untouched
on disk. 27 new tests port the pathfinder's resize-math suite onto
the uncapped range and pin the write path's fidelity.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
Four scenes (welcome, restore bootstrap, board group, card group) with
system restoration disabled in favor of the registry's open-now flags:
set when a window actually opens, cleared only on user close, so quit —
and crash — leave exactly the restoration set behind. AppModel joins
windows to sessions (shared store, registry record, card refs, held
security scope); CloseFlushCoordinator pins 02's strict close order as
a seam-injected machine (card sessions end, windows drain, store
flushes, record stamps, teardown) with named slots where m6/m7 flushes
land. HostedWindowController proxies — never replaces — SwiftUI's
window delegate to intercept windowShouldClose for the flush, report
frames, and place saved frames onto live screens. Card windows are
(board path, case-folded card id) values: reopen focuses, and a
snapshot-pure fate function dismisses on delete, tombstone, tombstoned
lane, or cross-board move.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
- 02: failed create discards the placeholder; a second creation is a
click-away for the open draft (replace-with-discard, ⌘N unreachable);
unwritable-lock banner names volume vs permissions; WriteOperation
vocabulary grows with rename/body-save surfaces; one bookmark per open
board threads through the open flow; in-progress banner class ratified
newest-first; signpost lifecycle is the dismissable untimed one-shot
- 01: folder-name tie-breaks compare the canonical lowercase spelling
everywhere (Ranks and trash sort)
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
Two follow-ups to this session's decisions: the reconciling reload's
writability re-probe now raises the unwritable lock as well as clearing it
(fail loudly once, not per gesture), and under the shape-only identity
predicate the loader deterministically dedupes case-spelled sibling twins
on case-sensitive volumes (lowercase spelling wins, else lexicographic
first; loser takes the stray posture) so a snapshot never carries
duplicate ids.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
Researched against the Frontmatter implementation: the duplicate-key
rescue's retry-loop pattern (compose a placeholder-patched copy, derive
spans from original lines) extends directly. Ratified: a top-level key
line whose plain unquoted remainder trips "mapping values are not allowed"
reads as everything after the first ": "; quoted/flow remainders and
nested shapes stay fail-fast; bytes preserved, fully editable, heals on
first app write of the key; silent with a log line. Test obligation named
(no hostile-YAML case covers the shape today).
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
- 02: welcome counts are live items only; failed first opens still record;
duplicate-identity registry records merge silently (newest wins);
BoardStore's FolderWatcher pointer reads "below"
- 11: lane-header new-card button inventoried (click names its target lane,
overriding 04's ⌘N rule — cross-referenced there); attachments quiet-add
affordance listed as a twin; Undo/Redo and Pull/Push rows gain the
abnormal-state pause, read-only lock, and unresolvable-remote conditions;
welcome scope added to Reveal in Finder
- 03/11: one lane context menu (header or empty space) with the full
inventory, replacing the split rows
- 09: instantiated boards are never in git mode — actual mode follows
detection at the destination (repo-nested possible)
- 08: the app never writes an existing CLAUDE.user.md; one-time rescue
creation stated as the exception
- README: pathfinder path corrected to ../../Kanban
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
TransientBoardState, one per store, holds state by how a reload treats
it: item-referencing sets (selection, drag membership, pending cut)
share one shape and one constraint rule — members must exist in the
current universe — applied in two directions by one primitive, so the
search filter's hidden-cards rule and reload survival are one rule
expressed once; derived state is stored as its inputs only (the query,
never its result set); and the new-card placeholder is a lane-anchored
overlay with no UUID until commit, discarded when its lane vanishes or
tombstones, handed off when the created card's UUID appears. Trash
visibility rides along per-open, never persisted. The decision is
written back into DESIGN/02 § Changes from Kanban — the TBD is closed.
Full suite 352 tests in 64 suites green. Two findings filed.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
The composer was a pure snapshot diff while the repo tracked files outside
the model (agent guide, strays, .gitignore), leaving stray-only windows
uncommittable and the guide's own subject underivable. Settled: the
committer stages the whole root with the tree as its commit condition; the
composer also receives the changed-path list, composing "Update agent
guide (vN)" (version from the marker line) and "Update '<path>'" events
that ride as body bullets under model-event subjects; attribution falls
out of the path-keyed EchoLedger.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The detached-HEAD/mid-merge pause named auto-commit, Undo/Redo, and branch
controls but left Pull, Push, and push-on-commit live — against a dirty
tree whose clean-by-pull-time invariant the held auto-commit had falsified.
Settled: the whole surface pauses; the ahead/behind badge keeps counting
since a fetch is a read.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
Show Trash's ⇧⌘T collided with the Show Tab Bar item macOS injects into
multi-window apps. Settled: tabbing is kept as a system affordance (each
tab a full board window, per-board frames applying when standalone) and
Show Trash ships with no default chord, assignable via the remap path.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The field's exits were commit-only, silently diverging from the board
rename's Escape-abandons rule, and Escape was ambiguous with the body in
Edit mode. Settled: Escape reverts to the on-disk title and focuses the
body; the collision resolves by focus per 06's first-responder routing.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The trash trio had no verbs — tombstoning and purging both composed
"Delete card 'X'", erasing the moved-to-trash vs gone-forever distinction
the docs ask users to learn. Settled by diff shape alone: deleted:
appearing = Delete, clearing = Restore, item leaving the tree =
Permanently delete; plural folding keeps Empty Trash distinct from a
multi-select delete.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The rubber-band had its boundary sentence; the keyboard extend gesture had
none. Settled: a ⇧-arrow that would cross live↔trash or card↔lane-entry is
inert — never move-and-replace — keeping the homogeneity invariants
unbreakable by keyboard while plain arrows still walk across.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
Paste's anchor rules never accounted for a tombstoned selection, and a
literal reading filed a pasted lane beside a trashed card's on-disk lane.
Settled: ⌘V stays enabled and behaves as with nothing selected (card
payload to the last-active lane, lane payload to the board's right end).
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The fixed grammar keys were formally live over the board while the search
field had focus, letting a reflexive Return fire rename/create and destroy
the query. Settled: every key acts on the field (no pass-throughs), Return
no-ops, Tab moves focus to the filtered board with the query intact, and
Escape stages — clear query, then exit field, with board-focus Escape
clearing an active search before its clear-selection meaning.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
Accept liberally, emit conservatively (764a4d4): the gate is 8-4-4-4-12
hex in any case and any UUID version — uuidgen and UUID().uuidString
print uppercase, and a strict lowercase gate would silently stray an
agent's standard-tool card. Identity comparison is UUID-value equality
everywhere: ItemID keeps its byte-faithful rawValue but equates and
hashes on the lowercased canonical form, and the writer's
import-boundary collision check canonicalizes, so a same-UUID arrival
spelled in another case remints instead of slipping past. The app still
mints only lowercase v4 and never renames to canonicalize.
Full suite 342 tests in 63 suites green. Two findings filed.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
Every lane command keyed off "a selected lane" with no drag-free way to
create one. Settled: ⌥↑ with the lane's first card selected selects the
lane (↓/⌥↓ descend back; ⌥↑ then inert), an empty selection seeds at the
first lane's first card, and a plain click on the lane title bar selects
with the drag surface engaging only on movement.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The row's name and icon implicitly required reading each recent board's
root index.md at welcome time — the hang the counts rule was written to
avoid. Settled: title/icon/iconColor live in the registry record; they
refresh whenever an open board's reload changes them (in-app renames land
instantly, foreign renames of open boards ride along), with closed-board
renames stale until next open like the counts.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The banner surface as one vocabulary (Kanban/LiveStore/BannerCenter,
Kanban/UI/BannerStripView): a pure precedence rule — in-progress pinned
above the collapse (ratified mid-build), lock > breakage > one-shot
write failures > commit+attachment, signposts last — with all
user-facing phrasing owned here via exhaustive switches over the closed
WriteOperation enum; free-form English survives only in diagnostics.
performWrite posts its failures before rethrowing, so no one-shot can
bypass the strip; refusals under lock post nothing.
The lock vocabulary completes: vanishedRoot and unwritableLocation join
bracketedReloadFailed, each with its own clearing rule (unwritable
clears only on a reconciling reload's writability re-probe). The
registry now owns root recovery: bookmark re-resolution absorbs renames
transparently, a dead root locks read-only and re-arms FSEvents on the
gone path so the root's return round-trips back through rootChanged,
re-minting and re-keying on the way. DirtyBufferGuard is the one modal
moment, retry / save a copy / discard, no fourth button.
36 new tests; full suite 333 tests in 62 suites green. Five findings
filed on the Redesign board.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
Both whole-board copies were absent from the lock's command enumeration,
staying enabled while the source tree was gone, unknown, or unwritable and
while the lock suppressed their required close flush. Settled: Duplicate
disables in every lock state; Save as Template disables except under the
unwritable-location lock, where reading the board into Application Support
is a legitimate copy-out with a vacuous flush precondition.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The stacking order covered only error classes, letting an in-progress row
collapse behind "+N more" — the sole explanation of a bracket's write lock
and a deferred close. Settled: in-progress rows pin above everything,
exempt from and uncounted by the collapse; the passive remote-change
signpost ranks below all error classes and may collapse.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The trash's newest-first order is load-bearing for keyboard input, but
same-second multi-deletes and 01's blessed-unparseable timestamps had no
ordering. Settled: ties break by folder name ascending; date-unknown
entries sort as oldest, after all dated entries; lane entries interleave
by their own stamp.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
01 said "lowercase UUIDv4" without defining the test, and the strict
reading made every uppercase uuidgen/UUID().uuidString folder a silently
skipped stray. Settled: 8-4-4-4-12 hex in any case and any version is an
identity; comparison is UUID-value equality (case-spellings are one
identity, matching default-APFS behavior); the app emits lowercase v4
only and never renames to canonicalize.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The registry recorded recency but not which boards were open at quit.
Settled: each record carries an open-now flag set on open and cleared on
user-initiated close only — quit teardown leaves it standing, so there is
no at-quit write and crash recovery falls out for free. Restoration
reopens flagged records by lastOpened order; the preference only gates
consulting the set.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
10 recovered focus "to the card's lane" even in its own lane-tombstone
case, where that lane renders nowhere. Settled: recovery walks up then
sideways — the lane at the vanished lane's position (next by order, else
previous), board container only when no lanes remain, never into the
hidden trash; the announcement names the lane, not the card.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
10 promised toggleable-without-pointer, 11's normative inventory had the
toggle as pointer-only, and 02's lock disabled it "via menu validation"
with no menu item to validate. Settled: checkboxes and links are real
controls in the focus/accessibility tree — FKA + Space and VO-Space toggle
them; in-content controls are content, not commands, so 04's menu-item
contract and 11's existence rule scope to commands; under the read-only
lock the controls disable in place.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
03's Put Back cascade ("restores the lane too") was unreachable dead text
under 01's hide-the-subtree consumer rule and 02's effective liveness.
Settled: the lane's single trash entry subsumes everything beneath it; its
count is what Put Back returns to the board; lane Put Back splits by flag
(hidden-with-lane cards return, individually tombstoned ones reappear as
trash rows), making that recovery deliberately two steps.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
04's drag rule called a within-board lane duplicate unsupported while the
clipboard rules quietly permitted it via same-board lane paste. Settled in
favor of the clipboard: the duplicate is supported there (fresh GUIDs,
tombstones stripped, no menu-validation special case) and merely not
available by drag — ⌥ stays ignored on lane drags so the gesture remains a
clean reorder.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
01's table promised an update on every app write, which raw-source Apply
(byte-for-byte contract) and attachment operations (never touch index.md;
load-bearing for 03's Add-Attachment-during-raw-source rule) cannot honor.
Settled: modified updates only on app writes that rewrite the item's
index.md; both exceptions stated as deliberate, and the stale-readout
consequence on 05's Modified line accepted with attachment history living
where the files do.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
06 and 10 consumed a per-file app-vs-foreign classifier that 02's component
inventory never named and whose layering prose seemed to deny. Settled: a
new EchoLedger component records each BoardWriter operation's expected
outcome (content hash / absence / move pair); final-content matching
classifies each observed file, deciding both races (byte-identical foreign
write → app-mediated, accepted; same-window foreign overwrite → foreign,
last writer wins). Ledger is in-memory and per-store; launch catch-up is
all-foreign; bracketed operations bypass it. "Trusts its own writes no more
than anyone else's" is scoped to rendering; BoardWriter's no-hidden-state
bullet stands.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
WriteOperation replaces free-form operation strings throughout
BoardWriter, per the settled rule in 02 § Write-failure surfacing: the
banner layer will switch exhaustively over it, so a new operation
without a banner rendering is a compile-time hole. Titles enrich at the
two points a document read makes them known (updateIndex, and the
move/copy pre-flight), so failures after the read name the item;
purge never reads and stays title-less. Free-form English survives
only in the diagnostic reason.
Full suite 300 tests in 56 suites green.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
The parallel design session settled the tombstoned-ancestor question:
a card counts as trashed if its own flag or its lane's says so, so
tombstoning a lane ejects its cards from a live selection — they render
nowhere once the lane collapses to a single trash entry, and nothing
invisible may stay selected, drag-included, or pending-cut. One-line
predicate change in Selection.resolved(against:), plus the test.
Full BoardStore suite green (15 tests).
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
Two registries (Kanban/LiveStore/): BoardStoreRegistry shares one live
store and one started, fully wired watcher per open board across its
windows — keyed by file identity (fileResourceIdentifier), never path,
refcounted to order teardown; release matches by store identity so a
board renamed while open can't leak its watcher. BoardRegistry persists
app-private per-board state in Application Support as diff-stable JSON:
records anchored by security-scoped bookmarks, recents = the registry
sorted by lastOpened (counts registry-cached, never scanned), graceful
orphaning with Forget, corrupt files quarantined aside, and files-first
verified — the board tree is untouched byte-for-byte. Timestamps use
ISO8601DateFormatter with fractional seconds: the FormatStyle variant
truncates-then-rounds and drifts a millisecond per round trip.
16 registry tests; full suite 297 tests in 56 suites green. Four
findings filed on the Redesign board.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
Tombstoning a card's lane ejects the card from selection, drag
membership, and the pending cut - liveness is ancestor-walked, since
the card renders nowhere once 03 collapses the lane to a trash entry.
BoardStore's bullet now says it coalesces reloads (one walk in flight)
while the debounce lives in FolderWatcher, matching m3 as built.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The next-reload-or-lock expectation arms on every bracket exit, thrown
operations included - a mid-flight failure is when the tree's state is
least known, so the mandatory final reload runs regardless.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
The parallel design session resolved the m3 watcher findings; two
changed behavior: .git internals are now filtered at any depth (a
nested clone or submodule is a stray whose internals never render),
and a root change landing mid-bracket is owned by the root-change
path — endBracket() skips its mandatory reload once a .rootChanged
tore the stream down, cleared when a fresh stream attaches. The skip
is scoped by an explicit flag, not by stream absence: a watcher whose
stream never came up still delivers post-bracket reloads, since
distrust of FSEvents delivery is the mandatory reload's whole reason.
Two new watcher tests; full suite 281 tests in 54 suites green.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
Per-board @Observable MainActor hub (Kanban/LiveStore/): watcher signals
drive off-main tree walks with a generation guard, single-flight
coalescing (strongest pending origin, watcher's merge rule), and the
resilience contract — a failed reload never replaces a good snapshot,
per-file breakage never locks editing, and a wholesale operation
(performWholesale) arms a reload-must-succeed-or-lock floor so a failed
post-bracket reload flips the board read-only until a good reload heals
it. Selection is a pure UUID-set value re-resolved on every swap;
liveness flips eject. performWrite brackets the watcher so Writer
round-trips come back app-mediated.
14 store tests; full suite 279 tests in 54 suites green. Three findings
filed on the Redesign board.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
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.
Debounced FSEvents watcher over the board tree (Kanban/LiveStore/):
trailing 200ms debounce coalesces bursts; nestable brackets suppress
delivery and close with exactly one app-mediated reload; origins merge
reconciling > appMediated > foreign; missed-events flags degrade to a
reconciling reload; WatchRoot vanish tears the stream down (streams die
and are recreated — reattach() covers rename re-resolution); board-root
.git churn is filtered against the symlink-resolved root. The FSEvents
release callback must be top-level and non-isolated — a MainActor
closure traps in dispatch_assert_queue during stream deallocation.
15 watcher tests (4 consecutive green runs); full suite 265 tests green.
Five design findings filed on the Redesign board.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
Four minor design-review findings resolved: appended frontmatter lines
adopt the file's prevailing line ending; empty-but-not-blank frontmatter
joins the readable-but-uneditable shapes; an interrupted renumber's
per-file partial is accepted; the write-failure operation becomes a
closed enum carrying the item title.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
- Appended frontmatter lines adopt the file's prevailing line ending
(read off the opening delimiter) — one stamp never produces a
mixed-ending file.
- Empty-but-not-blank frontmatter ({}, null, ~) joins the
readable-but-uneditable shapes: zero keys load fine, but appending
after that text would be unparseable YAML.
- Renumber is atomic per file, not per lane — an interrupted rescale
is accepted the same way as the shared-board burst: valid floats,
deterministic order, next renumber completes the job.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
The executable spec for BoardWriter's cross-cutting guarantees, in a
dedicated WriteFidelityTests.swift: minimal-touch verified down to
sibling mtimes across a seven-operation session; renumber pinned as
the sole exception (whole lane rewritten, board root / other lanes /
tombstones untouched); delete→restore byte-precise (only the modified
line differs, zero tombstone residue); unknown-key order preserved
with keys interleaved among schema-owned ones; and a composite
building a board purely through the writer, hand-editing a file like
an agent would, then moving/copying/deleting/renumbering to a
zero-warning reload. Guarantees already pinned by the per-operation
suites (atomicity, move/copy identity, body round-trip) are
referenced, not duplicated. Shared fixtures promoted to
WriterTestSupport.swift. README gains the write-side feature bullet.
5 new tests; 250 total green. Closes milestone m2-storage-write.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
importAttachments lands files into a card's attachments/ (created on
first import — the one folder the app ever makes there), never
overwriting and never refusing a name: collisions auto-rename
Finder-style, counting up from 2, with a same-named subfolder
blocking a name exactly like a file. Sources are validated before any
copy; a failed copy removes the partial destination and throws naming
the source file; a multi-file batch stops at the first failure with
earlier files staying landed. listAttachments is the flat surface:
top-level regular files only, Finder-sorted, subfolders and hidden
entries excluded and untouched; a missing attachments/ lists empty.
11 new unit tests; 245 total green.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
deleteItem writes deleted: <now> in place through updateIndex — the
folder never moves, nothing beneath it is touched (hiding the subtree
is the renderer's ancestor walk, not a stored flag). restoreItem
removes the key — position-perfect by construction, every duplicate
occurrence taken so a hand-written twin cannot resurrect the
tombstone. purgeItem physically removes the tree; an already-missing
folder is success (a Finder deletion converges on the same end
state), and no prior tombstone is required (Delete Immediately skips
the stage by design). Board-root deletion is structurally unreachable
via the UUID-shape guard shared with move/copy. Neither delete nor
restore polices liveness — re-deleting refreshes the timestamp,
restoring a live item is a harmless stamped rewrite.
14 new unit tests; 234 total green.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
moveItem: physical folder move, UUID and created travel unchanged;
exactly one file rewritten (the moved root's order, stamped). Import
boundary detected by resolved board-root comparison; on a cross-board
move-in, arriving UUIDs colliding with any identity in the destination
board (tombstones included) are reminted per folder at the finest
grain — folder rename only, file bytes untouched, every repair
reported in MoveResult. A colliding root moves straight to its minted
name. A same-parent move degrades to a plain reorder, self excluded
from the appended-rank scan.
copyItem: whole-tree copy minting fresh UUIDs at every depth; .fork
keeps created while stamping modified and clearing modified-by, .born
(template instantiation) stamps created fresh too. Nested uneditable
or unreadable files copy byte-verbatim rather than blocking the
gesture; the root must be rewritable. All-or-nothing at the
destination — any failure removes the partial tree.
24 new unit tests; 220 total green.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
createBoard fills or mints a .kanban (or extension-less) folder and
refuses to clobber an existing board; createLane/createCard mint fresh
lowercase-UUIDv4 folders and append at max+1024 among visible siblings
via the same strict scan renumber uses (extracted, shared). New files
carry schema/title?/order?/created/modified — created==modified, no
modified-by — written LF through the same atomic temp+rename path.
The editability pre-flight is now scoped to operations that rewrite
siblings: renumber refuses on an uneditable sibling, a create beside
one proceeds. Schema constant unified on BoardLoader.supportedSchema.
Info.plist declares the .kanban document type (UTI conforming to
com.apple.package) so Finder treats a board as one document.
20 new unit tests; 196 total green.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
The single point through which every mutation becomes a filesystem
operation: read fresh from disk (strict byte-faithful UTF-8), refuse
readable-but-uneditable frontmatter shapes, apply the edit through the
span engine, stamp modified / clear modified-by, then temp-file+rename
atomically. Renumber-visible-children is the sole minimal-touch
exception, tombstones untouched. Structured BoardWriteError carries
operation + path + reason for the future banner surface.
Alongside, three edges the card surfaced:
- The loader now decodes byte-faithfully too, so a BOM'd or non-UTF-8
index.md is rejected at load per the encoding contract, instead of
loading via NSString's silent BOM strip and then refusing every write.
- An appended frontmatter line adopts the file's prevailing line ending
(a CRLF file stays uniformly CRLF when a stamp first lands in it).
- Empty-but-not-blank frontmatter ({}, null, ~) is detected as
uneditable — appending after it would be unparseable YAML.
30 new unit tests; 176 total green.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
- 10 cites 11 for Add Attachment and the lane-width menu items
- 04 uses ⌘/⇧ symbols for click modifiers; card moves scoped to the
⌥⌘ chord; ../WISHLIST.md path fixed
- 03 says stepper (plus the menu pair) in the editing-surfaces table
- 06 says one commit per debounce window
- 03/10 Reduce Motion lists aligned at five items; rubber-band
disambiguated as the lane-resize feedback
- 11 requires the trash shown for Empty Trash
- 02 rewords and-peers
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
Fills the gap: the rename editor follows its card by UUID through
foreign moves, discards silently when the target vanishes or flips
liveness, and a mid-search rename re-runs the predicate like any edit
— creation stays the search filter's only carve-out.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
Fills the gap: wake/activation reconciling reloads, MustScanSubDirs and
overflow degrade to a full reload, dead streams are recreated at the
re-resolved root. Stale boards structurally excluded.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
Fills the gap: eager staging at copy time, at most the current copy
kept (sweep at launch and each copy against pasteboard references,
surviving relaunch), and attachment-less fallback pastes surface a
one-shot banner naming the loss.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
Fills the gap: resting zones recompute per snapshot (frozen inputs are
the dragged items' sizes and the pointer); proposals re-validate by
liveness with release-cancels-when-invalid; an emptied drag cancels
itself, partial vanishing drops survivors.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
Fills the gap: on root-gone signals the bookmark re-resolves first —
success absorbs the rename transparently (watcher re-attach, URL and
window-key re-derive, full reload); only an unresolvable bookmark is
the vanished-root lock.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
Fills the gap: Save All applies each raw buffer (a validation failure
cancels the switch, focus on the offender), Discard exits without
writing, Cancel keeps everything. External checkouts remain accepted
last-writer-wins per the dirty-buffer rule.
Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
18 golden fixture boards as real on-disk trees under Fixtures/ — ten
valid/tolerated cases (rich board, interrupted create, non-UUID strays,
stray files, tombstones, tie-breaks, unknown-key order, coercion,
last-wins duplicates, board-level deleted) and eight fail-fast cases,
each pinned to its exact error reason and path; the rich board's whole
tree round-trips byte-identically. Pins two contract subtleties now
recorded in the design: tombstone flags are not inherited (renderers
walk ancestors) and last-wins resolves before order strictness. README
gains the storage-foundation feature entry. 146 tests total.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 16:20:47 -04:00
594 changed files with 160287 additions and 480 deletions
description:General-purpose implementation agent pinned to Claude Opus 4.8 (capacity fallback when Opus 5 is overloaded). Use for complex coding tasks dispatched from the main session.
model:claude-opus-4-8
---
You are a senior software engineer implementing well-specified tasks in this repository. Follow the task brief you are given exactly: read the referenced design docs and existing code patterns before writing, match surrounding idiom, keep pure logic in testable seams, and verify with the build/test commands specified in the brief. Report results as raw data in your final message per the brief's report format.
A board now exports as a single file — Obsidian Kanban Markdown, a plain Markdown outline, or CSV — from File ▸ Export.
File ▸ Import Board… turns an Obsidian Kanban file, a Markdown outline, or a CSV into a fresh board, detecting the format for you.
Cards can now carry labels, managed from the card window's new Labels section or the context menu's labels submenu.
The card and lane context menus are reorganized into tidy groups — open, style, clipboard, navigation, width, and trash where each belongs.
Move Left and Move Right on a card's context menu now really move it into the neighboring lane, keeping its position.
The Paste row now says what it is about to paste — Paste Card, Paste Lane, or a count of either.
A card's title can now be edited directly in the card window's Edit mode.
The card window's sidebar hides and shows with a new trailing toolbar toggle or View ▸ Show Sidebar.
Delete and Reveal in Finder moved from the card window's sidebar into its toolbar.
The attachments section now sits at the bottom of the card window's sidebar.
The color and symbol pickers now match — compact square combos, four new palette colors, and the system color panel behind Other….
A new symbol browser offers every symbol the system knows, searchable and grouped by category.
Choose Image… in the style editor sets any picture as a board's background.
Generated and pasted board backgrounds now keep to a tidy folder of their own, and replaced ones are cleaned up automatically.
A file copied in Finder now pastes onto a card as an attachment with ⌘V.
The card window now offers a visible paste button in its attachments header whenever the clipboard holds an image.
A card's comments column can now be resized by dragging its divider, and the width sticks.
A board's background image now stays behind the title bar instead of vanishing there after a trip away from the window.
The board window no longer shows its name twice in the title bar.
The card window's title bar no longer collapses into a single line after switching between edit and source modes.
The lane collapse chevron now shows the standard cursor instead of a resize arrow.
Every board's agent guide now teaches the comment-thread etiquette and arrives with a vendor-neutral AGENTS.md twin.
An image on the clipboard now pastes straight onto the selected card as an attachment — with Set as Hero and Paste as Board Background alongside.
Press Space on a .kanban board in Finder for a Quick Look preview outlining its lanes and cards.
File ▸ Share… sends a board anywhere the system shares, staged as a zip with history left out.
Symbols in printed boards and cards now render as crisp glyphs instead of solid boxes.
Boards, lanes, and cards now each pick from their own curated symbol shelf in the style editor.
Card faces now wear quiet chips for comments and attachments when a card has them.
A card can now carry a hero image — name one of its attachments under a *hero:* key and it banners across the face.
File ▸ Print (⌘P) prints the board as a flowing document — or one card from its window — with configurable content, page breaks, and named print profiles.
Arrowing across lanes now remembers your row, so passing through a short lane and back returns to the card you left.
The lane header's + button is gone — new cards come from ⌘N, Return on a selected lane, or double-clicking empty lane space.
Copy Link on a card's context menu puts the card folder's location on the clipboard.
Lanes now collapse to a slim titled strip — fold with the header's chevron, expand with a click on the strip.
New boards on Mac and iPhone now start with the current agent guide, and the phone refreshes an outdated one when a board opens.
Dragging a lane's edge past the screen now keeps widening the lane by narrowing the others, instead of stopping — so wide lanes work even in full screen.
Edit ▸ Undo, ⌘Z, and the toolbar's Undo and Redo buttons now work on board changes — they had been silently disabled.
The board's symbol now appears in the title bar beside the board's name, in its chosen tint.
The board's symbol can now wear a color: the symbol picker carries a row of tints below the glyphs, with None to clear it.
The board popover's new Theme tab dresses the board in a solid color or a generated pattern: filter by light or dark, colors and saturation, then pick from eight hues.
The board popover is now organized into tabs — Info with the board's vital statistics, and Theme for backgrounds.
The rubber band now highlights cards the moment it touches them, instead of lagging behind on large boards.
The app's appearance can now be set to Light, Dark, or Auto from the View menu or the toolbar's new Appearance item.
A board can now wear a background image, painted across the whole window with a frosted strip keeping the title bar legible.
The background field is now written as a mapping — *{color: green}* instead of a bare *green* — and a board's may name an image beside the color.
Clicking a card or lane now selects it immediately, instead of pausing for about half a second.
**July 2026**
**July 2026**
Development begins — nothing user-facing yet.
Version 2.0: Lanework's first release — a kanban app whose boards are ordinary folders of Markdown files on your Mac.
Edit a board from any other app, script, or AI agent and the open window updates live; outside edits are first-class, never overwritten.
Cards open in their own window with a formatted preview, a Markdown editor with checkable task lists, and a raw-source view.
Attach files to a card by dropping them onto its face or into its card window.
Drag cards and lanes to rearrange them, move or copy them between boards, or drop files from Finder to create new cards.
Cut, copy, and paste cards and lanes — within a board, across boards, or as plain text into other apps.
Deleted cards land in a trash lane you can show beside your lanes; drag a card out (or cut and paste it) to restore it.
Undo and redo cover every board action, including bringing back a deleted lane with all its cards.
A card window keeps its own undo trail while open; closing it folds everything you did there into one board-level undo step.
Start new boards from ten bundled templates, or save any board as a template of your own.
Search filters the board as you type, and new cards you create clear the filter so they never vanish under it.
Style cards and lanes from a twelve-color palette, tint lanes with a band, and give each board its own background.
The whole app works from the keyboard — arrow-key navigation, drag-free card moves, and Full Keyboard Access on every control.
VoiceOver reads boards as lanes of cards, announces outside changes in one polite digest, and names a focused card that was deleted externally.
Every board gets a guide file teaching AI agents the folder format, kept up to date automatically.
A hand-made card file no longer needs an order or schema line — the card simply lands at the end of its lane until you place it.
Every board carries a .gitignore naming which files count as noise, so system files like .DS_Store stay put instead of being gathered into a card's attachments.
Boards open into their own window right away, with a quiet spinner while a large one is read, and ⌘W cancels an open in progress.
A board that won't open now explains itself in that window, listing every problem file by file and grouped by what's wrong.
Each listed file offers Reveal in Finder and Open in Editor, so you can fix it yourself and press Re-check.
Repair and Open makes the fixes that are safe to make — a folder missing its board file, a board missing its format line — and opens the board.
You can skip a file Lanework can't fix and open the board without it; the board then names what was left out, and asks again next time.
Every card can carry a comment thread, shown beside or below the card's text in its window.
The comment composer keeps its draft inside the board itself, so a half-written comment is waiting whenever and wherever you reopen the card.
Comments render Markdown like the card body, take file drops of their own, and note when they've been edited.
Board search now matches comment text, and ⌘F in a card window steps through matches across the whole thread.
Deleting a comment takes effect immediately, stays undoable in the card's window, and board-level undo can still bring it back after the window closes.
Zoom the board in and out from the View menu (⌘+ and ⌘−, ⌘0 for actual size), and the size you settle on is remembered across launches.
@@ -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.
## Tiers
Lanework ships as **one free Mac App Store app** with tiers from one codebase and one format (12-editions.md, re-ruled 2026-07-30): **Lanework** (free — no git; macOS-native undo — 13-native-undo.md), **Lanework Pro** (an auto-renewable subscription unlocking git-backed history, branches, remote sync — 06/07), and **Lanework Teams** (tracker integration over the reserved enhanced schema; deferred, probably a separate app). The deeper reason for the tier seam: history and sync live behind a provider boundary, 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).
@@ -20,37 +20,44 @@ BoardStore (one per open board, @Observable, MainActor)
SwiftUI views (board window + card windows share the store)
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 — this is what makes external editors and agents first-class.
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 write path's do-nothing guards read the pending truth** (ruled 2026-08-06): never-trust-memory is the *render* path's scope, and it does not extend to a guard deciding whether a write would change disk. Between a write's bracket and its echo the snapshot describes the past — a guard comparing an asked-for value against it answers the wrong question, and a fast gesture pair silently loses its second half (the found case: two lane resizes released within one echo, A→B→A — the second compares A against the stale snapshot's A, writes nothing, and the first's echo settles the board at B, the width the user last dragged away from). So a do-nothing guard's baseline is the snapshot **as amended by this store's own in-flight writes** — the value it last wrote to that field and has not yet seen echo (a small pending-value record of the write path's own, *not* the EchoLedger, whose feeds-attribution-only charter stands); a gesture whose meaning is relative (step one width unit) resolves its base against the same amended truth, or a fast double-step loses its second press to the same staleness; and the undo step's recorded prior reads it too, or its inverse restores a state that never was. The amendment dies with its echo — a landed snapshot agreeing with the write clears it — and a failed or refused write never enters it (no echo is coming; the snapshot is still the truth). Per-gesture coalescing was weighed and set aside: one gesture is one write, one echo, one commit (the style batch's rule), and merging two gestures' writes would merge their commits.
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. 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.
- **BoardWriter** — every mutation (create, move, reorder, delete, style) as an explicit filesystem operation. No hidden state; a write is done when the file is on disk. (The EchoLedger's receipts are not this bullet's "hidden state": a receipt describes a *completed* write, and the ledger lives beside the Writer, not in it — no write is ever pending in memory.)
- **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.
- **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). **The reload publishes a changed-path channel — paths only, advisory** (ruled 2026-07-31): each reload vends the debounce window's observed path set alongside the generation bump; the initial load, wholesale/bracket-ending reloads, and root recovery vend **nil — "assume everything changed."** The channel is an **optimization surface, never a correctness input**: FSEvents can coalesce and drop, so every consumer must stay correct against nil or an over-broad set, and the render path never consults it — the snapshot remains the only render input. Consumers classify for themselves (the comments pane and search's comment index filter by `CommentPath`; the announcer keeps consuming the EchoLedger, which stays the sole provenance authority — the channel carries no app/foreign classification). This is what lets window-scoped readers stop re-reading on every generation change.
- **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), 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.
- **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). Its refresh is a scheduled heal riding the HealScheduler (below).
- **HistoryStore** — git plumbing for undo/redo (see 06-history-undo.md).
- **IntegrityRules** — the one pure vocabulary of object validity (01-storage-format.md ▸ Validation and healing; settled 2026-07-29): the identity predicate and its canonical form (one rule shared by `ItemID` and the Writer's string-level checks — today's parallel `canonicalIdentity` derivation folds in), the per-field coercion rulebook, shape classification (the readable-but-uneditable shapes), per-kind index validation (the card validator generalized per kind — board, lane, card, the enhanced schema's comment when it lands), the reserved-name tables (card children, board-root claimed names — today scattered), the trash `kind` discriminator, and the typed **Defect** vocabulary the loader reports. `LoadResult`'s ad-hoc repair channels (loose files, legacy tombstones) become one typed defect stream; tolerate-tier warnings stay warnings — information, not work. **Loader and Writer remain the enforcement points and call in** — the service consolidates rules and policy, never relocates enforcement; a service smeared across the read/write/orchestration boundaries would be worse than the current discipline.
- **HealScheduler** — the scheduled-heal engine: the six-step pattern today re-derived per healer in BoardStore (loose-file relocation, tombstone migration, agent-guide refresh), expressed once — compute work from the latest defects → resting-clear when empty → lock-and-writability gate (the read-only-lock deferral plus the guide's narrow `isWritableFile` defense, generalized to every healer) → signature compare → arm the memo *before* attempting → one write bracket whose write half re-verifies each defect against disk → post per one banner-posture table (each defect class declares loss row / silent / failure-only once; BannerCenter still owns all phrasing) → **clear the memo explicitly on success** (today only the guide does; the others' resting states merely happen to converge). Fires uniformly at the reload tail and at registry acquire — closing today's asymmetry where tombstone migration never fires at open. Inline heals (the midpoint-exhaustion renumber-and-retry, the import-boundary remint) stay gesture-scoped, with the renumber's ask-renumber-ask-again two-step as one shared helper instead of today's nine hand-rolled copies; on-touch heals live at the Writer's `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is rewriting (`kind` backfill; the span editor's duplicate-key twin removal and quote-on-first-write are the same class, named). **Window-scoped heals are memo-less — their trigger is their guard** (ruled 2026-07-31; comments' thread-level claimed-name displacement is the first): the memo exists to break reload-cadence hot loops, and window-scoped work runs only on window open or file change — a failed heal changes no files, so failure cannot trigger its own retry, and a partial success converges on the next read. The class-keyed memo stays board-wide (a thread's picture must never overwrite the board's); each new window-scoped heal owes the same no-self-trigger argument, and if one ever gains a self-triggering shape, scoping the memo key by class × container path is the named next step.
- **HistoryStore** — the history provider behind the tier seam (12-editions.md): the board session binds one `HistoryProviding` implementation at composition, chosen by the entitlement's local read — the free tier's native undo stack (13-native-undo.md, inverse `WriteOperation`s over NSUndoManager) or Pro's git plumbing (06-history-undo.md). One target since the 2026-07-30 collapse: libgit2 and the git provider compile in dormant, and nothing outside the seam touches 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 heals silently** (re-ruled 2026-07-29, superseding the repairable-condition banner): the loader's board-wide dedupe (01-storage-format.md ▸ Fractal layout rules) withholds losing occurrences from every snapshot; a scheduled heal remints them through the Writer (the loader itself never writes) and a warning-tone notice reports the repair — no banner, no button, nothing waits on consent. The withheld window is one heal cycle, not a standing condition; a remint racing a vanished duplicate (repaired elsewhere, a hand-deleted copy) is a no-op, never an error.
- **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal — and, blessed 2026-07-31, the store **skips the assignment entirely** when the fresh snapshot equals the current one: assigning an equal tree into an `@Observable` property still costs a render pass, so "costs nothing visible" becomes *costs nothing*), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **The skip's structural consequence — the two-counter split (blessed 2026-08-06):** once value-equal landings stop bumping the applied-snapshot generation, anything whose subject is the *walk* rather than the applied snapshot can no longer key on it. The store therefore carries two counters — snapshots **applied** (the committed-overlay hold's event, meaning unchanged) and walks **landed** with a snapshot in hand, bumped on every successful reload, equal or not; a failed reload bumps neither. The rule for choosing: **anything outside the snapshot, or about the walk itself, watches landed walks, not applied snapshots** — the card window's comment thread (comments are outside the snapshot, so a foreign comment arriving leaves the model value-equal), the comment search index (same shape), and the auto-committer's covering gate (what covers a flush is a completed walk, whether or not it found anything to show); on the applied counter each would sleep through exactly the value-equal landing it exists to notice. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry). **The walk memoizes its parse, never its result** (blessed 2026-07-31 — a performance posture, not a semantic change): the loader may reuse the previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged — the previous snapshot *is* the memo — while directory enumeration (folder discovery, attachment listings, trash entries) stays fresh every walk, because attachment changes never touch `index.md`. The loader's contract is result-purity with cost unspecified: same tree in, same snapshot out, and the memo can only change how fast. The mtime+size trust is the git-index heuristic; a writer that defeats it — content changed, mtime and size both preserved — is outside the app's care (blessed 2026-08-06 as a decision on file, the boundary being reachable in practice — a byte-length-preserving edit plus deliberate utimes, a restore tool replaying old attributes — and pinned by test: git itself lives with the same blind spot, and the named tightenings — content hashing, which is the read the memo exists to avoid, or fileSystemFileNumber/generation stamps — wait for a real-world defeat, not a hypothetical one).
- **App-initiated git churn is bracketed.** Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. **The bracket also locks writes** (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted.
- **App-initiated git churn is bracketed.** Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. **The bracket also locks writes** (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted.
- **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A liveness flip is a vanish for this purpose**: re-resolution matches UUID *and* liveness side, so a foreign edit that tombstones a selected live card — or restores a selected tombstoned one — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's homogeneous-by-liveness invariant true across reloads. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map).
- **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A container crossing is a vanish for this purpose** (resettled 2026-07-28 — the materialized trash): re-resolution matches UUID *and* container side (board vs `.trash/`), so a foreign move that trashes a selected board card — or restores a selected trash card — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's container-boundary invariant true across reloads. The old effective-liveness ancestor walk is retired with the tombstone model — presence in the snapshot is the whole question. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted or moved to the trash (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map).
- **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and Put Back, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles — and the board refuses drops, including drags arriving from another board's window. Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it).
- **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The rule arms on every bracket exit, thrown operations included** (settled): an operation that fails or aborts mid-flight is precisely when the tree's state is least known, so the mandatory final reload runs regardless — succeeding, it renders whatever the operation left (often value-equal after a clean failure, whose tree is left as it was — 06-history-undo.md); failing, it locks exactly as above. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and restore, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles (these disable in place as controls — 05-card-window.md's in-content rule) — and the board refuses drops, including drags arriving from another board's window.**The lock is a predicate every mutating entry point consults, not a menu-validation sweep** (settled): mutating paths without a menu item exist beyond the checkboxes — the attachment row's ⌫/Remove (grammar key + context menu only, 11-command-nexus.md ▸ Context menus), plain-⌫ delete, Return-creation — and each disables with its surface or follows its command twin's validation; menu validation is the lock's most visible face, never its whole mechanism. **File ▸ Duplicate and File ▸ Save as Template join the disabled set** (settled): both copy the on-disk tree, which the lock marks as gone (vanished root), unknown (this failed-reload state), or unwritable — and both are specified to run the close flush first (03-board-ui.md, 09-templates.md), which the lock's suspended saves make impossible to honor. **One carve-out: under the unwritable-location lock alone (below), Save as Template stays live** — it reads the board and writes into Application Support, the copy-out-is-a-read principle applied (archiving the read-only DMG board being inspected is a legitimate errand). **The carve-out gates on the hazard itself, open sessions, not on lock provenance** (settled): the item disables while any open Edit or raw-source session holds unsaved content — content the lock's suspended saves cannot flush, which the template would silently miss (09-templates.md's never-misses-keystrokes guarantee outranks availability) — and re-enables when those sessions settle or the lock clears. A lock standing since open never meets this state: it disables the flip into Edit mode, so no session can start beneath it and the gate is vacuously open; unsaved sessions under this lock exist only when the symmetric probe (▸ Write-failure surfacing) raised it mid-session. Duplicate stays disabled even there — its destination is the same unwritable parent (a writable-parent/unwritable-board permission split was weighed and set aside as too rare to earn the inconsistency). Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it).
### Write-failure surfacing
### Write-failure surfacing
The read-side rules above have a write-side mirror — one banner vocabulary for both directions:
The read-side rules above have a write-side mirror — one banner vocabulary for both directions:
- **The one-way flow makes write failures honest by construction.** Views render only what is on disk, so a failed Writer operation (disk full, permissions, volume error) never shows phantom state — the action visibly doesn't happen. The failure surfaces in the same non-modal banner as read-side breakage, naming the operation and the cause ("Couldn't move 'Fix login' — disk full"). One-shot actions (move, tombstone, style, create) fail once and wait for the user to act again; nothing is queued behind their back.
- **The one-way flow makes write failures honest by construction.** Views render only what is on disk, so a failed Writer operation (disk full, permissions, volume error) never shows phantom state — the action visibly doesn't happen. The failure surfaces in the same non-modal banner as read-side breakage, naming the operation and the cause ("Couldn't move 'Fix login' — disk full"). **The operation is a closed enum, not a string** (settled): Writer errors identify the failed operation as an enum case (create, move, reorder, delete, restore, style, …) carrying the affected item's title where known; the banner owns all user-facing phrasing and localization from that vocabulary, and a new Writer operation without a banner rendering is a compile-time hole, not a silent default. **The vocabulary grows with the surfaces** (settled): inline rename and the body save get their own cases when wired ("Couldn't rename 'Fix login'…", "Couldn't save 'Fix login'…") — style stays styling-only, never the generic frontmatter bucket; growing the enum is cheap by design. Free-form English survives only inside the diagnostic `reason`, never as the banner's verb. One-shot actions (move, delete, style, create) fail once and wait for the user to act again; nothing is queued behind their back.
- **The debounced body save retries on its own cadence** — keystrokes stay in the dirty buffer, so nothing is lost while the window stays open; the banner stands until a save lands. The **one modal moment on the write-failure path**: closing a window (or the board, or quitting) with a dirty buffer that cannot be written — the only state that exists nowhere but memory — raises an alert (retry / save a copy elsewhere / discard) instead of failing silently. Everything else on this path stays non-modal. (Deliberate confirmations elsewhere are their own stories: Empty Trash… and Delete Immediately on boards without git history — 03-board-ui.md, the branch-switch save-or-discard step — 06-history-undo.md, machine-key regeneration — 07-sync-collab.md, the raw-source Apply validation alert — 05-card-window.md, the once-per-board iCloud/network-volume warning on open/create — 07, and the SSH trust-on-first-use fingerprint confirmation with its mismatch hard-block — 07.)
- **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 the trash's permanent Delete on boards without git history — 03-board-ui.md, the branch-switch save-or-discard step — 06-history-undo.md, machine-key regeneration — 07-sync-collab.md, the raw-source Apply validation alert — 05-card-window.md, the once-per-board iCloud/network-volume warning on open/create — 07, and the SSH trust-on-first-use fingerprint confirmation with its mismatch hard-block — 07.)
- **A vanished board root locks the board read-only** — the bracketed-reload vocabulary applied to a root that is gone (volume unmounted, folder Finder-deleted while open): every write would land nowhere, so the last-good snapshot stays on screen, read-only, banner up. The watcher keeps watching; if the root returns (remount, Finder undo), the next successful reload clears the lock and pending dirty buffers save normally.
- **A renamed or moved board root follows its file identity** (settled): the board the app has open is the *file*, not the path string — the registry's security-scoped bookmark is the identity, mid-session as much as across opens (01-storage-format.md calls Finder renames ordinary, and mid-session must honor that). On any root-gone signal — the watcher's path stops delivering, a write lands on a stale path — the app first **re-resolves the bookmark**: if it resolves to a new location, the rename/move is absorbed transparently — the watcher re-attaches there, Writer URLs and card-window keys re-derive from the new root, one full reload runs, and the window title follows the folder-name fallback where it applies — no banner, no lock, nothing was ever wrong. Only when the bookmark does not resolve is the root truly vanished (below). 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 — **which specific cause, not a shared line** (settled): the probe distinguishes read-only volume from permission-denied folder and the lock reason carries it ("this board's volume is read-only" vs "you don't have permission to change this folder"), the fixes being different acts — 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), **and the probe is symmetric** (settled): a rewritable remount or fixed permission clears the lock without ceremony, and a volume gone read-only mid-session *raises* it at the next probe — banner up front, not every gesture failing one at a time (the lock's own founding rationale). Between probes, a write that hits the newly read-only volume fails as an ordinary one-shot; the next reconciliation converts the condition into the standing lock. The lock's read affordances stay live as always — inspecting an archived board on a DMG is a legitimate errand, and viewing-first is the point.
- **Auto-commit failures beyond `index.lock` contention** (06-history-undo.md covers the lock) — disk full mid-commit, repo corruption: the files are safely on disk but history stops advancing, which quietly suspends the undo trail and the flush-before-overwrite guarantee. That degradation is surfaced, not hidden: the banner states that changes aren't being recorded to history; the committer retries on the next debounce and the banner clears on the first successful commit.
- **Auto-commit failures beyond `index.lock` contention** (06-history-undo.md covers the lock) — disk full mid-commit, repo corruption: the files are safely on disk but history stops advancing, which quietly suspends the undo trail and the flush-before-overwrite guarantee. That degradation is surfaced, not hidden: the banner states that changes aren't being recorded to history; the committer retries on the next debounce and the banner clears on the first successful commit.
- **Attachment import copy failures** (source unreadable, destination full): the drop was accepted — "never refuses the drop" (01-storage-format.md ▸ Attachments) is policy, not an I/O guarantee — so a failed copy surfaces in the banner with the filename, and any partial file is removed; no half-copied attachment is ever left in `attachments/`.
- **Attachment import copy failures** (source unreadable, destination full): the drop was accepted — "never refuses the drop" (01-storage-format.md ▸ Attachments) is policy, not an I/O guarantee — so a failed copy surfaces in the banner with the filename, and any partial file is removed; no half-copied attachment is ever left in `attachments/`.
@@ -58,12 +65,12 @@ The read-side rules above have a write-side mirror — one banner vocabulary for
The non-modal banner named throughout the read- and write-side rules above is one UI component, specified here:
The non-modal banner named throughout the read- and write-side rules above is one UI component, specified here:
- **Hosted by the window of origin.** Every window hosts a banner strip; a condition surfaces in the window whose action produced it — debounced body save, attachment drop, and raw-source Apply failures in their card window; reload breakage, one-shot write failures, commit failures, and lock states in the board window. A card window that closes while its condition persists re-homes the banner to the board window (the condition is still true; it must stay visible somewhere).
- **Hosted by the window of origin.** Every window hosts a banner strip; a condition surfaces in the window whose action produced it — debounced body save, attachment drop, and raw-source Apply failures in their card window; reload breakage, one-shot write failures, commit failures, and lock states in the board window. A card window that closes while its condition persists re-homes the banner to the board window (the condition is still true; it must stay visible somewhere).**Reaffirmed 2026-08-06 against the shipped board-strip interim**: everything posts to the board window's strip today, defended in code by "a card window is not always the frontmost thing on screen" — but a full-screen or other-Space card window whose failing save banners into a window the user cannot see is a silence trap, exactly what the form-anchored rule (06 ▸ form-anchored operations) exists to prevent; the window of origin *is* under the user's eye at the moment of the action. The interim's one good idea is absorbed rather than discarded: **rows name their card wherever ambiguity exists, in per-window strips too** — naming and hosting answer different questions ("whose failure" vs "where the user is looking"), and re-homing keeps the name when a card row lands on the board strip.
- **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: read-only lock > reload breakage > one-shot write failures > commit and attachment failures; newest first within a class. Each row heals or dismisses independently; beyond three rows the remainder collapse behind a "+N more" disclosure.
- **Concurrent conditions stack.** The strip presents independent rows, precedence-ordered:**in-progress rows (pinned) >** read-only lock > reload breakage > one-shot write failures >**loss rows** > commit and attachment failures**> passive info rows** (the remote-change signpost); newest first within a class. **The one-shot failure class carries two shapes** (settled 2026-07-31): the `BoardWriteError`-shaped write failure, and a message-carrying **git-operation failure** — the operation named in the user's words plus the underlying error, phrasing still BannerCenter's — because failures rank by what they are, not by which error vocabulary threw them. A failed undo restore, branch switch, or (pro-m2) pull/push is an action that didn't happen: it presents in the error tone at the failure rank, never as a warning-tone loss row (the shipped loss-row compromise is retired). Git operations stay off the closed `WriteOperation` vocabulary — only the banner tier learns the second shape. Recovery *notices* — "a branch switch was interrupted — the previous state is restored" — report a success, not a failure, and stay warning-tone. **Loss rows are the warning-tone class for non-failure losses** (settled 2026-07-28): content that didn't arrive though nothing failed — folders skipped from a Finder drop, a legacy migration's folded notices, their future kin (the degraded paste left the class 2026-07-29 — a snapshot-less paste now refuses outright, a one-shot failure, 04-interactions.md ▸ Clipboard). They take the one-shot's lifecycle (dismissable, untimed — a loss the user didn't notice is the harm, so it never auto-expires), rank below the true failures (an action that didn't happen outranks one that partially did), and above the ambient notices. 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.
- **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
@@ -72,22 +79,27 @@ 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.
- **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)
### 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.
- **The restoration set is a live open marker, never an at-quit write** (settled): each registry record (Per-board app state below) carries an open-now flag — set when the board's window opens and cleared on *user-initiated* close — quit's teardown closes deliberately leave it standing (the boards were open at quit by definition; teardown distinguishes user-close from quit-close, and that distinction is the whole mechanism). Restoration reads the flagged records, reopening by `lastOpened` order. Crash recovery falls out for free: after a crash the flags describe what was open at crash time, so relaunch restores it — no separate recovery logic, no once-at-quit stamp to race teardown or miss on a crash. The preference gates only whether the flagged set is consulted; the flags are maintained regardless.
- **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.
- **Every open passes through a pre-snapshot loading state** (ruled 2026-07-29): the board window appears **immediately** — welcome click, File ▸ Open…, Finder double-click, restoration alike — at its saved frame, its chrome carrying the registry record's cached title and icon (the same no-scan sources the welcome row reads; a first-ever open shows the folder name, the record's provisional display name). The content area holds a quiet loading surface: a centered system spinner appearing only after a short grace (~200 ms) so ordinary fast opens never flash it — no skeleton lanes, the motion language animates real data only. The first snapshot replaces the surface in place (a snap — there is no prior arrangement to animate from). **The walk is cancellable**: ⌘W during loading cancels it and closes the window, an ordinary user-initiated close clearing the open-now flag. **Restoration is parallel**: every flagged window appears at once in loading state (stacked by `lastOpened` order), each walk independent — a slow network board never delays the others and stays closeable while it loads. Failure resolves by attendance (amended 2026-07-31 — 01-storage-format.md ▸ The decision surface): an **attended** open's fail-fast walk transforms the loading content in place into the aggregated repair surface — a live decision, not an error display — and only Cancel retires the window to the row-level welcome landing; a **restored** window's walk keeps the settled behavior, retiring to welcome row-level — welcome returning if the open came from it or from Finder — and the row's retry click is the attended open that earns the surface. The open walk earns no in-progress info row: the loading state is the surface, and the info-row class stays scoped to copy-shaped work.
- **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 loading window retires (the pre-snapshot state above); 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.
## Per-board app state
## Per-board app state
State that belongs to the app, not the user's files — the recents list, per-board window frames, 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 the app's Application Support container** (re-ruled 2026-07-30 — the one-app collapse removed the App Group wholesale; 12-editions.md ▸ App-side state): 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.
- **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. Records that can't be counted show without counts: unavailable boards per Graceful orphaning below; a board that fails to load just fails on open, fail-fast — the welcome row doesn't pre-detect it.
- **Recents counts are registry-cached.** The lane/card counts in the welcome window come from the record, stamped at last close — no directory scan at welcome time (which would be slow or hang on big/unavailable boards). Staleness until the next open is accepted. **The counts are working items only** (settled, re-grounded 2026-07-28; extended for the lanes-era trash 2026-07-29): items in `.trash/` don't count — a trashed lane subtracts from the lane count and its nested cards subtract from the card count; the row advertises the board's working size, and the trash is an errand, not inventory. Records that can't be counted show without counts: unavailable boards per Graceful orphaning below; a board that fails to load just fails on open, fail-fast — the welcome row doesn't pre-detect it. **A first open that fails fail-fast still records** (settled): the registry record is created before loading, so the failed board lands in recents carrying fail-fast's specifics — retry after fixing the file is one click, uniform with the restored-board failure row. **The record's provisional display name is the folder name** (settled): fail-fast means the frontmatter can't be trusted, and the folder name is the Finder document name the user just picked; the first successful load replaces it with the cached title. A never-successfully-opened record is not a special class — it lingers in recents like any other, and Forget is the eraser for a genuinely mistaken open. **Records that collapse onto one file identity merge silently** (settled — a restored registry file, or an orphan whose bookmark re-resolves onto a recreated board): on detection, the record with the newest `lastOpened` wins wholesale and the others retire — recents never shows one board twice, and per-board settings are conveniences that don't earn a merge UI.
- **The row's title and icon are registry-cached too — with live write-through** (settled): the record carries the board's `title`, `icon`, and `iconColor` beside the counts, and the welcome row reads only the record — it never opens any board's `index.md` (the same hang-avoidance that motivated the counts rule). Unlike the counts' at-close stamp, these three refresh **whenever an open board's reload changes them**: the store already holds the new snapshot, so an in-app Board rename (03-board-ui.md) lands in the record instantly — never a welcome row showing a name the user just changed away from — and a foreign rename of an *open* board rides the same path for free. The honest residual: renaming a board that isn't open (an agent editing its root `index.md`) stays stale until the next open — accepted, the counts' staleness class. The title falls back to the folder name per 01-storage-format.md, cached at the same moments.
- **Files-first stays absolute**: nothing app-private is ever written into the board folder — no frontmatter keys, no sidecar files, no xattrs. Two machines sharing a board via a remote each keep their own record (push-on-commit and window frames are genuinely per-machine choices).
- **Files-first stays absolute**: nothing app-private is ever written into the board folder — no frontmatter keys, no sidecar files, no xattrs. Two machines sharing a board via a remote each keep their own record (push-on-commit and window frames are genuinely per-machine choices).
- **Graceful orphaning**: a record whose bookmark no longer resolves (board deleted, or moved across volumes where bookmarks can't follow) is orphaned — recents surface it as unavailable with Forget; its settings are conveniences and die with it (accepted).
- **Graceful orphaning**: a record whose bookmark no longer resolves (board deleted, or moved across volumes where bookmarks can't follow) is orphaned — recents surface it as unavailable with Forget; its settings are conveniences and die with it (accepted).
- **App-wide state has the same home.** Not everything app-side is board-scoped: quick-style recents (03-board-ui.md), the SSH host-key assignment table and TOFU fingerprint store (07-sync-collab.md — host-scoped), the last-used card-window size (05-card-window.md), and peers live beside the registry in Application Support (or `UserDefaults` where a scalar fits) — no per-board record involved. Secrets are the named exception: Keychain only, never here (07).
- **App-wide state has the same home.** Not everything app-side is board-scoped: quick-style recents (03-board-ui.md), the SSH host-key assignment table and TOFU fingerprint store (07-sync-collab.md — host-scoped), the last-used card-window size (05-card-window.md), the user template store (09-templates.md — plain board folders), and their peers live beside the registry in Application Support (or standard `UserDefaults` where a scalar fits) — no per-board record involved. Secrets are the named exception: Keychain only, never here (07).
## Caching and search
## Caching and search
@@ -100,7 +112,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 (selection, drag, search) gets an explicit home rather than accreting — exact shape TBD during implementation planning.
- The store's transient-state grab-bag gets an explicit home (settled, m3): **TransientBoardState**, one per store, holding state by how a reload treats it. **Item-referencing sets** — selection, drag membership, the pending cut — share one shape (a UUID set plus the container side it lives on, board or trash) and one constraint rule, *members must exist in the current universe*, applied in two directions by one primitive: a reload re-resolves each set independently against the new snapshot (present on the same container side — resettled 2026-07-28, the materialized trash: presence is the whole test, no ancestor walk, no effective liveness), and the search filter constrains the selection to its visible set — the hidden-cards-leave-the-selection rule and the reload-survival rule are one rule, expressed once. **Derived state is stored as its inputs only**: the search query is kept, its result set never is — the predicate re-runs against each snapshot (04-interactions.md's live filter). **The overlays** — transient render state covering the gap between a gesture and its disk echo, each discarding itself at handoff. The **new-card placeholder** is anchored to its lane, not to items: no UUID until the title commits, discarded when a reload drops its lane, and handed off by discarding itself the moment the created card's UUID appears in a snapshot. The **held drop proposal** (settled — 03-board-ui.md ▸ Motion) is its kin on the other side of a write: at drag release the proposed arrangement keeps rendering over the snapshot while the move write brackets, and the proposal discards itself when the echo reload lands (positions match, nothing visibly moves); a failed write or a reload that vanishes the dragged items discards it and the board animates back to snapshot order. **Its home is the app-wide DragSession, not this per-store state** (settled 2026-07-28 — the one overlay that outlives a store's scope): a drag inherently crosses boards, so the hold lives once on AppModel, keyed by board root and snapshot generation; per-store homing would need a store-to-store hand-off mid-gesture for no behavioral gain. The placeholder kinship is semantic — hold, hand off at echo, discard on vanish — not residential. A refused write produces no echo reload, so **a short timeout stands in for the failed-write discard signal** (accepted): the hold snaps back animated when no echo arrives. Trash visibility rides along as a plain per-open value: hidden on every open, never persisted — visiting the trash is an errand, not a layout choice (the trash itself is `.trash/` on disk — 03-board-ui.md).
@@ -4,72 +4,104 @@ The board window: layout, lanes, cards, and styling. Interaction mechanics (sele
## Layout — full visibility
## Layout — full visibility
- **Every lane is always on screen.** The window width divides across the lanes' width units — no horizontal scroll, no enforced minimum lane width. Resizing the window is the width control. (Settled emphatically in the old app: horizontal scroll strays from what kanban is for.) The degenerate case is accepted, not floored: enough lanes/units in a small window compress every lane, titles and cards truncate gracefully, and the remedy is the user's (fewer units, bigger window). A minimum-width setting that reintroduces scroll was considered in the pathfinder and deliberately rejected. **Lane-count changes re-divide, never resize**: new lane (⇧⌘N), lane paste or cross-board lane drop, lane tombstone, Put Back, and Show/Hide Trash (the quasi-lane's fixed unit joins and leaves the division — Trash below) all re-divide the existing window width across the new unit total — window-growing behavior belongs to the right-edge drag alone (Lane below).
- **Every lane is always on screen.** The window width divides across the lanes' width units — no horizontal scroll, no enforced minimum lane width. Resizing the window is the width control. (Settled emphatically in the old app: horizontal scroll strays from what kanban is for.) The degenerate case is accepted, not floored: enough lanes/units in a small window compress every lane, titles and cards truncate gracefully, and the remedy is the user's (fewer units, bigger window). A minimum-width setting that reintroduces scroll was considered in the pathfinder and deliberately rejected. **Lane-count changes re-divide, never resize**: new lane (⇧⌘N), lane paste or cross-board lane drop, lane delete (and its undo), and Show/Hide Trash (the trash lane's fixed unit joins and leaves the division — Trash below) all re-divide the existing window width across the new unit total — window-growing behavior belongs to the right-edge drag alone (Lane below).
- A lane spans a **whole number of width units** (`width` frontmatter, ≥ 1, no cap). Cards stay standard width; a wide lane flows them into as many interior masonry columns as it has units.
- 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).
-**Zoom scales the ruler, never the strip** (settled 2026-08-02 — View ▸ Zoom In / Zoom Out / Actual Size, 11-command-nexus.md). The board's whole geometry is already derived from the body font's point size (10-accessibility.md ▸ Full relative scaling — every figure an em multiple, no fixed point sizes), and macOS supplies no text-size control to move it, so the zoom commands *are* that control: a rung on the level ladder raises the effective body size, and type, card chrome, lane chrome and card heights grow together off it. **Full visibility above is untouched, and that is the whole design constraint**: a canvas magnification would have to widen the strip and reintroduce the horizontal scroll this section rejects, so zoom does not do that — lane *width* stays the window's division at every rung. Zoom does move the inter-lane gap (an em multiple like everything else), so lanes narrow by a few percent across the ladder's whole range; the felt effect is the intended one — zoom in for bigger, more legible cards and fewer per screen, out for a denser board. **The level is app-wide, persisted, and never a property of a board**: it lives beside Show Comments in the app's preferences, not in any lane's or board's frontmatter, because it describes how a user likes to read rather than what a board is. **Zoom never moves the window** — the minimum content size stays pinned to the *system* body size, since window-growing behavior belongs to the right-edge drag alone (Lane below).
- 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)
Toolbars are **pure enhancement**: every function they host already has a menu item + shortcut (04-interactions.md's contract), so nothing below is anyone's only path. Both windows' toolbars are **user-customizable, macOS-native** (right-click ▸ Customize Toolbar…, drag to rearrange, system overflow and icon/text display options) — the sets below are shipped defaults, not verdicts. Toolbar item labels match their menu-item titles exactly (Show Trash, Edit Body, Raw Source, …), minus any trailing ellipsis (macOS convention: "Add Attachment…" labels as Add Attachment) — one vocabulary everywhere, and the customize palette self-documents against the menus. One exception: the Undo/Redo toolbar items keep static labels — NSUndoManager rewrites their menu titles dynamically ("Undo Move Card…", 04-interactions.md ▸ Configurable bindings), which a toolbar label doesn't track.
Toolbars are **pure enhancement**: every function they host already has a menu item + shortcut (04-interactions.md's contract), so nothing below is anyone's only path. Both windows' toolbars are **user-customizable, macOS-native** (right-click ▸ Customize Toolbar…, drag to rearrange, system overflow and icon/text display options) — the sets below are shipped defaults, not verdicts. Toolbar item labels match their menu-item titles exactly (Show Trash, Edit Body, Raw Source, …), minus any trailing ellipsis (macOS convention: "Add Attachment…" labels as Add Attachment) — one vocabulary everywhere, and the customize palette self-documents against the menus. One exception: the Undo/Redo toolbar items keep static labels — NSUndoManager rewrites their menu titles dynamically ("Undo Move Card…", 04-interactions.md ▸ Configurable bindings), which a toolbar label doesn't track.
- **Board window default: the search field, nothing else** — trailing, the one default item; the titlebar stays clean. ⌘F always summons search: with the field removed from the toolbar, invoking it surfaces the field transiently until the search clears. **Catalog** (available via Customize): New Card, New Lane, Undo, Redo (the pair disabled on boards without undo — no-git and repo-nested boards, matching their menu items — 06-history-undo.md), Show Trash (toggle state matching the View menu checkmark). The board popover deliberately has **no toolbar item** — the window-title widget is its committed home (below), and a second entry would muddy it.
- **Board window default: the search field and Appearance** — both **centered** (ratified 2026-08-06 off the 2026-08-01 live try-out, reversing the earlier trailing ruling; Appearance joined the default set 2026-08-07 — 11-command-nexus.md ▸ View ▸ Appearance): the titlebar reads as a placement grammar — leading is board identity (the title widget), center is view controls (search and the app's Auto/Light/Dark override today, the filter family if one ever grows), trailing remains the user's catalog space. The mechanism is `NSToolbar.centeredItemIdentifiers`, deliberately not a flexible-space sandwich: it centers against the window rather than leftover space, holds as catalog items install, and sits outside the autosaved configuration, so it takes effect on machines with a saved arrangement — and `defaultItems` is unchanged, keeping the pinned default-set tests standing. The known tensions were weighed and accepted at ratification: the 400pt leading widget and a centered field share the titlebar's budget in narrow windows (the squeezed field's expand-in-place answer below is the relief), and HIG's trailing-edge convention yields to the grammar. The titlebar stays clean. ⌘F always summons search: with the field removed from the toolbar, invoking it surfaces the field transiently until the search clears. **A squeezed field expands in place; the strip answers only genuine unreachability** (blessed 2026-08-06): a space-constrained `NSSearchToolbarItem` collapses to a magnifying-glass button still in the window, and ⌘F expands and focuses it — the toolbar's own field, a better surface than the fallback — so the transient strip fires only for a truly windowless field (true overflow, or the item removed from the toolbar). **The item's overflow row is the platform's own second answer, and the duality is blessed** (2026-08-06): AppKit's overflow menu row carries a live action that widens the window until the field is usable, and suppressing it would destroy the honest overflow presentation — so a squeezed toolbar reaches search two ways with two honest resolutions: ⌘F expands the item or surfaces the strip (ours), the overflow row grows the window (the platform's) — different gestures, reasonable respective outcomes, no contradiction to resolve. **The field is the platform's own search toolbar item and grows on focus** (2026-08-01): the em-derived width is the *focused* width — applied when the field takes the keyboard, expanding via the item's own animation — and the resting width is AppKit's natural one, not the app's to set. **The two-homes width rule is focused-width parity** (ruled 2026-08-01): the width the user *types in* is the same em-derived figure whichever home the field is in — the toolbar item focused, or the transient strip; the toolbar field's resting width is outside the invariant (the strip never rests — it exists only while a search is live or focused, so it has no collapsed state to mirror). **Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out (the zoom pair is catalog-only, unlike Appearance below — the titlebar's default is the search field plus Appearance, nothing more; each disables at its end of the ladder, and both disable mid-drag like their menu rows — Layout ▸ zoom above), Undo, Redo (the pair disabled only under locks and on empty stacks — re-ruled 2026-07-31, twice: the provider follows the board, so boards without app-managed git — repo-nested included — bind 13-native-undo.md's native stack in **every** tier and Pro git boards bind the git provider — 06-history-undo.md), Show Trash (toggle state matching the View menu checkmark), Appearance (a pull-down of Auto/Light/Dark — 11-command-nexus.md ▸ View ▸ Appearance; the one catalog command that also ships as a default item, centered beside search rather than reached through Customize). The board popover deliberately has **no toolbar item** — the window-title widget is its committed home (below), and a second entry would muddy it.
- **Card window default: Edit Body · Raw Source · Add Attachment** — the window's three committed functions, all discoverable from its toolbar; the catalog is the same trio. Edit Body is a **single toggle button** (on-state in Edit — mirroring the View ▸ Edit Body checkmark and the ⌘E/Return/Escape grammar; the pathfinder's segmented Preview|Edit is retired). Raw Source is likewise a toggle showing on-state; while source mode is active, Edit Body disables (Cancel/Apply own the exits — 05-card-window.md). Add Attachment stays enabled in every mode — attachment operations never touch `index.md`, so they're safe alongside a raw edit (the sidebar's feedback returns on exit).
- **Card window default: Edit Body · Raw Source · Add Attachment** — the window's three committed functions, all discoverable from its toolbar; the catalog is the same trio. Edit Body is a **single toggle button** (on-state in Edit — mirroring the View ▸ Edit Body checkmark and the ⌘E/Return/Escape grammar; the pathfinder's segmented Preview|Edit is retired). Raw Source is likewise a toggle showing on-state; while source mode is active, Edit Body disables (Cancel/Apply own the exits — 05-card-window.md). Add Attachment stays enabled in every mode — attachment operations never touch `index.md`, so they're safe alongside a raw edit (the sidebar's feedback returns on exit).
## Lane
## Lane
- 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. 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), the collapse chevron (below). 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). **No header card-creation control** (removed): the header carried a "+" new-card button through m5; it is gone, and every other creation path — ⌘N, Return on a selected lane, double-click on lane empty space — is unchanged. **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. **At the screen's visible frame the window stops and the drag degrades to the re-divide** (settled 2026-08-08, superseding the pathfinder's hard stop): further snaps re-divide the now-fixed width across one more unit each — siblings compress, exactly the stepper's mechanism — so a lane keeps growing at the siblings' expense once the screen is spent; a window with no headroom to begin with (already at the edge, or full screen) re-divides from the first snap. The two regimes meet without a pixel jump — the re-divided standard at the screen fit *is* the frozen standard. Shrinking mirrors it: re-divide back down to the screen fit, then window shrinking resumes (and shrinking is always allowed). The rubber-band feedback (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound) moves to the true end of travel — the strip's own capacity — and the window still never overflows the screen. The header context menu's Width control (stepper, uncapped) remains the precise control: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress); the drag now reaches those widths too, but the stepper stays the exact, accessible path. 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.
- **Collapsed lanes** (settled 2026-08-08): a lane folds to a **fixed slim vertical strip** — its `icon`, its card-count badge, its title rotated a quarter turn **counterclockwise so it reads bottom-up** (the book-spine orientation) and **justified to the top** of the room below that chrome (ruled by the owner 2026-08-08, reversing the top-down reading of the first pass), and its accent band, at a fixed width (~44pt, an em multiple like every other figure) — and **the strip is not part of the width re-division**: the window's width divides across the *expanded* lanes' units alone, with each collapsed strip's fixed width taken off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family (the window never moves and the siblings grow into what the lane gave up). Persistence is a first-class lane frontmatter key, `collapsed: true` — **document state exactly like `width`**, because the files are the board and an agent must be able to fold a lane by editing frontmatter; absent means expanded, expanding **removes** the key (never writes `false` — the remove-at-default family), and the lane's `width` is preserved untouched while collapsed so expanding restores the lane the user had. **Toggling**: the header's trailing collapse chevron (always visible, not hover-revealed — the header has no hover chrome to match), the lane context menu's single **Collapse Lane** / **Expand Lane** row (single-lane like the width stepper, not batched), and a plain click anywhere on the collapsed strip, which expands it; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. Rename and the expanded header's double-click behaviours are unchanged. **While collapsed**: no resize handle (the width stepper and ⌥⌘→/⌥⌘← still edit the preserved key), and the lane's cards are **not rendered at all** — which is what excludes them from the marquee sweep, from spatial keyboard navigation, and from the ⌥-arrow jumps (↓ from a folded lane is inert, like an empty one), and what makes ⌘N skip the lane for creation (a selection inside a folded lane falls through to the last-active lane rather than refusing). A drop on the strip **appends at the lane's end**, like an end-of-lane drop, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto-expand in v1 (future work). Lane reorder drag works on the strip, and a folded lane's replica and drop shadow are the strip rather than its units. **Search still matches cards in collapsed lanes** — the badge and the flatten lists count them, since collapse is a presentation fold rather than a filter — but the lane stays folded and shows no match: pointing into a folded lane is not something the current search presentation can do, and the honest minimum is that the query does not fold or unfold anything. The **trash quasi-lane is not collapsible**; multi-select styling and the Style editor are unaffected.
## Card face
## Card face
- Leading icon + title. The only face chip in scope is**attachments** (a quiet indicator when the card has files — the title dominates). Metadata chips (labels/assignees/due) went to the enhanced schema with their fields — out of scope.
- Leading icon + title. Two face chips are in scope, both present-only and the same secondary-tinted, decorative styling family:**attachments** (a quiet glyph when the card has files — the title dominates) and **comments** (ruled 2026-08-09, card e729e30a — a quiet bubble glyph plus its count when the card has one or more, joining the attachments chip at the row's trailing edge). Metadata chips (labels/assignees/due) went to the enhanced schema with their fields — out of scope.
- **No body excerpt** (settled): the face stays title-only — the old "iterate on the card face later" item is closed with no growth.
- **No body excerpt, but not "closed with no growth"** (re-settled 2026-08-09 — that old phrasing is retired as stale): the face still never draws a preview of the card's own prose, and that stays closed for good. What *has* grown since is exposure of facts the card already carries structurally, never content: the hero banner (below) and the comments chip both draw from a fact — a named attachment, a folder count — that a sighted glance or a screen reader already had another way to learn, not from anything a body excerpt would have shown.
- **Hero image** (ruled 2026-08-09): a card whose `hero` key names one of its own attachments (a bare filename — 01-storage-format.md § Frontmatter's card table) draws it as a **banner across the full plate width above the icon-and-title row**, aspect-fill cropped to a fixed em-scaled band (2.75 em — 36pt at the standard body, so it scales with system text size and board zoom like every other face figure). Set from the card window's **attachment row context menu** — "Set as Hero" on any image row, "Remove Hero" on the current one (re-ruled 2026-08-09, promoting the day-one "future work" note; 05-card-window.md ▸ Attachments) — and hand-written or agent-written exactly as before. One hero per card: Set on a card that already has one **replaces** it, and the row that holds it shows Remove Hero instead, so there is no Remove-then-Set dance. Image rows only, by extension: offering the row on a `.zip` would let a user set a hero that can never draw. A key that is absent, malformed, or names a file that is missing, unreadable or not an image renders **exactly as no key at all** — the band has no height until a picture actually decodes, so the degrade is structural rather than a branch. Everything else the face draws is unchanged and sits outside the band: the accent stripe runs the plate's full leading edge over it, the selection and file-hover strokes ring the whole plate, the cut/drag dims cover it, and the drop model registers the plate's real height — a hero card is simply a taller card. The trash draws it too (the one-face rule). The drag replica carries the band when the picture is already decoded, which it is for any card the user can see to drag.
- **Titles are optional at every level.** On cards and lanes, a missing `title` renders as a quiet placeholder ("Untitled", secondary styling) wherever the title would appear. On boards, the fallback is the folder name (sans extension), never "Untitled" — see 01-storage-format.md's board-naming rule; window title and welcome recents show `title` when present, folder name otherwise.
- **Titles are optional at every level.** On cards and lanes, a missing `title` renders as a quiet placeholder ("Untitled", secondary styling) wherever the title would appear. On boards, the fallback is the folder name (sans extension), never "Untitled" — see 01-storage-format.md's board-naming rule; window title and welcome recents show `title` when present, folder name otherwise.
- Attachments: **the sole selected card** shows the paged media carousel when it has attachments (the pathfinder's selection-keyed in-place expansion, minus its body blurb — no body excerpt, above). Single selection only: multi-selections and unselected cards stay compact, and the expansion animates under the selection-keyed transaction (Motion below). QuickLook thumbnails for anything previewable, Finder icon fallback, page dots on a glass underlay, paged by trackpad pan / dot click / scroll wheel.
- Attachments on the face: **the chip only — there is no face carousel** (resettled 2026-07-28, reversing the carry-over): the pathfinder's selection-keyed in-place expansion — compact unselected, media carousel when sole-selected — **proved undesirable and does not carry over**. A card has **one presentation**: selection changes styling (the selection treatment), never geometry, so the masonry never reflows on click and a card face is the same object whatever the selection state. The attachment chip above is the face's whole attachment story; viewing media is the card window's job (⌘↩ / double-click — the attachments section and QuickLook, 05-card-window.md). The earlier 2026-07-28 carousel settlements (metrics, tick paging, clamp, dots, marquee suppression, trash exclusion) are superseded with it — recorded on their Resolved cards.
## Styling
## Styling
### Capabilities (settled)
### Capabilities (settled)
- **`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: a mapping, `{color: …, image: …}`, and only a mapping (settled 2026-08-06 — 01-storage-format.md § Frontmatter; a bare scalar has no reading and paints nothing). `color` is a palette name (kebab-case, hand-editable) or a `#RRGGBB[AA]` hex; either subkey may stand alone. Board color paints the board window's content background (the surface behind and between lanes).**A board's background may also carry an image**: the `image` path is relative to the board root so the picture travels with the document. Color and image both paint the **full window** — the content runs under a transparent title bar, with a frosted strip across the title-bar/toolbar band keeping the chrome legible over them; the extended chrome applies only while the board has a background of its own, and a board without one keeps the standard chrome unchanged. The image draws over the color, scaled to fill and cropped, with the color standing in while it loads or if it can't be read; an unresolvable path paints nothing, the lenient degrade an unrecognized color already gets. **The image has exactly one in-app control, and it writes a file rather than browsing for one**: Edit ▸ Paste as Board Background (ruled 2026-08-09) writes the pasteboard's picture into the board folder as `Pasted Background.<ext>` and points `image` at it — the generated-background gesture's shape, minus the colour, because a picture off the pasteboard carries no ground colour to degrade to, so `color` is left exactly as it was. Re-pasting overwrites the board's own file; a file of that name the app did not write is stepped around, Finder-style. There is still **no image picker** — naming a picture that is already somewhere in the folder is the raw file's job, and it stays the escape hatch (the stance custom hex held until the 2026-08-06 combo reversal; for browsing it stands) — and **the board-level ink rule still derives from the color reading alone**: an image makes no AA claim (10-accessibility.md), since a picture has no single luminance to threshold against. 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.
- **The standard chrome is the pathfinder's surface stack** (settled 2026-08-06): the window keeps the neutral system background; every lane wears a quiet quaternary-wash plate (the trash plate's own figure — translucent, so a board-chosen color shows through and the board-level ink rule keeps its premise; opaque under Reduce Transparency, the trash precedent); every card sits on an opaque `controlBackgroundColor` plate — white over the washed lane in light appearance, a step *darker* than the window in dark. One plate value for every face a card draws (resting, replicas, placeholder, arriving), so a card is the same object wherever it renders.
- **`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; grown to 16+16 on 2026-08-09 — the hue ring's four widest gaps filled at lime 64°, jade 158°, indigo 238°, magenta 312°, S/B set per table's own statistics) 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 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 — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't.**The card window sidebar now styles through the compact `SymbolPicker` instead of this component** (2026-08-06 anchor-ownership rule for the background half; the symbol half followed the same way on 2026-08-08): the sidebar pairs the **color combo** (below) — standing in for the background half, the narrow context the combo was built for — with the picker's own well and popover-hosted grid, fed `CuratedSymbols`' card-level set, so this component now has exactly one anchor left, the Style… popover, which alone carries both grids in full. (The board popover was briefly a third anchor — the background half embedded in its Theme tab — until the 2026-08-07 Theme rework made that tab preset-only; Style… with nothing selected is the board's manual grid now, and the popover's symbol picker beside the rename field still owns the board glyph — the same `SymbolPicker` the card sidebar now uses.)
- **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.
- **Curated-first, panel-backed** (re-ratified 2026-08-06, reversing 2026-07-29's palette-only ruling): the background grid offers the 16 palette colors (12 until 2026-08-09) — every one AA-verified through one code path (ratified 2026-07-29: palette names route through the same runtime ink-selection seam as hand-written hex — the appearance flip picks the readable label vocabulary — and PaletteContrastTests pins that the chosen ink meets AA in both appearances for all 16 backgrounds, so palette drift can never silently break it) — plus a leading **None** well that removes the `background` key. Beside the grid the vocabulary now has a second, compact form: the **color combo** — a swatch-faced popup listing None, the role's twelve, the current off-palette value verbatim when there is one, and **Other…**, which opens the system Colors panel. The panel is the in-app escape hatch the 2026-07-29 ruling withheld: a pick landing exactly on a palette color stores the *name* (so a re-pick never drifts to a hex spelling), anything else stores the hex — the same unlimited vocabulary hand-editing always had, now pickable. An arbitrary pick changes no contrast story (it lands on the identical runtime ink computation hand-written hex already gets — 10-accessibility.md), and the quick-style recents stay palette-vocabulary: a panel pick never enters them.
- **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**: three hand-picked, level-specific sets (~30–40 SF Symbols each, one for boards, one for lanes, one for cards — split 2026-08-09 from an original single ~65-entry list); the grid draws from whichever set matches the target's level, or the three combined for a target spanning more than one (unreachable today under 04-interactions.md's cards-XOR-lanes rule, kept as a real fallback rather than an assumption). 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. **The full-browser stance reversed 2026-08-09** (Pipeline card 5004c540 — the pickers-rhyme pass): the symbol combo's face opens a standalone searchable, categorized browser panel — the Colors-panel move, made for symbols, once the browser could be *derived* rather than hand-curated: the catalog reads the OS's own `CoreGlyphs` plists (categories, order, search terms, restrictions), so it inventories exactly what the running OS renders, drops trademark-restricted glyphs, and grows per macOS release with no app-side list to maintain. The raw file remains the escape hatch for names the browser doesn't surface.
- **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 folder (a member moved to the trash leaves the set like any other departure). **The read-only lock instead disables its wells in place** (settled): a popover open when the lock lands stays open, content disabled — the banner names why, and the lock never yanks a surface (the Edit buffer's keeps-its-place posture). The embedded anchors need no rule of their own: the card sidebar dismisses with its card's window, and the board popover's target is the board itself.
- **Quick-style row, recents only**: card and lane context menus carry one compact row of recently used backgrounds plus the Style… item — one-click recolor for the common case; the pathfinder's second full-palette tier is gone. Before any background has ever been applied, the row is omitted entirely — never an empty strip. Recents are app-wide and persist app-side (user preference, never board data).
- **Keyboard path**: Style… is a menu item with a shortcut (04-interactions.md's contract); inside the editor the grids are arrow-navigable and every well Tab-reachable (10-accessibility.md).
- **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 tabbed popover (2026-08-07, restructure complete — all three tab sessions settled).** The symbol/name header stays at the top — the board's glyph with its tint row beside the rename field; below it sit tabs — **Info**, **Theme**, **Git** — each the settings surface for one aspect of board configuration, each settled in its own dedicated session. **Tab membership is the git posture's** (the Git session's ruling): the Git tab joins the strip only when the git section has something true to say — `BoardGitSection` resolves to anything but absent — so a free-tier board with no `.git` shows Info | Theme alone. This carries 12-editions.md's "absent, no placeholder" rule up to the tab strip: a standing Git tab on every free board would be the standing ad for Pro that 12 forbids. *(Pivot 2026-08-07, same day — 12: git left the paywall, so the absent posture is unreachable and every board carries all three tabs. The membership rule stands structurally — the strip still asks the posture — it just never hears "absent" anymore.)***Selection resets to Info on every open** (ruled in the Git session, closing the question the earlier tab sessions deferred): the popover is transient and Info is the board's face — and a remembered tab could strand selection on a tab the next posture doesn't offer. *(Added 2026-08-07, the same night the settings sheet retired: a fourth tab, **Sync** — a standing placeholder, last in the strip, rendering one honest "Nothing here yet." caption. It claims the position where the remote half of the git story will live — tracking, Pull/Push, the badges, and whatever home 07-sync-collab.md's setup surfaces are ruled into — without ruling any of that: the open Redesign card owns the question, and the placeholder is deliberately empty rather than a greyed-out preview.)*
- **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).
**The Theme tab** (settled 2026-08-07, its dedicated session; reworked same day from the first "Background" cut — the style-editor embed was in, then ruled out): a **preset-only** surface — no palette grid, no "Board" subtitle, no manual controls (the manual surface is Style… with nothing selected; the raw file remains the image escape hatch). One **Solid color / Pattern** segmented choice at the top, then the filters that apply to the chosen kind, then one carousel of the eight wheel hues with tall skinny chevrons flanking it (compact-chevron paging buttons, ~three swatches per press, each disabling at its end of the strip). **Solid color** shows Tone (defaults to the current appearance) and Saturation only; the carousel holds flat swatches — each hue at the selected tonality's base saturation and lightness, exactly the primary color the matching facets recipe would write — and clicking one sets `background.color` and *removes*`background.image` in one write (the generated PNG stays on disk so undo can restore the field that pointed at it). **Pattern** is the faceted-background picker (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery, the reviewed recipe): four filters — Tone and Saturation shared with Solid, plus Colors mono/duo/trio and Mesh coarse/medium/fine — over rendered previews, one seed per hue, plus a Reroll button that re-mints the seeds (filters change the treatment, Reroll the geometry; preview and file share a seed, so what's clicked is what lands). Clicking a pattern swatch renders the recipe at the 3072 px decode ceiling off-main and lands it in one write: the PNG into the board root as `facets.png` (Finder-ladder rename only when a foreign file owns the name), `background.image` pointed at it, and `background.color` set to the recipe's primary color — the underlay that stands in while the image decodes or if the file ever goes missing. The mode opens on whichever kind the board currently wears (Pattern when `background.image` is the generated file, Solid otherwise). **Backgrounds ship as static pixels, never live-rendered views** (the perf/sync ruling, 2026-08-07): the generator runs at pick time, the render loop only ever composites a decoded bitmap. Native undo restores the two fields, not overwritten bytes — regenerating over our own PNG is destructive, documented, and accepted. The whole tab disables under the read-only lock as one surface.
- **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.
### Info tab (settled 2026-08-07)
The board's vital statistics, read-only, in two registers with one honest split: **model facts** off the live snapshot — Lanes, Cards, Attachments — counting *the board you see* (the welcome-count live-only rule; the Cards row grows a quiet "· N in Trash" tail only when the trash holds anything, counting freight the way the purge confirms do — never a standing "0 in Trash"); and **disk facts** off one background whole-folder walk — File (the `.board` folder's name), Files, Size, Created, Modified — counting *everything*, `.git` and `.trash/` included, because their job is to agree with Finder's Get Info about the same folder (a size that quietly excluded the repository would send a user hunting for missing gigabytes). Disk facts are honest-as-of-appearance, refreshed per tab visit, never live — `FolderWatcher` filters `.git` churn out of the reload stream by design, so there is no event they could honestly hang off, and a ticking size is motion without meaning on a settings surface; an em dash holds each disk row until the walk answers. Modified is the tree's newest content-modification date, directories included (a deletion-only change moves no file's mtime, only its parent folder's); Created is the folder's filesystem birth date, not frontmatter — the folder may predate any stamp in it. A **Reveal in Finder** link closes the tab — the rows describe the folder, and this is the door to it; not disabled under the read-only lock, since revealing is not a mutation.
### Git tab (settled 2026-08-07)
The pre-tab closing git section rehomed whole — and, later the same day, **the retired settings sheet's contents with it**: this tab is where a board's repository is both operated and set up (a repository-facts dossier in the Info register was considered this session and declined — the popover's git surface is for operating, and per-item history is the card History section's). The postures (06-history-undo.md ▸ Rules; 12-editions.md) render one tab surface each, with the tab's own label doing the work the section's "Git" header used to:
- **No repository** → one caption stating the fact, then **add-git** directly under it — the fact-then-offer posture blessed 2026-08-06, whose middle term was a **Board Settings…** door for the week the sheet existed *(amended 2026-08-07: the split reversed, so the offer is the control itself again)*. *(Pivot 2026-08-07 — 12: this is every tier's posture now, and git stays opt-in per board: the button is an offer, never an auto-init.)*
- **Repo-nested** and **unverifiable** → their settled prose, no action (nothing setup-shaped can apply).
- **Git mode** → the branch display with the **switch picker**, the abnormal-state notes (paused, unreadable, switch failure — 06), and a **Commit Identity** block under a heading VoiceOver navigates by *(rehomed 2026-08-07 from the sheet; withheld when the repository won't open, since writing an identity is a write into a repository the app can't open — the branch surface's own unreadable sentence stands alone there)*; on remote-backed boards, remote tracking (ahead/behind) with **Pull/Push** controls and the status badges (Authentication needed, queued pushes, last error) join as one more block under the branch controls (07-sync-collab.md's cards, whose own setup half needs a home now that the sheet is gone — open).
- **Free + inert `.git`** and **absent** — *retired by the 2026-08-07 pivot (12: git left the paywall)*: the Pro pointer described a gate that no longer exists, and with no free-only postures every board resolves one of the three families above, so the tab is always in the strip.
**Branch creation is the switch menu's again***(2026-08-07, reversing the 2026-07-31 relocation to the sheet)*: below the switch targets sits a divider and a **New Branch…** entry that reveals an inline name field with Create under the branch row — the shape creation had before the split, restored when the sheet retired. Escape steps outward one layer per press (a dirty field clears, an empty one closes the reveal, then the popover dismisses); create-and-switch runs 06's identical settle sequence, and its failures answer at the section's own caption.
**A single-branch board's picker opens onto a disabled explanatory row** (ruled 2026-08-06, built with the tab): the menu's upper half holds only the *other* local branches, and an unexplained gap above the divider reads as a menu that lost something — a disabled "No other branches" row says why there is nothing to pick. *(Amended 2026-08-07: the row taught a second thing while creation lived in the sheet — that creation was no longer here — and that half retires with the reveal's return.)* The popover is now the one configuration home; each control keeps exactly one home within it.
The window-title widget opens the **board popover** — the one board-level surface. **The widget is a two-line identity block** (reworked 2026-08-07): the board's glyph at roughly double its drawn text height, sized to span both lines, beside a stack whose first line is the board's name (titlebar weight, the string the window title would show) and whose second — **git-mode boards only** — is the current branch, smaller and secondary; then the disclosure chevron. It was one line reading `glyph Name — branch ⌄`, and the em-dash retired with the rework: a separator was doing a hierarchy's job, and the branch was competing for width with the name it qualifies. Both lines truncate at the tail inside the widget's 400pt cap; a board with no branch is the same block with one line, centered against the same glyph. Its header hosts, on every board:
- **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).
- **The board glyph** — the symbol picker with its tint row beside the rename field owns the board's `icon`/`iconColor` (Styling ▸ Controls above); manual board styling beyond the glyph is Style… ⌥⌘S with nothing selected, and the Theme tab owns the preset backgrounds.
## Board settings sheet — RETIRED 2026-08-07
**The surface is gone, and the 2026-07-31 popover/sheet split with it.** The split's promise was one home per control across two committed surfaces; a week of it showed the cost — setup a user could only reach through a door, a second surface whose existence had to be validated before either door could point at it, and a menu command that did nothing but open it. So the popover is the board's **one configuration home** again: **add-git** and **commit identity** render inline in the Git tab's postures (▸ Git tab above), **branch creation** went back into the switch menu's New Branch… reveal, the sheet's **availability rule** retires with the surface it gated, and **Board ▸ Board Settings…** leaves the menu bar (11-command-nexus.md). Board Info ⌘I is the door to all of it. The mechanical arguments the split rested on stand as *unfinished business*, not as a case for the sheet: 07-sync-collab.md's credential and SSH surfaces still want confirmation alerts, inline network probes and drag-in key import, and where those live is that card's to rule — the popover is not obviously wrong for them (an alert can present over it), but nothing here decides it.
The retired ruling, kept for its reasoning:
**The setup home** (ruled 2026-07-31 — the popover/sheet split, 04-interactions.md's configuration carve-out): a board-scoped, titled, sectioned sheet on the board window, opened from the popover's Board Settings… row and from Board ▸ Board Settings… (11-command-nexus.md). It hosts everything setup-shaped: **add-git** (mode none; opt-in init — 06), **add/change remote** with the inline verify probe (07 ▸ Setup verifies right there), **credentials** — HTTPS username/token fields and the whole SSH surface (machine key Copy + Verify, key import by paste or **drag** — the sheet's stable frame is part of why it exists — the per-host key picker, unreferenced-import removal, confirm-gated machine-key regeneration), the TOFU first-connect confirm and mismatch block, **commit identity** name/email (06 — the visibility-scoped 2 s config re-read rides with the fields), **branch creation** (switching stays in the popover; create-and-switch runs 06's identical settle sequence from here), and **push-on-commit**. The mechanics that forced the split live comfortably here: confirmation alerts present over the sheet without dismissing the flow that owns them, network probes and their spinners survive focus changes, and typed-but-unverified credentials are never discarded by a stray click. Under the read-only lock the sheet's mutating controls disable in place (the Style-popover rule); every control is Tab-reachable and labeled (10-accessibility.md). Each control has exactly one home — the popover never duplicates a sheet control, the sheet never hosts the daily surface.
## Trash
## Trash
Deletion is a two-stage, Finder-style story: ⌫ tombstones (01-storage-format.md), and the **trash quasi-lane** is where tombstoned items live on screen. It is a **pure view** — tombstoned cards keep their `deleted:` key and stay exactly where they are on disk; nothing about the storage schema is trash-specific.
**Resettled 2026-07-28 — the materialized trash.** The tombstone model (a `deleted:` flag on items left in place, rendered by a pure-view quasi-lane) is **retired**: it generated a standing tax of nesting rules — ancestor walks, effective liveness, entry-vs-universe splits, kind-homogeneous selection — that this design replaces wholesale. Deletion is now a **move**: deleting a card moves its folder into **`<board-root>/.trash/`**, a reserved, materialized container (01-storage-format.md). A trashed card is an ordinary card in a special place — search, selection, rendering, styling, and clipboard all treat it exactly like any other card, and `.trash/` is self-describing in Finder and to agents.
- **Rendering**: trailing (rightmost) position, visually distinct — dimmed/hatched header, trash SF Symbol, count badge; no new-card button; not draggable, not resizable, excluded from lane reordering. It spans a **fixed one width unit** — no `width` frontmatter, and neither the stepper nor the edge drag applies — consumed only while shown: Show/Hide Trash is a re-divide trigger (Layout above), dividing the window across lane units + 1. A small window compresses like any lane add — accepted, not floored.
- **Lanes trash too** (re-ruled 2026-07-29, retiring "cards only" and with it the design's sole destructive delete): deleting a lane moves its folder — subtree intact — into `.trash/`, exactly as a card moves; `kind: lane` in its frontmatter is what tells a trashed lane from a card in the flat container (01-storage-format.md ▸ Deletion), stamped on the way in when absent. The no-dialog posture survives for a better reason: the move is recoverable, so nothing needs confirming. Native undo's inverse is the ordinary move back (13-native-undo.md — the capture/recreate machinery retires). **A trashed lane is an opaque unit**: one distinct dimmed row showing its title and held-card count ("Doing — 5 cards"), no styling accents, never expandable; its cards are invisible to search and not individually addressable — it restores whole or purges whole. The row matches the search filter by lane title only. **The held count is a load-time disk fact** (pinned 2026-07-31): counted from a subtree the snapshot deliberately does not hold — the model's one number not derivable from the model — refreshed by reload like every snapshot field (its changes speak in the digest, 10-accessibility.md); the freight confirm's "…and its 5 cards" is thereby the design's one confirmation counting unrendered content, honest as of the latest reload — the opaque-unit trade, deliberate. Lane rows and cards interleave in the one trash column by `modified` descending (the trash's sort — Entry below).
- **Contents**: the board's tombstoned cards, sorted by `deleted` timestamp (newest first). A tombstoned *lane* appears as a single restorable entry — its cards were hidden with it, not individually tombstoned, and it restores as a whole.
- **Entry is always at the top — the trash sorts by `modified` descending** (re-ruled 2026-07-31, retiring the arrival rank mint): every arrival, card or lane — ⌫/⌘⌫ delete and drag-to-trash alike — lands at the top because the move **stamps `modified`** (a container-changing move — 01's `modified` scope; deletion is an edit to the card's story), and that stamp is the position: newest-first with no `order` rewrite, no rank minting, the item's `order` key riding along untouched for its eventual restore. Ties break by title (case-insensitive), then folder name. **Undated entries sort after every dated one** (blessed 2026-07-31 — the comments rule's rung applied here: malformed sorts after dated siblings), then fall to the same tail. **Whole-second stamps make batch deletes tie deliberately** (blessed 2026-07-31): `modified` serializes at second granularity, so a multi-item delete bracket — and any two deletes inside one wall-clock second — carries identical stamps and orders by the tail; "newest first" reading as title-order within a second is the accepted consequence, not worth a schema-wide move to fractional stamps. **The merged order is one derivation** (both kinds interleaved — the column, the keyboard grammar, and the path resolver all read the same sequence; a second implementation of "the row below this one" is a bug by definition). The stamp is also what a future age-based auto-purge will read (deferred, 01-storage-format.md).
- **Visibility**: hidden by default; **View ▸ Show Trash** toggles it (⇧⌘T; stable title with checkmark state, per 04-interactions.md's configurable-bindings rules). Transient board-scoped state, held in the BoardStore (02-architecture.md; one board window per board, so board-scoped and per-window coincide today) — resets to hidden on open, not persisted (visiting the trash is an errand, not a layout choice). Hidden trash is invisible to search; shown, it participates in the filter like any lane.
- **Rendering**: trailing (rightmost) position when shown, visually distinct — dimmed/hatched header, trash SF Symbol, count badge — **the badge counts rows** (blessed 2026-07-31): its one invariant is matching what the column draws, so a trashed lane counts as one whatever its freight; card-level totals surface where consequences are decided — the freight confirm and the spoken accessibility value; no new-card button; not draggable, not resizable, excluded from lane reordering. Fixed one width unit, consumed only while shown; Show/Hide Trash is a re-divide trigger (Layout above). **Visibility**: hidden by default; View ▸ Show Trash toggles (no default chord — ⇧⌘T belongs to the system's Show Tab Bar, 11-command-nexus.md); per-open transient state, resets to hidden, never persisted. Hidden, the trash is invisible to every gesture and to search; shown, its cards participate in the filter **exactly like any other card** — the point of the pivot.
- **Put Back** (context menu, Finder vocabulary; ⌘⌫ on a tombstoned selection — Finder's own symmetry): removes `deleted:` — the item reappears in its lane at its old `order` (ties break deterministically). Putting back a card whose parent lane is tombstoned restores the lane too. Restore fidelity is perfect because nothing ever moved.
- **No Put Back** (settled): the valuable item is the card (or lane); where it goes on the way out is the user's cheap decision. Restoring is an ordinary move out: **drag** a trash card into any lane at any position — or a trashed lane row to a lane-strip slot — or **⌘X in the trash, ⌘V** — into a lane for cards, after the anchor lane for a trashed lane (04-interactions.md's lane-paste rule verbatim); the clipboard works on trash items like on any item, which is also the keyboard-native restore path (10-accessibility.md). Dropped on another board it follows the drag locality model (04-interactions.md).
- **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.
- **No editing in the trash**: trash cards don't open — double-click stops at selection; move it out first — and a trashed lane row never expands. Moving a card to the trash dismisses its open card window (05-card-window.md), and an external move-in observed by reload does the same; a lane entering the trash dismisses the open card windows of every card it carries (they entered the trash with it).
- **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.
- **Permanent deletion**: on a trash selection, **Delete (⌫/⌘⌫) is permanent** — one delete vocabulary, staged by place: on the board it moves to the trash, in the trash it removes the folder. **Delete Immediately is deliberately absent** (removed 2026-07-30): Finder's ⌥⌘⌫ answers disk-space pressure boards don't have, and it was the one gesture reaching unrecoverable straight from the board — permanence is only reachable inside the trash, where the staging makes the loss visible. The trash-side Delete **confirms exactly where the loss is real** (carried over): on boards without app-managed git history the alert stands between one keystroke and unrecoverable deletion; on git boards it acts immediately (delete-never-forgets). Confirms name the freight honestly — a trashed lane's alert counts its cards ("Permanently delete lane 'Doing' and its 5 cards"). **Empty Trash…** (⇧⌘⌫) confirms everywhere and purges the whole `.trash/`, lane subtrees walked, search-independent, the confirmation naming the full count ("Permanently delete 41 cards", "… 41 cards and 2 lanes containing 9 more cards" — 06-history-undo.md's plural folding); menu validation's "non-empty" reads `.trash/`, not the filtered view.
-**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).
-Every trash operation is an ordinary file operation — auto-committed and undoable on git boards; native undo restores a delete-to-trash in-session on any board (a trash move undoes as a move back — durable across relaunch, since it is just a move), while a permanent delete registers no step (13-native-undo.md: the confirm is the safety).
- **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).
- **Naming constraint** (carried over): attachment Remove moves files to the *system* Trash (05-card-window.md); board deletion says "Delete" and this container is "Trash" / "Show Trash" — Finder's "Move to Trash" phrasing stays reserved for the system Trash.
-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).
-**Materialized reserved lanes are a pattern, not a one-off**: `.trash` is the first; an **archive lane** (`<root>/.archive`, intake criteria to be designed) is the anticipated second (WISHLIST) — same mechanics, ordinary cards in a reserved dot-named container, different entry semantics. Nothing beyond `.trash` is committed yet.
- **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.
## Welcome screen & templates
## Welcome screen & templates
@@ -77,7 +109,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. 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.**A sandbox refusal of the sibling write falls back to a save panel** (settled — the board's security-scoped bookmark grants its subtree, not its parent, so the sibling destination may be unwritable): the silent Finder-style sibling is attempted first; on a permission refusal a save panel opens pre-filled with the parent folder and the "copy" name — the panel's grant is the sandbox's own answer, and it doubles as a choose-another-location affordance. Cancelling the panel cancels the duplicate quietly (no banner — the user declined, nothing failed); non-permission failures (disk full, …) keep the ordinary one-shot banner. **The copy itself is cancellable** (settled — 02-architecture.md's in-progress banner promises Cancel on copy-shaped work, and Duplicate honors it): the copy runs as a per-item file walk that checks cancellation between items — never one monolithic `copyItem` — and Cancel removes the partial sibling before dismissing the banner (the attachment partial-cleanup precedent): a cancelled duplicate never happened. The copy is preceded by the close flush (02-architecture.md ▸ Windows; the rule and its Edit-session exception are stated at 09-templates.md ▸ Save as Template), so neither the tree nor the copied history misses pending work; under the read-only lock Duplicate disables in every state (02-architecture.md — the flush can't run and the sibling destination shares the board's fate). The duplicate **opens in its own board window** once copied — macOS Duplicate convention; the original stays open too. On a git board, the duplicate **keeps `.git` but has its remote configuration stripped** — remotes only: the repo-local `user.name`/`user.email` (06-history-undo.md's identity home) survives, so the fork keeps its commit identity. The copy keeps every GUID — a whole-board copy is 01-storage-format.md's explicit carve-out from the copies-remint rule (a new identity namespace, no collision possible), and keeping them is what keeps the copied history true: its commits name paths that still exist.**The trash is carried too** (settled, re-grounded 2026-07-28): Duplicate is a full fork, `.trash/` included — dropping it would leave the copy's working tree disagreeing with its own copied HEAD (the trash folders are tracked), where keeping it means the duplicate is born exactly matching its history; Empty Trash in the copy is one command away. Save as Template makes the opposite choice — a template isn't a fork (09-templates.md). A fork of the board keeps its history (undo trail, delete-never-forgets — and it opens straight in git mode via 06-history-undo.md's adoption rule), but it must not silently push into the original's remote — sharing stays a deliberate per-board opt-in. (Push-on-commit lives app-side in the board registry and never carries to a new board path anyway.)
## Editing surfaces summary
## Editing surfaces summary
@@ -87,7 +119,7 @@ The welcome window carries over from the pathfinder unchanged — confirmed, it
| Card body | Card window (05-card-window.md) |
| Card body | Card window (05-card-window.md) |
| Lane title | Inline rename on the header |
| Lane title | Inline rename on the header |
| Colors / icons | The style editor — card sidebar Style section (05-card-window.md), board popover, or Style… (context menu / Board ▸ Style…) |
| Colors / icons | The style editor — card sidebar Style section (05-card-window.md), board popover, or Style… (context menu / Board ▸ Style…) |
@@ -100,9 +132,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 search query (filter reflow) and on the drag's **drop proposal** (the reflow-to-make-room above animates under it, ~0.18 s) — never on broad state like the selection set (selection changes styling only, never geometry — Card face above, the no-carousel resettlement). What stays animation-free by construction rather than by suppression: the drag replica's tracking and the marquee rectangle (1:1 cursor following — animating input echo would be lag), and multi-select churn. **The replica's bracketing transitions do animate** (settled): the pickup lift (scale + shadow as it detaches from the card) and the cancel fly-back are the system drag session's own behaviors and match the spec verbatim; only the tracking between them is verbatim input echo. **The drop settle is the board's, not the replica's** (resettled 2026-07-28 — drags are system `NSItemProvider` sessions, required for cross-board transfer and the copy badge, and a successful drop's drag image has no fly-to-slot hook, only AppKit's brief fade): at release the held overlay (below) renders the dropped arrangement instantly while the system fade dissolves the drag image over it — the item is in its slot the moment the mouse releases, which is the promise that matters. A custom fly-to-slot animator (shadow-window replica, masked system fade) remains a deliberate later upgrade, not a commitment (WISHLIST). **The settle holds the drop proposal until the echo lands** (settled — the one-way flow means the write is still in flight at release, and a snapshot-order re-render would glide the dragged item back before the reload animates it forward again): the proposal survives release as overlay state in the app-wide DragSession (02-architecture.md — the placeholder's kin in semantics; app-wide in home because a drag crosses boards), the board keeps rendering the proposed arrangement under the system fade (the drop settle above) — **the release presentation is an open question** (reopened 2026-07-28): the first treatment — swapping the shadow for the dropped card(s) drawn in place immediately at release — was implemented and backed out on user review; the pause between release and the card's appearance still wants a designed answer, revisited separately (Redesign board ▸ Issues to Resolve). Until then the shadow holds through the gap and the card appears at the echo — and the proposal discards itself when the bracket's echo reload lands — positions already match, so the handoff moves nothing. A **failed write discards the proposal** and the board animates back to snapshot order with the ordinary one-shot banner — the width-drag rollback posture (the action visibly doesn't happen); a foreign reload that vanishes the dragged item discards it too (02-architecture.md's constraint rule).
- **Motion never feeds back into logic** (the pathfinder's animation-proof-inputs rule, kept as a hard constraint): drop-proposal math reads analytically computed resting zones, the physical mouse position, and item sizes frozen at drag start — never mid-flight measured frames, which are garbage precisely during the ~0.2 s reflow they trigger.
- **Motion never feeds back into logic** (the pathfinder's animation-proof-inputs rule, kept as a hard constraint): drop-proposal math reads analytically computed resting zones, the physical mouse position, and item sizes frozen at drag start — never mid-flight measured frames, which are garbage precisely during the ~0.2 s reflow they trigger.
- **Reduce Motion is a rewrite obligation, not an inheritance**: the pathfinder ships zero reduced variants; 10-accessibility.md's commitments (crossfade or instant for reflow, search animate-out, the drag replica, trash) are new work.
- **Reduce Motion is a rewrite obligation, not an inheritance**: the pathfinder ships zero reduced variants; 10-accessibility.md's commitments (crossfade or instant for reflow, search animate-out, the drag replica's lift and settle, the lane-resize rubber-band feedback, trash) are new work.
-**The range anchor** (settled — standard macOS list semantics): the anchor is the last plain- or ⌘-clicked item, per board window, transient — never persisted. ⇧-click ranges from anchor to target in the flatten order (cards), lane order (lanes), or the trash's own order — rows of both kinds included, since trash selection went kind-blind (re-ruled 2026-07-31; The trash below) — replacing the selection and leaving the anchor in place. A marquee and wholesale selections (Select All) set no anchor, so a following ⇧-click acts as a plain click; a reload that drops or liveness-flips the anchor clears it.
-Lane empty-space: single click selects the lane (click again to unselect); double click creates a card at the bottom, title editor focused.
-Selection is **homogeneous**: cards XOR lanes — on the live board. The trash's selection is **kind-blind** (re-ruled 2026-07-31; The trash below): cards and lane rows select together there, and the guard lives at the exits instead.
- **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) — which extends the full column height below the last row, card and lane rows alike (re-affirmed 2026-07-29; the rewrites dropped the clause, the ruling never changed): no dead zone, a band can arm from anywhere in the shown trash's column.
- 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
@@ -15,17 +17,19 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
- Cards reorder within a lane and move between lanes (folder move). Lanes reorder; a full-size replica travels under the cursor.
- Cards reorder within a lane and move between lanes (folder move). Lanes reorder; a full-size replica travels under the cursor.
- **Multi-drag**: dragging any member of a multi-selection drags the whole selection; N contiguous shadows; drop inserts contiguously in preserved relative order — defined, for any multi-selection, as lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom).
- **Multi-drag**: dragging any member of a multi-selection drags the whole selection; N contiguous shadows; drop inserts contiguously in preserved relative order — defined, for any multi-selection, as lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom).
- **Locality picks the default — the Finder volume model** (settled): within a board a drag is a **move** (rearranging); between boards it is a **copy** (transferring — the system copy badge shows over the foreign board). **⌥ always forces copy** and **⌘ always forces move**, Finder's exact modifier grammar; each is a no-op where its behavior is already the default. The badge tracks the effective operation live as the cursor crosses a board boundary.
- **Locality picks the default — the Finder volume model** (settled): within a board a drag is a **move** (rearranging); between boards it is a **copy** (transferring — the system copy badge shows over the foreign board). **⌥ always forces copy** and **⌘ always forces move**, Finder's exact modifier grammar; each is a no-op where its behavior is already the default. The badge tracks the effective operation live as the cursor crosses a board boundary.
- **Within-board ⌥-drag copies**: originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop. Lane drags never copy *within their board* — a lane duplicate inside its own board stays unsupported; ⌥ is simply ignored there (the drag stays a move and the badge never shows copy).
- **Within-board ⌥-drag copies**: originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop. Lane drags never copy *within their board* — a within-board lane duplicate is **not available by drag** (⌥ is simply ignored there: the drag stays a clean reorder and the badge never shows copy); the duplicate itself is supported, via the clipboard (Lane paste below) — the usual shape: the keyboard path is the canonical one, drag the enhancement (10-accessibility.md).
- **Cross-board copy** (the default): cards and lanes (including multi-selections) drag between open boards; fresh-GUID duplicates land at the drop, originals stay, `created` is kept (a copy is a fork — 01-storage-format.md). Lanes copy cards and all — transferring workflow structure between boards is safe by default. A lane copy **strips tombstoned cards**: the copy transfers content, and trash isn't content (09-templates.md's instantiation precedent — a board isn't born with trash); the tombstoned originals stay recoverable in the source board. A ⌘-drag *move* carries them whole — the folder moves as-is, and they land in the destination's trash.
- **Cross-board copy** (the default): cards and lanes (including multi-selections) drag between open boards; fresh-GUID duplicates land at the drop, originals stay, `created` is kept (a copy is a fork — 01-storage-format.md). **Locality means the board the drag was picked up from** (ruled 2026-08-06): a drag knows where it came from — the Finder volume model — so a rename or Finder move of the source board absorbed mid-drag is not a departure: the relocation carries the live drag with it (the drag holds its source *store's* identity, not a frozen copy of its key — a frozen key would let a new board opened at the vacated path compare equal to the renamed-away one, turning a copy into a silent cross-board move, the worse failure), and a within-board reorder stays a reorder across a mid-drag rename. Until the carry ships, the recorded residue — the minted key follows the folder, so a mid-drag rename finishes a reorder as a copy — is cosmetic and bounded by the seconds a drag is in flight. Lanes copy cards and all — transferring workflow structure between boards is safe by default. A lane carries exactly its cards — the trash is board-level (`.trash/` — 03-board-ui.md), so there is nothing lane-nested to strip or carry: copy and ⌘-drag move alike transfer the lane's folder as it is (resettled 2026-07-28; the old tombstone-stripping rule is retired with the tombstone model).
- **Cross-board move** (⌘-drag): a real filesystem move, works across volumes — identity travels. A moved folder whose UUID already exists in the destination board arrives as a fresh-UUID copy (01-storage-format.md's import-boundary rule); in a compound move (lane with cards, multi-selection) only the colliding folders are reminted — the rest is a true move (01's per-folder degradation).
- **Cross-board move** (⌘-drag): a real filesystem move, works across volumes — identity travels. A moved folder whose UUID already exists in the destination board arrives as a fresh-UUID copy (01-storage-format.md's import-boundary rule); in a compound move (lane with cards, multi-selection) only the colliding folders are reminted — the rest is a true move (01's per-folder degradation).
- **Files from Finder**: dropped on a card → copied into its `attachments/` (any type, multi-file; card highlights while hovered). Dropped on lane empty space → creates a card with the file attached, titled with the filename without its extension (multi-file drop: one card per file).
- **Files from Finder**: dropped on a card → copied into its `attachments/` (any*file* type, multi-file; card highlights while hovered). Dropped on lane empty space → creates a card with the file attached, titled with the filename without its extension (multi-file drop: one card per file).**Folders are refused at hover** (settled — the attachment model is flat top-level files, and the importer refuses directories by design): a drag containing only folders never engages — no highlight, no drop proposal, the standard incompatible-payload read; a mixed drag proposes for its files only, and the drop imports the files while a loss row (02-architecture.md's warning tone) names the skipped folders ("Folders can't be attached — 2 skipped"). The create path thereby only ever fires with at least one importable file — no card is minted for an import that cannot succeed. **Created cards land at the drop position** (settled): resolved through the same card-grid zones an ordinary card drag uses, shadow included — drops are positional everywhere, and append-at-bottom stays the creation *trio's* rule, not the drop's. A multi-file drop shows **one nominal-height shadow per incoming file** (the multi-drag precedent; when macOS withholds item counts during hover the count floors at one shadow, the commit unaffected). **A release on the lane header resolves to the topmost position** (settled — forgiving beats a dead stripe: the header's chrome roles don't collide with a file payload). **The landing shadow is the create path's whole feedback** (settled): no lane-level highlight on top — each target gets one clear signal, and the card-attach highlight exists precisely because that target has no shadow.
- **A foreign reload mid-drag re-grounds the drag, never corrupts the drop** (settled — a two-second drag racing agent edits is the designed concurrency). Three rules compose: (1) **geometry re-derives** — the frozen-at-drag-start inputs are the *dragged items'* sizes and the physical pointer only (03-board-ui.md ▸ Motion); the analytic resting zones recompute against each new snapshot, so a foreign lane-count re-divide mid-drag just moves the zones and the next proposal targets the board as it now is. (2) **Proposals re-validate by liveness** — a proposal whose target lane vanished in the reload is invalidated; the shadow withdraws and no proposal stands until the pointer reaches a live target, and **release with no valid proposal cancels** — items return, nothing is written; a card is never filed under a vanished parent. (3) **An emptied drag cancels itself** — drag membership is already a UUID set that vanished items leave silently (02-architecture.md); when the *last* dragged item vanishes the replica dissolves and release is a no-op. Partial vanishing drops the survivors, matching the pending-cut precedent. **A cross-board lane arrival pre-divides the destination strip during hover** (settled): while a foreign lane drag proposes into a board, the destination's standard width is computed with the arriving run's units included, so the shadow draws at the width the lane will actually take — without this it overflows the strip (the pathfinder's stripWidthUnits). The first entry samples the un-widened standard for one frame before hysteresis settles — accepted, imperceptible.
## Clipboard
## Clipboard
- ⌘X/⌘C/⌘V on cards **and lanes** (resettled — lanes joined the clipboard so cross-board structure transfer has a keyboard path under the every-function contract; the cards-XOR-lanes selection rule means the clipboard holds cards or lanes, never both). Hybrid clipboard: pasteboard carries a JSON manifest + plain text; full folder snapshots staged in Application Support so paste reproduces the item byte-for-byte — cards, attachments and all — across boards. Each manifest entry embeds the full `index.md`as a staging-less fallback (a lane entry embeds its cards' too, attachment-less).
- ⌘X/⌘C/⌘V on cards **and lanes** (resettled — lanes joined the clipboard so cross-board structure transfer has a keyboard path under the every-function contract; the cards-XOR-lanes selection rule means the clipboard holds cards or lanes, never both). Hybrid clipboard: pasteboard carries a JSON manifest + plain text; full folder snapshots staged app-side (02-architecture.md ▸ Per-board app state's app-wide home) so paste reproduces the item byte-for-byte — cards, attachments and all — across boards. Each manifest entry embeds the full `index.md`— identification metadata (menu validation, refusal wording, the plain-text flavor's source), **never a materialization source** since the 2026-07-29 refuse-don't-degrade ruling below (a lane entry embeds its cards' too). **Staging lifecycle** (settled): snapshots are staged **eagerly at ⌘C/⌘X time** — copy captures the source as it is at the gesture, immune to later deletion or unmount — and the store holds at most the *current* copy: a new Lanework copy replaces the previous snapshot, and a sweep at launch and on each copy purges entries the pasteboard no longer references (another app taking the pasteboard orphans the snapshot; the next sweep collects it). The snapshot survives relaunch exactly as long as the pasteboard still points at it — a copy made before quitting pastes whole after restart. **A paste is an import boundary, so normalization applies** (settled 2026-07-28 — 01-storage-format.md's loose-file rule): loose files the staged snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`, Finder-renamed on collision — nothing the snapshot preserved is dropped on arrival. **A paste whose staged snapshot is missing or unreadable refuses loudly — never degrades** (re-ruled 2026-07-29, retiring the degraded embedded-`index.md` fallback and its loss row; Finder's invariant adopted, and 01's leniency doctrine applied — proceed-partially-lose-a-little is never a verdict): the paste produces **nothing**, and a one-shot failure banner names it from the manifest's metadata ("The copied cards are no longer available" / "Couldn't paste 'Fix login' — the copied content is gone"; BannerCenter owns the phrasing). An item arrives **whole — index, attachments, loose files, and comments when they ship — or not at all**; a hollowed card is never materialized, so the loss-accounting problem (what didn't arrive, and whether the totals are honest) dissolves rather than being solved. The refusal is transactional — all-or-nothing for the whole paste, the copies-are-transactions posture (01). With eager staging and the shared-store sweep discipline this is a rare corner, not a flow: the refusal names it, and ⌘C again is the recovery. **The pasteboard is re-read lazily, and the brief lie is accepted** (settled): changeCount is checked on activation, on menu validation, and before paste — no timers. Menu validation is two concrete checkpoints (refined 2026-08-09, with the image branch): a menu beginning to track, and **⌘ going down** — commands validate by conditional responder attachment, so the key-equivalent path needs a re-read *before* the chord's letter lands, and the modifier's own beat is that moment; without it, the one pasteboard writer that never deactivates the app (the screenshot hotkey) left ⌘V dead until the next app switch. A background app taking the pasteboard while Lanework stays frontmost can still leave Edit ▸ Paste enabled until the next check, and the paste itself re-validates and no-ops — nothing stale ever lands, which is the guarantee that matters.
- **Cut is Finder-style deferred**: cut items dim in place until paste moves them; voided if another app takes the pasteboard or the source board closes; second paste materializes copies. **Deletion voids per item**: a cut item that is tombstoned or vanishes externally before paste drops out of the pending cut — 02-architecture.md's UUID-set rule; transient state never resurrects what's gone — so paste moves only the survivors, and a cut voided down to nothing is simply void (paste disabled, no error).
- **Cut is Finder-style deferred**: cut items dim in place until paste moves them; voided if another app takes the pasteboard or the source board closes; second paste materializes copies. **Deletion voids per item**: a cut item that is deleted (moved to the trash or destroyed) or vanishes externally before paste drops out of the pending cut — 02-architecture.md's UUID-set rule; transient state never resurrects what's gone — so paste moves only the survivors, and a cut voided down to nothing is simply void (paste disabled, no error).
- Paste lands after the anchor card (or appends to a selected lane). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`.
- Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at its last member in flatten order — the ⌘N target rule's shared anchor (The map below). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`. **A trash selection never anchors paste** (settled — the ⌘N target rule's own wording, returned to the precedent it cites): ⌘V stays enabled and behaves exactly as with nothing selected — a card payload appends to the last-active lane, a lane payload lands at the board's right end; the trash is never the destination (▸ The trash), and a trashed card's live disk-lane never leaks in as "the selected card's lane".
- **Lane paste** lands after the anchor lane — the selected lane, or the selected card's lane; 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.
- **⌘V's image-data branch** (ruled 2026-08-09): when the pasteboard carries **raw image data and no file URL** — a screenshot, a browser's Copy Image, Preview's ⌘C — ⌘V lands the picture as a new file in a card's `attachments/`. **Precedence, in one order**: the app's own clipboard type wins outright (a Lanework copy still pastes as cards or lanes); a **file URL** on the pasteboard suppresses the branch entirely (a Finder copy is a reference to a file the user already filed — a different gesture, and this one declines rather than guessing); raw image data is the fallback. **Target**: the board window pastes onto the **anchor card** (the existing paste-target grammar — the last selected card in flatten order), so a lane, an empty and a trash selection all grey the row out rather than the app guessing a card; a card window pastes onto **its own card**, and a focused text field still wins ⌘V natively, as everywhere else. **Format**: a file-shaped flavor is kept byte for byte — PNG, JPEG, GIF, HEIC, WebP, extension its own — and everything else (TIFF, BMP: interchange encodings, not files people want in a folder) is re-encoded to PNG; PNG is preferred when several flavors are offered, which is the screenshot's case. **Name**: Finder's own — `Pasted Image.png`, then `Pasted Image 2.png` — minted by the existing import path's own collision ladder. It **is** that import path (the one Finder file drops and ⇧⌘A take), so the bracket, the read-only lock, the banners, the echo receipts and "no undo step for an attachment arrival" all hold unchanged, and the arrival announces exactly as a drop's does. A pasteboard that declares a flavor it cannot back up writes nothing at all. **Edit ▸ Paste as Board Background** is the same payload's other destination and a named row rather than a ⌘V branch — a backdrop has no selection to target (03-board-ui.md § Styling ▸ Capabilities).
- **Lane paste** lands after the anchor lane — the selected lane, or the selected card's lane (several selected: the last, per the shared anchor rule); nothing selected = the board's right end. Semantics mirror the drag pair above exactly: a pasted *copy* takes fresh GUIDs throughout; a cut-paste is the ⌘-drag move — the folder moves whole (nothing lane-nested to strip or carry — the trash is board-level, resettled 2026-07-28). **Pasting into the source board is supported and is the within-board lane duplicate** (settled): fresh GUIDs apply as anywhere else, no menu-validation special case — the drag path deliberately lacks this operation (⌥ ignored on lane drags, above), the clipboard is its one home.
## Keyboard
## Keyboard
@@ -33,12 +37,14 @@ 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) — **except ←/→ crossing lanes, which preserve the origin's ordinal** (settled 2026-08-09; the *sticky ordinal*): a run of lateral presses remembers the 1-based position the card it started from held in its lane's logical order, counting the cards the board is showing (search filter respected), and each hop lands on `min(that ordinal, the target lane's count)` in the next lane that is showing cards — collapsed and query-emptied lanes hopped over as everywhere else. So a 10-3-10 walk goes 8th → 3rd (clamped) → 8th, which no rule over drawn rectangles can do: after the clamp, "8 cards down" is no longer on screen to be measured. The ordinal is **transient per-window state** (`TransientBoardState.lateralOrdinal`), never persisted, and **any non-lateral selection change resets it to the actual new position** — ↑/↓ steps, ⌥-jumps, clicks, the rubber band, ⌫'s successor, and programmatic re-selection after a reload. A wide lane's interior masonry columns keep their spatial step and do not participate: stickiness is lane-granular, and the ordinal is the lane's logical order whichever column drew the card. 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.
- **⌥-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 trashed, deleted, or gone at commit time discards the editor and its keystrokes silently (entering the trash is a vanish from the board; nothing is ever written into a vanished folder). A write that fails *after* a valid commit is the ordinary one-shot write-failure banner. VoiceOver announces the vanished target per 10-accessibility.md's recovery rule.
- **Return** on a sole selected **card**: inline rename. Return disambiguates on card selection — sole card = rename, lane = create (above) — and is **inert on a multi-card selection**; a lane's rename path is Board ▸ Rename. **Escape** steps outward one layer per press: abandons an open editor; else clears search, returning focus to the board (Search below); else **clears the selection** — the keyboard deselect.
- **Return** on a sole selected **card**: inline rename. Return disambiguates on card selection — sole card = rename, lane = create (above) — and is **inert on a multi-card selection**; a lane's rename path is Board ▸ Rename. **Escape** steps outward one layer per press: abandons an open editor; else clears search, returning focus to the board (Search below); else **clears the selection** — the keyboard deselect.
- **Focused editor = text domain** (settled): while an inline title editor — rename or the new-card placeholder — is focused, board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu validation; text-domain chords route to the field as standard text ops — ⌘Z/⇧⌘Z are the editor's text undo (06-history-undo.md ▸ Undo routing), ⌘X/⌘C/⌘V/⌘A act on the text. The one board-command carve-out is **Open Card ⌘↩**, which stays enabled to commit the edit — placeholder or rename — and open the card window. Exits are otherwise unchanged: Return commits, Escape abandons; click-away splits by editor kind — a **rename commits** (focus loss = commit, matching the card window's title field in 05-card-window.md and the branch-switch parenthetical in 06-history-undo.md), while the **placeholder discards** per its rule above, the deliberate exception because nothing exists on disk yet.
- **Focused editor = text domain** (settled): while an inline title editor — rename or the new-card placeholder — is focused, board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu validation; text-domain chords route to the field as standard text ops — ⌘Z/⇧⌘Z are the editor's text undo (06-history-undo.md ▸ Undo routing), ⌘X/⌘C/⌘V/⌘A act on the text. The one board-command carve-out is **Open Card ⌘↩**, which stays enabled to commit the edit — placeholder or rename — and open the card window. Exits are otherwise unchanged: Return commits, Escape abandons; click-away splits by editor kind — a **rename commits** (focus loss = commit, matching the card window's title field in 05-card-window.md and the branch-switch parenthetical in 06-history-undo.md), while the **placeholder discards** per its rule above, the deliberate exception because nothing exists on disk yet.
- **⌫** on a live selection: delete (tombstone) — the plain-key synonym for File ▸ Delete ⌘⌫ (see The map). Grammar, not a menu item: giving it a menu home would require a second "Delete"-titled item, which would collide for title-matched remapping (Configurable bindings). Inert while a title editor is focused, like every grammar key.
- **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 selection: delete — the plain-key synonym for File ▸ Delete ⌘⌫, staged by place like the menu item (see The map). Grammar, not a menu item: giving it a menu home would require a second "Delete"-titled item, which would collide for title-matched remapping (Configurable bindings). Inert while a title editor is focused, like every grammar key.
- The card window speaks the same grammar: **Return** in Preview enters Edit, **Escape** returns to Preview (05-card-window.md) — plain keys, not menu items.
- 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.
@@ -46,32 +52,32 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
Every command is a menu item. The full inventory — every command and action, its default binding, applicable context, and customizability class — lives in **11-command-nexus.md**, the single source of truth for what the app can do; the command titles there are the stable strings the remapping mechanism keys on (Configurable bindings below). The rules below are the behavior behind those bindings and stay normative here.
Every command is a menu item. The full inventory — every command and action, its default binding, applicable context, and customizability class — lives in **11-command-nexus.md**, the single source of truth for what the app can do; the command titles there are the stable strings the remapping mechanism keys on (Configurable bindings below). The rules below are the behavior behind those bindings and stay normative here.
- **⌥⌘↑/⌥⌘↓ sort within the lane** (the move-vs-jump question, resettled: moves live on the ⌥⌘ chord, joining ⌥⌘←/⌥⌘→ lane width in a "⌥⌘ modifies" family; plain ⌥-arrows stay jumps; plain ⌘↑/⌘↓ are unassigned): the selected card(s) move one position within the lane — logical `order`, across interior masonry columns (10-accessibility.md's logical-order rule). A non-contiguous multi-selection **gathers on the first press**: the cards collect into a contiguous block anchored at the first selected card (first = lowest logical order; the rest follow in preserved relative order), and subsequent presses move the block one position. **Cards never change lanes by ⌘-arrow** (settled): inter-lane movement is drag or Cut/Paste (the clipboard rules above), so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes and ⌘←/⌘→ are inert on card selections. With a **lane** selected, ⌘←/⌘→ move the lane one slot — closing 10-accessibility.md's lane-move defect — and ⌥⌘↑/⌥⌘↓ are inert.
- **⌥⌘↑/⌥⌘↓ sort within the lane** (the move-vs-jump question, resettled:*card* moves live on the ⌥⌘ chord, joining ⌥⌘←/⌥⌘→ lane width in a "⌥⌘ modifies" family; plain ⌥-arrows stay jumps; plain ⌘↑/⌘↓ are unassigned): the selected card(s) move one position within the lane — logical `order`, across interior masonry columns (10-accessibility.md's logical-order rule). A non-contiguous multi-selection **gathers on the first press**: the cards collect into a contiguous block anchored at the first selected card (first = lowest logical order; the rest follow in preserved relative order), and subsequent presses move the block one position. **Cards never change lanes by ⌘-arrow** (settled): inter-lane movement is drag or Cut/Paste (the clipboard rules above), so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes and ⌘←/⌘→ are inert on card selections. With a **lane** selected, ⌘←/⌘→ move the lane one slot — closing 10-accessibility.md's lane-move defect — and ⌥⌘↑/⌥⌘↓ are inert.
- **⌫/⌘⌫ delete** (unchanged): tombstone into the trash quasi-lane (03-board-ui.md); lanes included, no dialog. Selection moves to the deleted item's successor sibling, Finder-style (next card in the lane, next lane on the board; the last sibling's predecessor otherwise; empty container = nothing selected) — repeated ⌫ walks down a lane. Deliberate deletes pick a successor; *external* vanishing never does (02-architecture.md's reload-survival rule: the selection just shrinks). On a **tombstoned** selection ⌘⌫ is **Put Back** instead — Finder's exact symmetry (⌘⌫ trashes and un-trashes). The dual role is carried by **twin menu items sharing the chord** — File ▸ Delete ⌘⌫ and File ▸ Put Back ⌘⌫, validation enabling exactly one by selection state; AppKit routes a shared key equivalent to the enabled item (Finder ships this exact pair as Move to Trash/Put Back; ours says Delete per 03-board-ui.md's naming constraint). Both titles stay stable (titles-are-API), and each is independently remappable — remapping one never moves the other's role. Plain ⌫ performs the same tombstone as fixed grammar (see Grammar above) — there is no Edit ▸ Delete item, so the two Delete-titled homes never collide for title-matched remapping.
- **⌫/⌘⌫ delete** (resettled 2026-07-28; lanes rejoined 2026-07-29): on cards *and lanes*, a move into the trash (`.trash/`, top position — 03-board-ui.md; a lane travels subtree-intact, `kind: lane` stamped when absent, no dialog — recoverable now, so nothing needs confirming); on a **trash** selection the same chord deletes **permanently** (one Delete vocabulary, staged by place — confirmation per 03's recoverability rule, a lane's alert counting its cards, a mixed trash selection's alert counting both kinds). 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. **In the trash the successor walk is kind-blind** (ruled 2026-07-31): the next row of either kind, in the same all-rows order plain arrows walk — a successor is a fresh singleton selection, so the landing violates no grammar, and repeated ⌘⌫ empties a mixed trash without dead-ends, each delete confirm-gated per its kind. Deliberate deletes pick a successor; *external* vanishing never does (02-architecture.md's reload-survival rule: the selection just shrinks). **Put Back is retired with the tombstone model** (resettled 2026-07-28): File ▸ Delete is the chord's only owner — no twin menu items, no shared-equivalent routing; restore is drag-out or ⌘X/⌘V (The trash below). Plain ⌫ performs the same delete as fixed grammar (see Grammar above) — there is no Edit ▸ Delete item, so the two Delete-titled homes never collide for title-matched remapping.
- **Select All**: all visible cards on the board — filter-respecting, like every surface (Search below).
- **Select All**: all visible cards on the board — filter-respecting, like every surface (Search below).**On the active trash side it selects the trash** (resettled 2026-07-28): with the trash visible and a non-empty trash selection, Select All selects all visible trash cards; in every other state, all visible live cards — the container boundary decides which "all" is meant (The trash below).
- **The contract's one carve-out is configuration** (settled): form-like git and board setup — add git, add/change remote, branch switching and creation, commit identity, credentials — lives in the board popover only, its committed home; its keyboard path is Board Info (⌘I) plus Tab-reachable controls (10-accessibility.md's Full Keyboard Access). Recurring remote *operations* stay under the contract: Board ▸ Pull and Board ▸ Push are menu items (no default chord, remappable; validation enables them only on remote-backed boards — 07-sync-collab.md).
- **The contract's one carve-out is configuration** (settled; containers re-ruled 2026-07-31 — the popover/sheet split — and **re-ruled back 2026-08-07**): form-like configuration keeps exactly one home per control, and that home is the **board popover** — rename, styling, branch display and *switching*, branch *creation*, add git, commit identity, ahead/behind with Pull/Push, the status badges; its keyboard path is Board Info (⌘I) plus Tab-reachable controls (10-accessibility.md's Full Keyboard Access).*(For a week, setup lived in a board settings sheet reached by Board ▸ Board Settings…; the split's mechanical argument — setup flows fire confirmation alerts, run inline network probes, and accept drag-in key import, acts that want a surface a stray click can't dismiss — did not survive the cost of a second validated surface for controls a user could otherwise reach directly. 03-board-ui.md ▸ Board settings sheet carries the retirement, and 07-sync-collab.md's credential and SSH surfaces are where the argument gets its next hearing.)* 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); 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; with nothing selected — or a **trash** selection, which never anchors creation — the **last-active lane** — the lane that most recently held selection or a creation in this window session — falling back to the first lane. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first.
### The trash, keyboard-first (settled)
### The trash, keyboard-first (resettled 2026-07-28 — the materialized trash)
The trash quasi-lane (03-board-ui.md ▸ Trash) speaks the same keyboard language when shown; hidden, it is invisible to every gesture. Rules:
The trash lane (03-board-ui.md ▸ Trash — cards and lanes moved into `<root>/.trash/`; lanes rejoined 2026-07-29 as opaque-unit rows) speaks the board's ordinary keyboard language when shown; hidden, it is invisible to every gesture — and **hiding it clears a trash selection** (nothing invisible stays selected, so the toggle-off drops the selection rather than leave commands enabled against rows nobody can see). Trash cards are ordinary cards; a trashed lane is one opaque row (title + card count) — the old liveness machinery stays retired: no ancestor walks, no entry-vs-universe split, one container boundary plus the board's own kind rule. 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; inside, plain arrows walk every row, card and lane row alike (navigation crosses kinds). The trash lane itself is never selectable *as a lane* (no lane op applies to it): with a lane selected, ←/→ and ⌥→ stop at the last real lane.
- **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).
- **Dropping a live card — or lane — on the shown trash deletes it** (lanes extended 2026-07-29): the drag is the pointer's delete gesture — release moves the dragged item(s) into `.trash/`; a lane drag over the shown trash proposes the delete alongside its strip slots. The drop diverges from positional drops in one way: **the shadow always takes the topmost position** — honest, not arbitrary: every trash arrival stamps `modified` and the trash sorts newest-first by that stamp (03 ▸ Trash), so a fresh delete genuinely lands on top. The trash takes no drops while hidden, like every gesture. Cross-board arrivals and ⌥-copies refuse too (a transfer-and-delete compound and a copy-into-the-trash are operations the design doesn't name), and a refusal falls through to the strip retarget rather than cancelling the held drag. **A refusal arriving over a standing trash proposal withdraws it** (ruled 2026-08-06 — the stationary ⌥ flip made the state reachable without a mouse move): the fall-through's honest reading — the column declines to be a target, it does not cancel the drag the user is still holding — covers only promises the refusal leaves keepable: shadows standing in a lane keep drawing, and an ⌥-copy released over the column lands where they are. A standing proposal naming the trash itself is the one promise the refusal just broke — tombstone rows promising a delete the release will refuse — so it withdraws rather than holds: the drag goes proposal-less over the column (the foreign-reload rule's shape — the shadow withdraws, no proposal stands until the pointer reaches a live target, and a release with no valid proposal cancels, which now agrees with the empty picture). Lifting ⌥ re-asks at the same address (the stationary flip's re-read) and the trash's proposal returns.
- **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. Menu validation stays binary — Delete for live selections, Put Back / Delete Immediately for tombstoned ones. External liveness flips can't breach the invariant: areload 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 keeps one container boundary — and goes kind-blind inside the trash** (re-ruled 2026-07-31, superseding the lanes-rejoin pass's kind-homogeneous trash grammar): a selection never mixes trash items with board items, but *within* the trash cards and lane rows select together — clicks, ⇧-click ranges, ⇧-arrow extension, and the rubberband all sweep every row (the band's full-height backdrop covers both kinds), and Select All with a non-empty trash selection selects **all visible trash rows**. The live board keeps cards XOR lanes, and its Select All stays card-scoped, as everywhere. The guard moves to the exits (the mixed-payload drop refusal and ⌘C/⌘X validation below) — inside the trash the only verbs are Delete and the restore paths, so upstream homogeneity bought nothing the exits don't. ⇧-arrow extension still stops at the container boundary. Menu validation stays binary by container: Delete = move to trash on board selections, Delete = permanent on trash selections (03 ▸ Trash) — and Delete works on a mixed selection, the alert counting both kinds. An external move observed by reload re-resolves the selection by presence, as everywhere (02-architecture.md).
- **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.
- **Within-trash moves are inert**: no move or paste ever targets the trash (deleting is ⌫/⌘⌫ or the drag above), and ⌥⌘↑/⌥⌘↓ are inert on trash rows — the trash's order is its arrival order, not a workspace to arrange.
- **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: the restore path.** ⌘C copies a trash card (a live copy lands wherever pasted — like copying out of Finder's Trash); **⌘X works** (resettled — it was disabled under the tombstone model): cut in the trash, paste is the keyboard-native restore, an ordinary folder move (10-accessibility.md's drag-free contract) — a card pastes into a lane, a trashed lane pastes after the anchor lane (the lane-paste rule above, verbatim). **⌘C and ⌘X validate against mixed selections** (ruled 2026-07-31, with kind-blind selection): the pasteboard's payload types are per-kind, so Cut and Copy grey out via ordinary menu validation while a trash selection mixes kinds — no failed gesture, no beep; the drag path's drop-time explanation (Drag-to-restore below) is where the rule teaches itself. **An item entering the trash voids its pending cut** (the deliberate-removal rule): a cut card — or lane — that gets deleted drops out of the pending cut, as under the old model.
- **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 trash selections — Open Card, Rename, Style…, and lane width ops on lane rows; Finder file drops on trash rows are inert (03's no-editing-in-the-trash). Creation never anchors to the trash: ⌘N and paste with a trash selection fall back to their nothing-selected targets.
- **Drag-to-restore follows the locality model**: dropping a 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 trash card into one of its own board's lanes — or a trashed lane row onto its own board's strip — is an ordinary move to the drop position. Dropped on *another* board it follows the copy default — a live copy lands there, the original stays in the source trash; ⌘-drag forces the true cross-board restore-move. **A mixed-kind drag never leaves the trash** (ruled 2026-07-31): pickup is allowed — the selection is legal — but every out-of-trash drop target refuses the mixed payload, and the release surfaces a notice explaining the rule ("Cards and lanes leave the trash separately — restore one kind at a time"); the refused drag ends like any refusal, rows staying put. Within-trash drops stay inert as above.
### Configurable bindings (settled)
### Configurable bindings (settled)
Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App Shortcuts mechanism (System Settings ▸ Keyboard ▸ App Shortcuts, stored as `NSUserKeyEquivalents` in the app's defaults) remaps any menu item, and AppKit applies it automatically — menus always display the *effective* binding, so the menu bar is the self-documenting keyboard map. Because every board function is a menu item (the contract above), coverage is complete for all modifier-chord commands; the fixed grammar keys stay fixed by design. An in-app shortcut-recorder pane was considered and set aside as ceremony (WISHLIST.md); the Help content carries one line teaching the System Settings path. Constraints this mechanism imposes, adopted as design rules:
Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App Shortcuts mechanism (System Settings ▸ Keyboard ▸ App Shortcuts, stored as `NSUserKeyEquivalents` in the app's defaults) remaps any menu item, and AppKit applies it automatically — menus always display the *effective* binding, so the menu bar is the self-documenting keyboard map. Because every board function is a menu item (the contract above), coverage is complete for all modifier-chord commands; the fixed grammar keys stay fixed by design. An in-app shortcut-recorder pane was considered and set aside as ceremony (../WISHLIST.md); the Help content carries one line teaching the System Settings path. Constraints this mechanism imposes, adopted as design rules:
- **Menu item titles are API.** The mechanism matches on exact titles — renaming a menu item orphans users' bindings. Titles change only with the deliberateness of a schema change.
- **Menu item titles are API.** The mechanism matches on exact titles — renaming a menu item orphans users' bindings. Titles change only with the deliberateness of a schema change.
- **Toggles keep one stable title** with a checkmark state — "Show Trash" stays "Show Trash" when checked, never becomes "Hide Trash". (Same for Edit Body and Raw Source.)
- **Toggles keep one stable title** with a checkmark state — "Show Trash" stays "Show Trash" when checked, never becomes "Hide Trash". (Same for Edit Body and Raw Source.)
- **Undo/Redo are effectively not remappable** — NSUndoManager rewrites their titles dynamically ("Undo Move Card…"), which defeats title matching. Accepted; nobody remaps ⌘Z.
- **Undo/Redo are effectively not remappable** — NSUndoManager rewrites their titles dynamically ("Undo Move Card…"), which defeats title matching. Accepted; nobody remaps ⌘Z.
- **Two items may share a default chord when validation is mutually exclusive** (Delete / Put Back on ⌘⌫) — AppKit fires the enabled one. Each keeps its own stable title, so remapping stays per-item. Corollary: no two menu items share a *title* either (titles are the remap key), which is why plain-⌫ delete is grammar rather than a second Delete item.
- **Two items may share a default chord when validation is mutually exclusive** (a pattern currently unused — Put Back's retirement removed its one instance) — AppKit fires the enabled one. Each keeps its own stable title, so remapping stays per-item. Corollary: no two menu items share a *title* either (titles are the remap key), which is why plain-⌫ delete is grammar rather than a second Delete item.
## Accessibility
## Accessibility
@@ -79,9 +85,9 @@ Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App
## Search
## Search
- Search field invoked with ⌘F (the board toolbar's sole default item; removed from the toolbar, ⌘F surfaces it transiently — 03-board-ui.md ▸ Toolbar; in the **card window**, Edit ▸ Find is find-in-text instead — 05-card-window.md), live filter: cards whose title *and* body both miss the query animate out; case/diacritic-insensitive substring. Scope is **title + body only** (settled) — attachment filenames are not searched.
- Search field invoked with ⌘F (the board toolbar's sole default item; removed from the toolbar, ⌘F surfaces it transiently — 03-board-ui.md ▸ Toolbar; in the **card window**, Edit ▸ Find is find-in-text instead — 05-card-window.md), live filter: cards whose title *and* body both miss the query animate out; case/diacritic-insensitive substring. Scope is **all card content the format makes meaningful** (re-ruled 2026-07-29, superseding title-plus-body-only): title + body today; **comment bodies join when comments ship** — via a search-owned transient comment index, never the snapshot: the first live-query keystroke kicks an async sweep of `comments/*/index.md` bodies (`.draft` and `comments/.trash/` excluded), kept fresh by the same FSEvents stream while a query is active and discarded when it clears — the board walk stays O(cards), 01's window-scoped read untouched; **attributes join as they activate** (title now; `labels` came alive 2026-08-09 — 01 § Frontmatter — but its search integration is deliberately unruled: labels stay unsearched until that pass happens; `assignees`/`due` remain reserved and inert). Attachment filenames stay unsearched. Scope options (content vs attributes, either/or) are WISHLIST #10.
- The filter is the single source of truth for "what's on the board": layout, drop zones, marquee, ranges, arrow nav, and lane count badges all read it. Hidden cards leave the selection; creating a card clears the search. Escape clears, then returns focus to the board.
- The filter is the single source of truth for "what's on the board": layout, drop zones, marquee, ranges, arrow nav, and lane count badges all read it. **A drop under an active filter counts in the rendered space** (ruled 2026-08-06, closing the divergence found 2026-08-01): the zones, the shadow, and the write all resolve through the same filtered list — the position the shadow shows is the landing the write performs (the shipped interim — zones and write counting the unfiltered lane while the slots rendered filtered — was self-consistent but named a position the user could not see; retired). The slot's meaning in the full order is its **visible anchor**: the dropped card enters immediately *after* the slot's visible predecessor — or immediately *before* its visible successor when the slot has none (top of the lane) — and a lane the query emptied appends at its true end; a multi-card drop enters in proposal order beside the same anchor. Hidden cards keep their ranks untouched, never displaced by a gesture that could not see them — and the anchor is what makes the gesture's meaning survive the query's clearing: when the filter lifts, the card sits exactly beside the card it was dropped against. The rendered space means everything rendered — the new-card placeholder occupies its slot for the zones exactly as it does for layout. 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, and it is **stated by mechanism, not by gesture** (settled): *any* user-initiated creation on the board clears the query — ⌘N, Return-creation, empty-space double-click, paste, and Finder file drops alike — while foreign/agent-filed cards keep riding the live filter (02-architecture.md's derived-result rule). **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. **A lane the query empties keeps its slot** (settled): lanes are never filtered out — an all-misses lane stays on the board at its width with a 0 badge (the count reads the filter, 03-board-ui.md); the search filters cards, and the board's structure is not a search result. **A leaving card stays input-reachable for its out-transition** (settled): marquee and arrow targets deregister when the ~0.28 s animate-out ends, so a card mid-departure is briefly reachable while already out of the selection — accepted: it is literally on screen for that span, and closing the window would teach three input sites a predicate the layout already applied. **An open inline rename survives the filter hiding its card** (settled): the editor is a surface the filter doesn't reach — it stays open and focused, commits by UUID wherever the card lives, Escape abandons; keystrokes are never silently discarded for a card that still exists (the dirty-buffer courtesy), and the vanish-discard rule stays reserved for true liveness flips. The typed-query path can't even occur — focusing the search field is focus loss, which commits the rename first — so the rule covers foreign edits that stop the card matching.
- **Dispatch while the search field is focused** (settled): the field is a *control*, not a content editor — the focused-editor lockdown (Grammar above) does not apply. Text-domain keys route to the field: ⌘A/⌘X/⌘C/⌘V act on the query, plain ⌫ edits the query and never reaches the board, horizontal arrows move the caret. Board menu commands stay enabled and act on the board selection exactly as when the field is unfocused — ⌘N included (creating a card clears the search, above) — and the Delete pair stays unambiguous by construction: plain ⌫ is query editing, ⌘⌫ is File ▸ Delete on the selection.
- **Dispatch while the search field is focused** (settled): the field is a *control*, not a content editor — the focused-editor lockdown (Grammar above) does not apply. Text-domain keys route to the field: ⌘A/⌘X/⌘C/⌘V act on the query, plain ⌫ edits the query and never reaches the board, horizontal arrows move the caret. **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).
The standalone per-card window. Opened by fast double-click, ⌘↩, or the context menu; at most one window per card (reopening a live card focuses the existing window); board and card windows share one live store, so edits reflect everywhere instantly (02-architecture.md).
The standalone per-card window. Opened by fast double-click, ⌘↩, or the context menu; at most one window per card (reopening a live card focuses the existing window); board and card windows share one live store, so edits reflect everywhere instantly (02-architecture.md).
> **Status: designed** — composition, body column, and the attributes sidebar are all settled. The storage-facing rules the pathfinder settled painfully — Preview/Edit over WYSIWYG, the untouched-body byte-identical guarantee, validate-before-write on the raw outlet, dirty-buffer-wins — carry over unchanged.
> **Status: designed** — composition, body column, the attributes sidebar, and the comments column (2026-07-29) are all settled. The storage-facing rules the pathfinder settled painfully — Preview/Edit over WYSIWYG, the untouched-body byte-identical guarantee, validate-before-write on the raw outlet, dirty-buffer-wins — carry over unchanged.
## Composition
## Composition
Two full-height columns: a wide **body column** (leading) and a narrow **attributes sidebar** (trailing), each scrolling independently. Visual reference: [assets/card-sidebar-reference.png](assets/card-sidebar-reference.png) — a GitLab-style issue pane; its *pattern* (stacked small-caps sections, quiet read-first rows, actions at the bottom) is what carries over, not its enhanced-schema content.
Three **componentized panes** (re-composed 2026-07-29, comments design): a wide **body pane** (leading), the **comments pane** (middle, when shown — The comments column below), and the narrow **attributes sidebar** (trailing) — each an independent component with its own scroll, arranged by the window's layout rather than wired to each other; componentization is the rule, so the comments pane mounts beside the body or below it (the layout option, next) without either pane knowing which. Visual reference: [assets/card-sidebar-reference.png](assets/card-sidebar-reference.png) — a GitLab-style issue pane; its *pattern* (stacked small-caps sections, quiet read-first rows, actions at the bottom) is what carries over, not its enhanced-schema content.
- **Body column, top to bottom**: the **title field** — large, borderless; edits write through to frontmatter on commit (Return or focus loss); clearing it removes the `title` key (titles are optional — the untitled placeholder shows here as on the face); Return commits and moves focus into the body. Beneath it, a **quiet created/modified line** ("Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", secondary styling, omitting whichever keys are absent — the "by" segment renders only when the self-reported provenance stamp is present, 01-storage-format.md; this is provenance made visible where git history may not exist) — the read-only readout the pathfinder dropped with its inspector, back where the mockup puts it. Then the **body** (Preview/Edit, below).
- **Body column, top to bottom**: the **title field** — large, borderless; edits write through to frontmatter on commit (Return or focus loss); clearing it removes the `title` key (titles are optional — the untitled placeholder shows here as on the face); Return commits and moves focus into the body.**Escape abandons** (settled — the board inline rename's abandon, applied here): the field reverts to the on-disk title and focus moves into the body, never a commit. The Edit-mode collision resolves by focus, 06-history-undo.md's first-responder rule: while the title field is focused, Escape is the title abandon even with the body in Edit; with the body editor focused, Escape is the Edit→Preview flip as specified. Beneath it, a **quiet created/modified line** ("Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", secondary styling, omitting whichever keys are absent — the "by" segment renders only when the self-reported provenance stamp is present, 01-storage-format.md; this is provenance made visible where git history may not exist) — the read-only readout the pathfinder dropped with its inspector, back where the mockup puts it. Then the **body** (Preview/Edit, below).
- **Attributes sidebar**: everything about the card that isn't the body — sections below. Fixed narrow width derived from font metrics (full relative scaling, 10-accessibility.md); the window's resize flex goes to the body.
- **Attributes sidebar**: everything about the card that isn't the body — sections below. Fixed narrow width derived from font metrics (full relative scaling, 10-accessibility.md); the window's resize flex goes to the body.**The sidebar can be hidden** (2026-08-09, Pipeline card 1d2d1c77): View ▸ Show Sidebar and a toolbar toggle (`sidebar.right`, fourth default item) — app-wide, persisted, default on, Show Comments' shape; the window minimum shrinks with it and the reflow animates in the structural voice (crossfade under Reduce Motion).
- **Body/comments layout is an option** (ruled 2026-07-29 — componentized panes make it cheap): side-by-side is the default; **View ▸ Comments Beside Body** unchecked stacks them — body pane above, comments pane below at a fixed ≈3:2 split, each keeping its own scroll — for narrow displays. App-wide, persisted. The panes are identical in both mounts, and the thread stays visible through body **Edit** in either (Edit swaps only the body pane's content — the sidebar's own precedent); the window's minimum width grows only while the column is shown side-by-side, and resize flex always goes to the body, never the fixed panes.
(The pathfinder's compositions are both gone: the metadata bar — labels, assignees, due — left with those fields' move to the enhanced schema, and the horizontal attachments strip dissolves into the sidebar.)
(The pathfinder's compositions are both gone: the metadata bar — labels, assignees, due — left with those fields' move to the enhanced schema, and the horizontal attachments strip dissolves into the sidebar.)
@@ -28,15 +29,15 @@ 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.
- **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 surface (Preview's selectable text, the Edit editor, raw source — and the comments pane, where it searches the thread's **content — every comment's body**, cross-row with wraparound, `.draft` excluded (tightened 2026-07-31: author and date lines are metadata, not find targets — a match the bar cannot highlight is worse than none); the composer and an inline comment edit are their own focused text surfaces with the editor's ordinary find). Board search is a board-window concern (04-interactions.md ▸ Search — which reaches comment bodies through its own transient index since the 2026-07-29 re-ruling, so the two finds never overlap in scope).
### Edit
### Edit
- 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.
- 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.
- 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)
### Write rules (settled, storage-facing)
@@ -45,24 +46,25 @@ Settled the hard way in the pathfinder (WYSIWYG built, then reversed): the body
## Raw source outlet
## 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.
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.
## The attributes sidebar
## The attributes sidebar
Stacked sections under small-caps headers, in this order; quiet rows, read-optimized, edit affordances only where a section edits. The sidebar is the card's non-body inventory — future attributes (enhanced-schema fields, if they ever ship) slot in as new sections rather than growing new bars.
Stacked sections under small-caps headers, in this order; quiet rows, read-optimized, edit affordances only where a section edits. The sidebar is the card's non-body inventory — future attributes slot in as new sections rather than growing new bars. **Order re-ruled 2026-08-09** (Pipeline cards 8f26b029, a4462d28): **Style · Labels · Details · Attachments** — attachments moved to the bottom, Labels (the newly activated key, 01 § Frontmatter) sits above Details deliberately: Details is the section for keys the app does *not* own. The section headers below predate the reorder; the list above is the order.
### Attachments
### Attachments
- Shows **every top-level file of `attachments/`** — including files also embedded in the body (settled: the section is the card's complete file inventory, no reference-tracking magic; an image appearing in both places is honest, not a bug). Subfolders are tolerated but not surfaced (01-storage-format.md's attachments rules).
- Shows **every top-level file of `attachments/`** — including files also embedded in the body (settled: the section is the card's complete file inventory, no reference-tracking magic; an image appearing in both places is honest, not a bug). Subfolders are tolerated but not surfaced (01-storage-format.md's attachments rules).
- **Compact rows**: small QuickLook thumbnail (Finder-icon fallback) + middle-truncated filename, one row per file. The section header carries a quiet add affordance; empty, the section stays with a one-line hint (drop files, or File ▸ Add Attachment…, ⇧⌘A) — the drop surface remains the **whole window** (name collisions auto-rename, Finder-style — 01-storage-format.md). **Drop precedence is split by payload** (settled): file drops import as attachments anywhere in the window — Edit mode included, the text editor never intercepts a file drop; dragged *text* lands in the Edit editor at the caret within its bounds as ordinary insertion, and is inert elsewhere in the window.
- **Compact rows**: small QuickLook thumbnail (Finder-icon fallback) + middle-truncated filename, one row per file. The section header carries a quiet add affordance; empty, the section stays with a one-line hint (drop files, or File ▸ Add Attachment…, ⇧⌘A) — the drop surface remains the **whole window** (name collisions auto-rename, Finder-style — 01-storage-format.md). **Drop precedence is split by payload** (settled): file drops import as attachments anywhere in the window — Edit mode included, the text editor never intercepts a file drop; dragged *text* lands in the Edit editor at the caret within its bounds as ordinary insertion, and is inert elsewhere in the window.**One carve-out by hover target** (ruled 2026-07-29 — comment attachments are authorable): a file dropped **within the comment composer's bounds** imports to the draft's `attachments/`, and within an **inline comment edit session's bounds** to that comment's — the window-wide card default covers everywhere else (The comments column below).
- Row interactions: double-click or Return opens; context menu Open / Reveal in Finder / Remove (moves to the **system** Trash, never hard-deletes — 03-board-ui.md's naming constraint keeps this distinct from board deletion); rows drag out their file URL.
- Row interactions: double-click or Return opens; context menu Open / Reveal in Finder /**Set as Hero** or **Remove Hero** / Remove (moves to the **system** Trash, never hard-deletes — 03-board-ui.md's naming constraint keeps this distinct from board deletion); rows drag out their file URL.**The hero row is one slot with two words** (ruled 2026-08-09, 03-board-ui.md § Card face ▸ Hero image): "Set as Hero" on any image row that is not already the hero, "Remove Hero" on the one that is, and **nothing at all** on a row that can be neither — present-or-absent rather than greyed, because a disabled "Set as Hero" on a `.zip` would claim a capability that will never arrive (Remove, just below it, greys under the lock as before). Removing the hero removes the *key*, never the file. The row that is the hero says so to VoiceOver as its value.
- **⌘V pastes a picture onto this card** (04-interactions.md ▸ Clipboard's image-data branch): the whole-window drop's keyboard twin, landing in the same `attachments/` through the same import path. A focused text field — the body editor, the composer, an inline comment edit — consumes ⌘V natively and stays a text paste, which is the drop precedence's rule arriving on the other input. **The body editor yields a paste it cannot read** (ruled 2026-08-09, live-probed): the editor holds the keyboard from the moment the window opens, so "a focused editor wins ⌘V" must not mean "blocks what it cannot take" — a pasteboard with no flavor the editor accepts (the screenshot's image-only pasteboard, foremost) passes `paste:` to the responder behind it, where the window's attachment branch answers; any text flavor riding along keeps the paste in the editor exactly as before. The yield is by capability (`readablePasteboardTypes`), the drop rule's own shape — the editor declines file *drops* the same way. The composer and inline comment edits do not yield yet; theirs is a narrower surface and a follow-up call.
- **Keyboard-native, new in the rewrite** (the pathfinder's strip was pointer-only): the section is focusable; arrows move between rows, **Space QuickLooks** the selected row, Return opens it, ⌫ removes it (same system-Trash semantics).
- **Keyboard-native, new in the rewrite** (the pathfinder's strip was pointer-only): the section is focusable; arrows move between rows, **Space QuickLooks** the selected row, Return opens it, ⌫ removes it (same system-Trash semantics).
### Style
### Style
The card-level styling home: the **embedded style editor** — background palette grid (with the leading None well) and curated symbol grid, per 03-board-ui.md ▸ Styling ▸ Controls. Card styling is discoverable here without a context menu; the same component appears in the board popover and behind Style….
The card-level styling home: the **Background color combo** over the compact **`SymbolPicker`** (03-board-ui.md ▸ Styling ▸ Controls, its 2026-08-06 anchor-ownership rule for the background half, extended to the symbol half on 2026-08-08) — the sidebar is exactly the narrow context the combo was built for, so it stands in for the style editor's background half here, panel escape hatch included, while the picker's own well and popover-hosted grid — fed `CuratedSymbols`' card-level set (one of three level-specific sets, 2026-08-09), not the picker's smaller general-purpose default, so a card's curated vocabulary doesn't shrink — stands in for the symbol half the same way; the full style editor's grid remains the surface only at Style…. Card styling is discoverable here without a context menu; the picker is the same reusable control the board popover's glyph picker beside the rename field uses.
### Details — unknown frontmatter keys
### Details — unknown frontmatter keys
@@ -77,25 +79,41 @@ The card-level styling home: the **embedded style editor** — background palett
- The section is **absent** on boards without app-managed git (mode none, repo-nested) — same honesty rule as the popover's git section (06-history-undo.md). Rows are focusable (arrows), but carry **no actions in v1** — restoring an old version stays a git-client task for now; a per-row forward-restore and lane history are wishlist items, deliberately.
- The section is **absent** on boards without app-managed git (mode none, repo-nested) — same honesty rule as the popover's git section (06-history-undo.md). Rows are focusable (arrows), but carry **no actions in v1** — restoring an old version stays a git-client task for now; a per-row forward-restore and lane history are wishlist items, deliberately.
- Menu path (contract): **View ▸ History** in the card window focuses the section — no default chord.
- Menu path (contract): **View ▸ History** in the card window focuses the section — no default chord.
- **Delete** — tombstones the card (destructive styling; the window then dismisses itself per Deletion & lifecycle below; recoverable from the board's trash quasi-lane).
The section's two rows moved to the card window's toolbar: **Delete Card** (trash symbol, a default item behind a trailing flexible space — Mail's own Delete placement; same no-confirm staged trash move, window dismissing per Deletion & lifecycle) and **Reveal in Finder** (catalog-only, customization adds it). Because every toolbar item needs a menu-row twin and ⌘⌫ stays reserved for the window's text surfaces, a chord-less **File ▸ Delete Card** row was added as Delete Card's twin (11-command-nexus.md). The sidebar toggle itself sits rightmost in the toolbar defaults (card 084d127f), and the sidebar as a whole can be hidden — Composition above.
The card's `labels` as removable rows with an add affordance (combo/autocomplete over the board's used-label universe + free-text creation). The key is schema-owned now — 01 § Frontmatter; the twelve-slot context-menu submenu and its More… dialog are the other surface (11 ▸ Context menus). Labels on the card *face* deliberately await their own design pass.
## The comments column
Designed 2026-07-29 (storage: 01-storage-format.md ▸ Enhanced schema). Ships in **every tier** — only tracker sync is tier-gated (12-editions.md). Feature lands post-2.0.
- **Visibility** (re-ruled 2026-07-29 — the pane obeys the user, not the content): **View ▸ Show Comments** is a checkmark toggle à la Show Trash, and its choice is **app-wide and persisted across restarts** (`UserDefaults.standard`, beside Comments Beside Body — the one-app collapse's scalars rule), and it **defaults ON** (ruled 2026-07-31): the trash's hidden-by-default bargain hides destructive residue, while the pane invites content — a new feature behind an unchecked menu item would never be discovered; one persisted uncheck opts out forever. One bit, no content-derived auto-show: checked, every card window carries the pane (a comment-less card shows the empty thread and the composer — the invitation is the point); unchecked, threads and drafts are out of sight until the user says otherwise, the Show Trash bargain. The checkmark reads the bit — the menu never lies. **File ▸ Add Comment** flips the bit on when it's off (the gesture *is* the user choosing to see comments — same persistence) and focuses the composer in one gesture (11-command-nexus.md). Deleting the last comment never closes the pane — nothing but the toggle does.
- **The thread**: one comment = an author line (self-reported `author`, unattributed when absent; timestamp; "· edited" when `modified` differs from `created`), the rendered Markdown body (the card-body subset), and attachment chips when its `attachments/` is non-empty (Quick Look, the sidebar section's pattern). No avatars — there is no identity system, and initials faked from self-reported strings would be decoration. The section header carries the count ("Comments · 3") and the **sort-direction control**: chronological ascending by default, flippable to newest-first (app-wide, persisted).
- **The composer edits `comments/.draft/`** (ruled 2026-07-29 — the draft is user content in the board, the `.trash` pattern applied to composition): an always-visible text area ("Add a comment…", Edit-mode Markdown highlighting) whose backing file is the card's single draft — a reserved dot-named folder under `comments/` holding ordinary comment schema, `attachments/` included, excluded from the thread listing. Restore-on-reopen falls out for free (the composer just reads its file); drafts ride git and sync across machines like any file; concurrent drafts on two machines are an ordinary file race (local-wins). **The composer sits at the thread's newest end** (bottom ascending, top descending) and the window opens scrolled to it — a thread opens where the conversation is happening. **Comment attachments author here** (ruled 2026-07-29): a file dropped within the composer's bounds imports to the draft's `attachments/` (the hover-target carve-out — Attachments above), a quiet **paperclip affordance** on the composer covers the no-drag path (the section header's add-affordance pattern; File ▸ Add Attachment… stays card-scoped), and the same pair applies within an inline comment edit session, targeting that comment's `attachments/`. Chips on an authoring surface carry remove (to the **system** Trash — the sidebar row's rule); a posted comment's chips are read-only, Quick Look only — Edit the comment to change its files.
- **Draft saves are slow-cadence, never prompted** (flow breakage minimized): the draft writes on composer blur, window close, quit, and a lazy interval (~30 s) — not the body editor's 700 ms, so a Pro user's typing never becomes a commit stream; the saves that do land compose the quiet path-shaped **"Draft comment on '⟨card⟩'"**. Close and quit just proceed — no DirtyBufferGuard on the ordinary path, nothing to lose while saves land. **The failure path gets the guard** (re-ruled 2026-07-31, narrowing "nothing to lose" to its true premise): a close-time draft flush that *fails* with typed text in the buffer raises the DirtyBufferGuard modal (retry / save a copy / discard) exactly as the body's does — the buffer is then the only home the text has, 02-architecture.md's one-modal-moment class; ordinary closes stay ceremony-free since the guard only ever fires on a failed write. A draft emptied of text with no attachments deletes its folder — no litter. **Escape moves focus out of the composer, draft untouched** (ruled 2026-07-29 — Escape never discards: the draft is a durable file, so "abandon" has no meaning here; emptying the draft is the discard gesture, and the title field's abandon-Escape stays the transient-bubble exception).
- **⌘↩ posts** (a Comment button twins it): posting renames `.draft` → a fresh lowercase UUID and **restamps `created`/`modified`** in the same write bracket — chronology is when it was posted, not when drafting began — one gesture, one commit ("Comment on '⟨card⟩'" — 06-history-undo.md's verb family per 01).
- **Edit and delete**: every comment is editable and deletable — files-first has no enforced identity. The comment's context menu (the per-item inventory — 10-accessibility.md) carries **Edit / Delete / Reveal in Finder**. Inline Edit is a **body-edit session in miniature** (no second draft mechanism): debounced saves to the comment's own file keep it crash-safe, Save (or ⌘↩) ends the session as its commit point, Cancel — or Escape, its keyboard twin (ruled 2026-07-29; 11's grammar table) — reverts to session-start bytes, window close flushes the session exactly as the body's does. Delete is immediate and undoable, no confirm (01's ruling — undo is the net: the comment moves into `comments/.trash/`, undo is the move back on the **window's own stack**; after close, board-level undo of the session restores it, and the folder purges only when undo no longer needs it — 13-native-undo.md's session-coarsening model, re-ruled 2026-07-31).
- **Live updates**: the pane reloads its thread from the same FSEvents stream (01's window-scoped rule — the board snapshot never loads comment content); foreign arrivals snap in per the motion language, and the announcer speaks them path-shaped ("New comment on '⟨card⟩'" — 10-accessibility.md).
- **Raw Source still swaps the entire content area** — all panes, comments included; the raw outlet's rule is unchanged.
## Window
## Window
- **The subtitle shows the card's place**: "⟨board⟩ › ⟨lane⟩" under the window title, live-updating as the card moves (the window follows its card).
- **The subtitle shows the card's place**: "⟨board⟩ › ⟨lane⟩" under the window title, live-updating as the card moves (the window follows its card).**Hidden with the title, and the loss is blessed** (ruled 2026-08-06): the no-title titlebar (Toolbar below — `titleVisibility = .hidden`) collapses AppKit's title and subtitle as one field, so the pair hides together; the window is usually beside its board, and `window.title`/`window.subtitle` keep feeding the Window menu, Exposé, VoiceOver, and restoration, so only the visual rendering is lost. The computation and live-update wiring stand untouched — if a visible placement surface is ever wanted (a details-sidebar placement row is the named candidate), it is a surface away, not a rewiring; deliberately not built now.
- New windows open at the last-used card-window size, cascaded; frames restore per card across relaunch where state restoration allows.
- New windows open at the last-used card-window size, cascaded; frames restore per card across relaunch where state restoration allows.
- **Toolbar (settled — 03-board-ui.md ▸ Toolbar)**: default set Edit Body (single toggle, on-state in Edit) · Raw Source (toggle; while active, Edit Body disables) · Add Attachment; user-customizable like the board window's.
- **Toolbar (settled — 03-board-ui.md ▸ Toolbar)**: default set Edit Body (single toggle, on-state in Edit) · Raw Source (toggle; while active, Edit Body disables) · Add Attachment; user-customizable like the board window's.
## 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 **entering the trash counts as deleted** (resettled 2026-07-28, the materialized trash): ⌫ on the board closes the card's open window (the card left the working set; restore and reopen if it was a slip), an external move into `.trash/` observed by reload does the same, and deleting the card's *lane* deletes the card with it — the window dismisses because the card is gone. Restoring reopens nothing — reopening is the user's act, like any open. **Dismissal never eats typed work silently where a save can land** (settled): a dirty Edit buffer flushes into the card's folder at its new `.trash/` location before the window dismisses — a surgical body write, so the keystrokes survive a later restore and enter history on git boards (the composer reads it as an edit to a trashed card — accurate). An open raw-source buffer discards instead: its Apply would write a whole stale `index.md` over the trashed card — a delete is never fought by a stale buffer. A card whose lane was deleted discards both — nowhere left to write. A card hard-deleted externally (folder gone) discards both — nowhere left to write, the inline-rename rule. Cards in the shown trash lane don't open at all — restore first (03-board-ui.md). Reopening a live card focuses the existing window.
## Changes from Kanban
## Changes from Kanban
- **Two-column composition** replaces the pathfinder's vertical title/strip/body stack: body column (title atop it, created/modified line beneath) plus a full-height attributes sidebar.
- **Three componentized panes** replace the pathfinder's vertical title/strip/body stack: the body pane (title atop it, created/modified line beneath), the comments pane (when shown — the 2026-07-29 re-composition), and a full-height attributes sidebar.
- **The sidebar revives the dropped readouts**: created/modified return under the title; unknown frontmatter keys get the read-only Details section (the pathfinder's inspector casualties, rehomed).
- **The sidebar revives the dropped readouts**: created/modified return under the title; unknown frontmatter keys get the read-only Details section (the pathfinder's inspector casualties, rehomed).
- **Empty body opens in Edit**; Return in Preview enters Edit; Escape returns to Preview (the pathfinder always opened in Preview, toggle-only).
- **Empty body opens in Edit**; Return in Preview enters Edit; Escape returns to Preview (the pathfinder always opened in Preview, toggle-only).
- **Live task-list checkboxes in Preview** — the pathfinder's preview was fully inert.
- **Live task-list checkboxes in Preview** — the pathfinder's preview was fully inert.
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.
**RETIRED 2026-08-08** (strategy/01-git-excision.md — the excision ruling): app-managed git is removed from Lanework entirely — the git HistoryProvider, auto-commit, branches, restore-as-forward-commit, all of the machinery this doc designed — so nothing below describes the shipping app. Three things outlive it: the **Undo routing** section migrated to 13-native-undo.md before this note landed (the rule was always tier- and substrate-independent, and 13 is its live home), the **commit-message vocabulary** survives as the harvested change narrator (`Kanban/Changes/`, the designated core of the future activity feed / foreign-change journal), and the safety-net value — history of externally delivered changes — routes to the deferred foreign-change journal (01-git-excision.md ▸ Successors). The stack itself is recoverable at tag `pre-git-excision`. Everything below is the record of the design as it stood.
**Tier scope: every tier** (Pivot 2026-08-07 — 12-editions.md: git left the paywall; this line formerly scoped the doc to Lanework Pro, with the free tier shipping mode:none only over the now-retired inert-`.git` posture). This doc is the git HistoryProvider, composed on git-mode boards in every tier; boards without app-managed git bind macOS-native undo (13-native-undo.md). The Undo routing section below was always tier-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's Git tab — 03-board-ui.md; see 07-sync-collab.md's mode progression). A board without app-managed git binds the **native undo stack in every tier** — repo-nested included (re-ruled 2026-07-31, twice — the provider follows the board, 13-native-undo.md; formerly no-undo under Pro, which made upgrading remove undo from mode-none boards, and the repo-nested no-undo residue retired the same day: the native stack touches no git, so leave-strictly-alone is untouched and no board lacks ⌘Z). (Text editors keep their standard typing undo everywhere; see Undo routing below.) Deletes — card or lane — are recoverable on every board via the materialized trash (03-board-ui.md). Add-git swaps native → git mid-session, discarding the in-session native stack and seeding the git trail — the branch-switch discard-and-reseed precedent. 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.**The initial branch is `main`** (blessed 2026-07-31): the host's `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it deterministically — git's modern default, the pathfinder's choice.
- **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.**Denial is not absence** (ruled 2026-07-31): the ancestor walk crosses paths above the board's sandbox grant, and a check the sandbox *refuses* (EACCES/EPERM) must never read as "no repo there" — detection distinguishes **clean none** (every ancestor answered not-found) from **unverifiable** (a check was denied); add-git is offered only on clean none, and unverifiable takes the repo-nested posture (conservative — the popover explains rather than offers). As hardening, add-git's create re-runs full detection and refuses unless it reads clean none, so the forbidden nested init is impossible even on a raced or stale read. Whether the shipped sandbox actually denies ancestor stats is an open empirical question (manual-verification list: a board deep inside an ungranted repo, and an ordinary board under an ungranted parent) — if it denies everywhere, every board would read unverifiable and this posture needs a data-informed revisit. 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 tab flows straight from the add-git offer into the git-mode posture, 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**: 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.
- **A`.git` that isn't a valid repository still reads as git mode — and fails loudly** (ruled 2026-07-31): detection is presence-shaped (any root `.git` entry, directory or worktree/submodule pointer file), so a corrupt or unopenable repo never falls to mode none — Add Git is never offered against an existing `.git`, whatever its condition (init into a repairable repo is exactly the never-mutate hazard). The board itself loads and edits normally — files are the board — but the failure is **loud**: a standing breakage-class banner at detection ("This board's git repository can't be read — history is paused; Lanework leaves the repository untouched"), announced per 10-accessibility.md, with the whole git surface paused (the abnormal-states posture below) and the popover's git section naming the state; the banner clears when a later open or reload finds the repo readable. Never a silent placeholder discovered only in the popover.
- **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.
- **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. **The root commit is never split and is user-authored** (blessed 2026-07-31): it is a baseline, not a change-set — the event it records is the user's act of putting the board under git, adopted unborn repos' unwitnessed files included. 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 — and, because a terminal's cleanup moves only files under `.git`, which the watcher never delivers, **a standing pause re-reads the repository state every 15 s** (blessed 2026-07-31; injectable cadence, only while paused, a handful of stats — not a retry, nothing is attempted) — 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 — **on success only; a failed abort keeps the stamp** (blessed 2026-07-31): the stamp is the sole evidence the leftover is the app's, and clearing it on failure would demote the leftover to somebody-else's forever — the pause would then send the user to "the tool that created it," which was this app. Kept, the next open or paused-state re-read recognizes it and retries; recovery converges instead of orphaning. 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.
- **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.
- **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 app-managed history — while the native session stack still serves ⌘Z there in every tier** (re-ruled 2026-07-31, retiring the old no-undo residue: 13's stack is memory-only and journal-free, so self-containment holds; what repo-nested denies is git, never undo). 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.
- **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.
- **Auto-commit**: every settled change (debounced past drag/typing churn) commits with a descriptive message ("Move card 'Fix login' to Doing"). Board history sees **card-window sessions, not gestures** (re-ruled 2026-07-31, widening the Edit-sessions-not-save-ticks rule — 13-native-undo.md's session-coarsening model, applied to the commit substrate): while a card's window is open, everything happening inside it — the body editor's ~700 ms crash-safe disk saves, comment posts and deletes, draft-save cadence, sidebar changes — stays **uncommitted**, and the committer **stages around the whole open card folder** (the former Edit-session stage-around, widened; comments included); **window close flushes the session as one commit** ("Update card 'Fix login'"-shaped, the composer folding the card-scoped diff, body bullets carrying the events) — granular window activity never litters history. **The flush awaits the snapshot that covers it** (ruled 2026-07-31): the composer diffs `store.snapshot` against HEAD, so the close flush awaits a snapshot generation covering its changed paths before the committer runs — the commit's subject can never be outrun by its own reload; the cadence margin (2 s debounce vs 200 ms watcher) is the practical cushion, never the guarantee (the awaiting-bracket machinery is the shape). **The await's baseline is the bracket close, not the walk's start** (blessed 2026-08-06): a reload already in flight when the bracket closes clears the gate without a started-after guarantee — only the store knows when a walk began, and the committer deliberately does not ask. The racer window is academic under the cadence margin, the post-bracket reload is scheduled unconditionally either way, and the deadline already accepts composing from a near-covering snapshot when the watcher is broken — the tree, not the snapshot, is what gets staged, so the exposure was always the message's precision, never the commit's content. Walk-start bookkeeping (the store exposing when a walk began) is the named escalation if the window ever stops being academic. **A commit's comment bullets sort chronologically** (blessed 2026-07-31): by the comments' own `created`, folder name on ties — event order reads as the conversation did, never UUID-arbitrary. The EchoLedger's two-commit split still applies at close when the held window mixes foreign changes to that card with the app's own. Settled-tree events that cannot wait — a pull's flush-before-overwrite — commit the session's on-disk state 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 open card-window sessions, 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 — **as strong as the watcher's knowledge** (bounded, blessed 2026-07-31): the gate fires on *known* foreign changes, learned from landed reloads, so a foreign write still inside the watcher debounce can be overwritten unflushed; the window is the debounce (~200 ms), the disk-level outcome inside it is 05's last-writer-wins — the same ruling at history granularity — and `index.md` writes compose over fresh disk bytes anyway, so wholesale loss needs a body-save or raw-Apply race. A **failed reload counts as a foreign change** (can't know, so protect). A pre-write disk compare was weighed and declined — a read per write to buy back the debounce corner. **The flush commits synchronously on the main actor** (blessed 2026-07-31): the write path it must precede is synchronous, so the ordering guarantee can only be kept by a synchronous commit — 02's hang-avoidance doctrine and this guarantee genuinely conflict here, and the guarantee wins, being rare (the known-foreign window only), bounded (one stage-and-commit plus the composer's HEAD-tree materialization — a few board-sized walks, accepted so exactly the commit that preserves somebody else's version keeps its full semantic subject), and load-bearing (the alternative loses a version no commit protects). One attempt, no lock backoff on this path — a held `index.lock` falls through to the next quiet debounce. 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).
- **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.
- **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.
- **Undo restore vs open Edit sessions** (settled): a restore materializes only the diff between the current tree and the target state, so a card whose open Edit session the diff doesn't touch is simply unaffected — its uncommitted ~700 ms saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all. When the diff *does* touch a session card, the restore **gates on the branch-switch save-or-discard step** (Branch switching below — Save All / Discard / Cancel, same machinery, same rationale): silently flushing would commit a tree the user deliberately hasn't saved, a checkout over uncommitted on-disk saves would destroy text no commit protects (the one place "both versions exist as commits" could otherwise fail), and a surviving dirty buffer's next debounced save would write pre-undo text over the restored card — a ⌘Z that visibly doesn't happen. With sessions settled the restore runs on a settled tree. Redo is symmetric. Open raw-source buffers get the branch-switch settle treatment too (Branch switching below).
- **Undo restore vs open Edit sessions** (settled): a restore materializes only the diff between the current tree and the target state, so a card whose open Edit session the diff doesn't touch is simply unaffected — its uncommitted ~700 ms saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all. When the diff *does* touch a session card, the restore **gates on the branch-switch save-or-discard step** (Branch switching below — Save All / Discard / Cancel, same machinery, same rationale): silently flushing would commit a tree the user deliberately hasn't saved, a checkout over uncommitted on-disk saves would destroy text no commit protects (the one place "both versions exist as commits" could otherwise fail), and a surviving dirty buffer's next debounced save would write pre-undo text over the restored card — a ⌘Z that visibly doesn't happen. With sessions settled the restore runs on a settled tree. Redo is symmetric. Open raw-source buffers get the branch-switch settle treatment too (Branch switching below).
- **The stack is HEAD's first-parent ancestry, live** (settled): foreign commits — watcher-auto-committed agent work and agents' *self*-commits alike — push onto the in-session undo stack as ordinary steps as they land. The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the app's committer; the pre-flight sync is how the stack learns), so ⌘Z always steps back exactly **one** commit — it can never silently revert twenty minutes of agent work landed since the user's last operation. Any commit arriving from anywhere clears the redo stack (classic behavior; redo also starts empty on the relaunch reseed below). In-session and post-relaunch behavior are thereby one rule — the reseed is the same ancestry walk from scratch.
- **The stack is HEAD's first-parent ancestry, live** (settled): foreign commits — watcher-auto-committed agent work and agents' *self*-commits alike — push onto the in-session undo stack as ordinary steps as they land. The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the app's committer; the pre-flight sync is how the stack learns), so ⌘Z always steps back exactly **one** commit — it can never silently revert twenty minutes of agent work landed since the user's last operation. Any commit arriving from anywhere clears the redo stack (classic behavior; redo also starts empty on the relaunch reseed below) — **except a heal-only window** (blessed 2026-07-31): heal transparency (below) makes heal commits invisible to the stack, and a heal-only clear would half-defeat it — the trap would dissolve for undo while redo still died on every landed repair; a window mixing a heal with real changes clears via the real changes. In-session and post-relaunch behavior are thereby one rule — the reseed is the same ancestry walk from scratch. **The root commit is the stack's floor, not a step** (settled 2026-07-31): it is a baseline, not a change-set (Abnormal repo states above), and it has no parent — crossing it could only mean restoring the empty tree, every file on the board deleted by one ⌘Z. The stack stops above it: undo bottoms out at the initial board state, and the reseed walk stops in the same place.
- **Undo survives relaunch**: the undo stack reseeds from HEAD's first-parent ancestry on load; redo starts empty. In-session it behaves as classic dual stacks; after relaunch, past restore commits reappear as ordinary undoable steps. Deliberate: no sidecar state, nothing ever lost. Interaction with pull (07-sync-collab.md): a pull rebases unpushed local commits, so the in-session stack must remap onto the rewritten commits — the pre-rebase hashes are orphaned. A pleasant consequence of the reseed rule: the fetched remote commits sit in HEAD's first-parent ancestry, so after the next relaunch remote work becomes ordinary undoable steps too.
- **Undo survives relaunch**: the undo stack reseeds from HEAD's first-parent ancestry on load; redo starts empty. In-session it behaves as classic dual stacks; after relaunch, past restore commits reappear as ordinary undoable steps. Deliberate: no sidecar state, nothing ever lost. Interaction with pull (07-sync-collab.md): a pull rebases unpushed local commits, so the in-session stack must remap onto the rewritten commits — the pre-rebase hashes are orphaned. A pleasant consequence of the reseed rule: the fetched remote commits sit in HEAD's first-parent ancestry, so after the next relaunch remote work becomes ordinary undoable steps too.
- **Heal commits are transparent to undo, in-session** (ruled 2026-07-29): heal-class commits — their paths known by the Writer's heal-marked receipts (Commit messages below) — never become undo steps: the stack pointer passes over them, and a restore materializing an older target **excludes paths whose divergence is heal work**, so a ⌘Z run never reverts a repair and never summons the scheduler (reverting one would re-arm the memo on the recreated defect signature, land a fresh heal commit, and — redo cleared — trap the run on an ever-renewing top; transparency dissolves the trap instead of suppressing the healer). The in-session qualifier is honest: receipts live in memory and the reseed is deliberately sidecar-free, so after relaunch old heal commits reappear as ordinary steps — undoing one recreates its defect and the scheduler re-heals within a reload, restore commit plus fresh heal commit, notice included. That **residual bounce is accepted family-wide** — the agent-guide quirk (Agent collaboration below) generalized to the relocation, the legacy migration, the displacement, and the remint — and it is **self-limiting to one bounce**: the fresh heal commit is in-session, transparent, and the undo run continues past it. Redo is symmetric.
- **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).
- **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.
- The git surface is the **board popover's Git tab**, whole (03-board-ui.md): branch/source display, the switch picker with its New Branch… reveal, the posture lines, add-git, and the commit-identity name/email fields (see Interaction with external writers below), alongside board rename and styling in the popover's other tabs. *(Amended 2026-08-07 — the 2026-07-31 popover/sheet split is reversed: setup lived in a board settings sheet for a week, and that surface retired with its menu command; 03 ▸ Board settings sheet carries the reasoning. Remote and credentials — 07 — need a home ruled on that card.)*
## Undo routing
Migrated to 13-native-undo.md (2026-08-08, at this doc\'s retirement) — the rule was always tier- and substrate-independent, and 13 is its live home; the heading stays here for the docs that cite it.
## 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`, and an on-touch heal's backfilled `kind` (01-storage-format.md ▸ Validation and healing) — produces no events: a diff touching only those composes nothing. Sequence is what the diff compares, not raw `order` values: an order change that *repositions* an item among its siblings composes Reorder, so a foreign writer's single-file reorder still reads as one. A midpoint-exhaustion renumber batches with the insert or move that triggered it, so its commit reads as that event.
- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus Attach / Remove for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"). One commit per window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows fall back to "Update board" — always with a bulleted body naming every event, so the oneline log stays scannable and the full message stays complete.
- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus **Collapse / Expand** for the lane fold (added 2026-08-08 with collapsed lanes — 03-board-ui.md § Lane ▸ Collapsed lanes: two verbs rather than one, the trash pair's split, because a row has to read back the gesture it undoes), plus Attach / Remove / **Replace** for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"; Replace added 2026-07-31 — a changed file under a card's `attachments/` with an unchanged listing is a content replacement, named from the path alone: "Replace attachment 'photo.png' — card 'X'", never the anonymous path generic), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's silent scheduled heal, re-ruled 2026-07-29 from its former banner gate; app-mediated and heal-marked, so its separate heal commit names the remint directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: a move into `.trash/` is Delete, a move out of it is Restore ("Restore card 'X'" — drag-to-restore, cut+paste), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — the trash's Delete, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free); the inverse oddity — a card *arriving from outside the tree directly in `.trash/`* — composes "Add card 'X'" with a to-the-trash detail (blessed 2026-07-31: the arrival shape is an Add, the detail names the destination). 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 **say so in the subject** (re-ruled 2026-07-31, retiring the bare "Update board" fallback): **"Mixed update — N changes"**, always with a bulleted body naming every event, so the oneline log stays scannable and never dresses a grab-bag as one thing; when every event in the window shares one item — the card-window session flush's usual shape — the subject keeps the name: **"Mixed update — N changes to card '⟨title⟩'"**.
- **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".**One level further down, a card's own event swallows its thread entirely** (blessed 2026-07-31): a card moved, deleted, restored, purged, or reminted carries its `comments/` paths silently — the card's event already explains every file under it, and comment bullets trailing "Delete card 'X'" would be the burying this rule exists to prevent.
- **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.
- **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.
- **Healing mutations commit separately** (ruled 2026-07-29 — 01-storage-format.md ▸ Validation and healing): a debounce window holding a scheduled heal's changes alongside anyone else's splits the heal's paths into their own commit — the two-commit split's mechanism with a third class, keyed by the Writer's heal-marked receipts in the EchoLedger; a three-way window commits **foreign → heal → user** (blessed 2026-07-31 — causal: what was found, the response to it, the newest overwrite); split staging starts by **resetting the shared index to HEAD** (blessed 2026-07-31 — another writer's staged-but-uncommitted picks are discarded, their *content* still committing on the app's classified terms; the exposed race is only the gap between a foreign `add` and its `commit`, since a held `index.lock` already backs the app off, and a private in-memory index beneath the wrapper is the named upgrade if cohabitation friction ever shows) (attribution machinery, like the author split; the composer stays a pure diff reader and names the heal commit from its own diff shape — a loose-file relocation reads as its Attach-shaped event, a legacy migration's move into `.trash/` as Delete, the guide as "Update agent guide (vN)"; that the shape vocabulary doesn't say "healed" is accepted, the banner already told the user). Mostly redundant — each scheduled healer runs its own bracket at the reload tail, normally its own window — but the split makes separation a guarantee rather than a timing accident. **Heal commits are authored `Lanework Integrity <[email protected]>`** (ruled 2026-07-31 — the third pinned synthetic, joining Lanework External and the agent-slug family; strings are API): a heal is a third origin — not the user's gesture, not a foreign writer — and the separation exists for audit, so the trail filters by author like every origin; the committer stays the user (the recorded-by convention above). On-touch and inline heals are structurally exempt: each lives inside a host write or its triggering gesture and rides that commit, the backfilled `kind` composing no event per the bookkeeping rule above.
- **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. **Subjects don't nest either — crossing a restore composes the inverse** (settled 2026-07-31): when the crossed commit's subject already carries a restore prefix — the post-relaunch case, where the reseed has made old restore commits ordinary steps — the composer emits the *inverse* label instead of stacking: crossing "Undo: S" yields "Redo: S", crossing "Redo: S" yields "Undo: S". This is the truer label, not a euphemism — undoing the restore that undid a move *re-applies* the move — and it caps prefixes at one across any number of relaunches. The sniff is on the subject string, so a foreign commit that happens to open with a prefix gets the inverse label too; that's cosmetic — the restore itself is unaffected. **Restore commits are user-authored, whatever they cross** (blessed 2026-07-31): the commit records the user's decision to put things back, not the crossed change — a foreign or agent-authored commit undone by ⌘Z yields a restore under the user's identity, keeping `--author` filtering truthful (an agent never appears to revert itself). **Menu enablement reads a cached stack picture that refreshes asynchronously after a landed commit** (blessed 2026-07-31): a crossing always awaits settlement — ⌘Z acts on true history, never the cache — so the lag can only mislabel or mis-grey a menu row for one beat, matching AppKit's own validation cadence; a synchronous walk on the commit-report path was declined as cost without an observable win.
**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:
- **The diff models the full schema-1 surface — plus the reserved metadata trio, deliberately**: label, assignee, and due changes compose ("Relabel card 'X'", "Assign card 'X'", "Set due date on card 'X'") even though 01-storage-format.md reserves those keys out of this version's UI — external writers (pathfinder-era boards, agents) are exactly who touches them, and naming three known keys in a pure diff function costs nothing. A change to any other unmodeled or custom key composes a named generic ("Update card 'X'") — never a board-level shrug when the touched item is identifiable.
- **The diff models the full schema-1 surface — plus the reserved metadata trio, deliberately**: label, assignee, and due changes compose ("Relabel card 'X'", "Assign card 'X'", "Set due date on card 'X'") even though 01-storage-format.md reserves those keys out of this version's UI — external writers (pathfinder-era boards, agents) are exactly who touches them, and naming three known keys in a pure diff function costs nothing. A change to any other unmodeled or custom key **says what it is** (re-ruled 2026-07-31 — first lines self-describe; generics are a last resort, kept very rare): **"Change custom key on card 'X'"** (board and lane likewise — "Change custom key on board '⟨title⟩'"; several keys fold plural), the body naming each key with its old → new values. The bare named generic ("Update card 'X'") survives only for a change in a known file that is neither a vocabulary event nor a key change — a shape that should almost never occur; never a board-level shrug when the touched item is identifiable.
- **Foreign commits speak the same vocabulary.** Origin lives in the author field (structural attribution below), not in message prose — a foreign move reads "Move card …" exactly like an app-mediated one, and any git client filters by author.
- **Foreign commits speak the same vocabulary.** Origin lives in the author field (structural attribution below), not in message prose — a foreign move reads "Move card …" exactly like an app-mediated one, and any git client filters by author.
- **The launch catch-up commit composes too**: changes found pending at board open diff HEAD's tree against the working tree through the same composer, instead of committing blind.
- **The launch catch-up commit composes too**: changes found pending at board open diff HEAD's tree against the working tree through the same composer, instead of committing blind.
## Branch switching
## Branch switching
Switching (or creating-and-switching) a branch from the board popover:
Switching a branch from the popover's picker, or creating-and-switching from the **New Branch…** reveal behind that picker's divider (03-board-ui.md ▸ Git tab; the field moved to the settings sheet with the 2026-07-31 split and came back with the 2026-08-07 reversal, the sequence untouched by either move) — **create-and-switch runs the identical settle → flush → stamp → switch sequence, no at-HEAD fast path** (blessed 2026-07-31): "creating at HEAD can't change the tree" fails as a proof under concurrent writers (a foreign commit can land between the check and the create), and flushing pending work onto the branch it was made on is the honest cadence regardless — the occasional save-or-discard beat on a create buys one sequence with no special case:
- **Settle the editors first — explicitly, never silently.** Branch switch neither silently commits nor silently abandons an open card-body Edit session: if any open card window has one (unsaved keystrokes, or on-disk ~700 ms saves the session hasn't committed — the mid-session state Auto-commit above deliberately leaves uncommitted), the switch presents a **save-or-discard step**: **Save All** ends every session with its normal commit (each card's Edit→Preview flip), **Discard** reverts buffers and uncommitted saves to HEAD, **Cancel** keeps the current branch and the sessions. Silently flushing the commit alone would be wrong twice over: the tree can be clean precisely because a save hasn't landed, and a later debounced save would write old-branch text onto the new branch's card. With sessions settled, the pending auto-commit flushes (flush-before-overwrite above) and checkout runs on a truly settled tree: it cannot fail dirty, and no in-flight work is lost or dragged across branches. (Inline title editors need no step of their own: reaching the popover's branch controls commits them — click-away commits, and board-scoped commands disable while one is focused — 04-interactions.md ▸ Grammar.)
- **Settle the editors first — explicitly, never silently.** Branch switch neither silently commits nor silently abandons an open card-body Edit session: if any open card window has one (unsaved keystrokes, or on-disk ~700 ms saves the session hasn't committed — the mid-session state Auto-commit above deliberately leaves uncommitted), the switch presents a **save-or-discard step**: **Save All** ends every session with its normal commit (each card's Edit→Preview flip), **Discard** reverts buffers and uncommitted saves to HEAD, **Cancel** keeps the current branch and the sessions. **Open raw-source buffers are settled by the same step** (settled — an unsettled raw buffer is the worse hazard: its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new branch's card): Save All *applies* each raw buffer — and since Apply validates, a buffer that fails validation cancels the whole switch with focus on the offending window, nothing half-switched; Discard exits raw source without writing; Cancel keeps everything. External checkouts the app can't gate are the accepted last-writer-wins case, same as the Edit buffer (05-card-window.md's dirty-buffer rule; on git boards the overwritten version is a commit, one revert away). Silently flushing the commit alone would be wrong twice over: the tree can be clean precisely because a save hasn't landed, and a later debounced save would write old-branch text onto the new branch's card. With sessions settled, the pending auto-commit flushes (flush-before-overwrite above) and checkout runs on a truly settled tree: it cannot fail dirty, and no in-flight work is lost or dragged across branches. **The settle also clears each open card window's fine undo stack** (ruled 2026-07-31): pre-switch steps describe the branch being left — Save All and Discard alike end with every window's stack empty, the board-stack discard-and-reseed precedent one level down; the windows stay open, following their cards onto the new branch with fresh stacks. **The flush is Save-All-shaped** (blessed 2026-07-31): after Discard, the session's reverted bytes are *reconciled, never flushed* — the pending window for that folder drops, since disk again agrees with HEAD and committing the discarded saves would betray the button; pending changes elsewhere on the board still flush normally. (Inline title editors need no step of their own: reaching the popover's picker or its New Branch… field commits them — click-away commits, and board-scoped commands disable while one is focused — 04-interactions.md ▸ Grammar.)
- **The undo/redo stack does not survive a switch.** It is discarded and reseeded from the new HEAD's first-parent ancestry — the relaunch rule applied at switch time; redo starts empty. (Replaying a restore commit from the previous branch onto the new one would be wrong.)
- **The undo/redo stack does not survive a switch.** It is discarded and reseeded from the new HEAD's first-parent ancestry — the relaunch rule applied at switch time; redo starts empty. (Replaying a restore commit from the previous branch onto the new one would be wrong.)
- **Everything remote-facing tracks the current branch**: ahead/behind, Pull/Push, and push-on-commit all operate against the current branch's upstream. On a branch with no upstream yet, the first push — manual or push-on-commit — **creates it on the remote quietly** (`push -u` semantics): creating a remote branch is non-destructive, and quiet is consistent with push-failures-never-nag (07-sync-collab.md). Genuine failures queue with the badge as usual.
- **Everything remote-facing tracks the current branch**: ahead/behind, Pull/Push, and push-on-commit all operate against the current branch's upstream. On a branch with no upstream yet, the first push — manual or push-on-commit — **creates it on the remote quietly** (`push -u` semantics): creating a remote branch is non-destructive, and quiet is consistent with push-failures-never-nag (07-sync-collab.md). Genuine failures queue with the badge as usual.
- The switch itself is bracketed (02-architecture.md): watcher suspended, one full reload at the end. If that final reload fails, the board locks read-only until a successful reload — see 02's live-reload resilience; the on-screen snapshot is from the previous branch and must not be edited over the new one.
- The switch itself is bracketed (02-architecture.md): watcher suspended, one full reload at the end. If that final reload fails, the board locks read-only until a successful reload — see 02's live-reload resilience; the on-screen snapshot is from the previous branch and must not be edited over the new one.
## Interaction with external writers
## Interaction with external writers
Agent and hand edits arrive through the watcher like any change and get auto-committed on the same debounce — so agent work is undoable, attributed, and *described* in the same trail: foreign changes compose through the same message engine as app-mediated ones (Commit messages above — the pathfinder's generic "External edit: 2 cards changed" fallback is gone), with origin carried by the author field. One attribution exception: the app's own agent-guide writes (08-agent-integration.md) are the app's own Writer operations — app-mediated by the echo machinery, carrying the guide's version-marker first line — and committed as "Update agent guide (vN)", not "External edit". Known quirk, not a bug: undoing an "Update agent guide (vN)" commit restores an older guide that the app immediately re-upgrades — a one-bounce no-op undo (restore commit + fresh upgrade commit). Harmless; the guide is app-owned and self-healing by design.
Agent and hand edits arrive through the watcher like any change and get auto-committed on the same debounce — so agent work is undoable, attributed, and *described* in the same trail: foreign changes compose through the same message engine as app-mediated ones (Commit messages above — the pathfinder's generic "External edit: 2 cards changed" fallback is gone), with origin carried by the author field. One attribution exception: the app's own agent-guide writes (08-agent-integration.md) are the app's own Writer operations — app-mediated by the echo machinery, carrying the guide's version-marker first line — and committed as "Update agent guide (vN)", not "External edit". The guide-commit undo quirk is the heal-transparency rule's oldest case (Rules ▸ Heal commits are transparent): in-session the upgrade commit never enters the stack; only a relaunch-old guide commit bounces — once, restore commit plus fresh upgrade commit, the fresh one transparent. Harmless; the guide is app-owned and self-healing by design.
**Commit attribution is structural, not just a message convention.** The Writer/echo machinery lets the auto-committer classify every observed change as **app-mediated** (the user acting through the app) or **foreign** (anything else). User-driven commits carry the user's git identity; foreign changes are committed under the pinned synthetic author **`Lanework External <[email protected]>`** — so any git client can filter, log, and blame by origin. The strings are API (users script against them; the `.invalid` TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness of a schema change.
**Commit attribution is structural, not just a message convention.** The Writer/echo machinery (the **EchoLedger** — 02-architecture.md ▸ Components, where its matching rule and race cases are settled) lets the auto-committer classify every observed change, per file, as **app-mediated** (the user acting through the app) or **foreign** (anything else). User-driven commits carry the user's git identity; foreign changes are committed under the pinned synthetic author **`Lanework External <[email protected]>`** — so any git client can filter, log, and blame by origin.**The committer field is always the user's identity** (blessed 2026-07-31 — git's own `am`/cherry-pick convention: author = whose change, committer = who recorded it): every commit the app makes, foreign-authored included, records the user's app as its committer. The strings are API (users script against them; the `.invalid` TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness of a schema change.
**Where the user's git identity comes from** (no git install is assumed, and the sandbox doesn't read `~/.gitconfig` — honest limits, not bugs): **repo-local `.git/config` wins when present** — standard git semantics, readable in-sandbox because it lives under the board root, and the natural state of adopted/cloned boards. The board popover's git section exposes name/email fields that **write that repo-local config** — the setting *is* the file, portable to any git client, per-board by nature (work and personal boards can differ). Absent repo config, the **derived default** applies: the macOS account's full name plus `shortname@hostname` — git's own no-config fallback shape, zero ceremony. Commits pushed to a forge under the derived email won't link to a forge account; the popover fields are the fix when that matters. A debounce window containing both kinds is **split into two commits**, never mixed (flush-before-overwrite already orders them: foreign first, then the user's overwrite). Honest limit: the app distinguishes app-mediated from foreign, not human from agent — a hand edit in a text editor and an agent write look identical *unless the writer says otherwise via `modified-by` (below)*. Agents wanting precise attribution are encouraged (via the agent guide, 08-agent-integration.md) to commit their own changes; the app follows along.
**Where the user's git identity comes from** (no git install is assumed, and the sandbox doesn't read `~/.gitconfig` — honest limits, not bugs): **repo-local `.git/config` wins when present** — standard git semantics, readable in-sandbox because it lives under the board root, and the natural state of adopted/cloned boards. The **popover's Git tab** carries an identity section (03-board-ui.md — the 2026-07-31 split moved the fields onto a settings sheet and the 2026-08-07 reversal brought them back) exposing name/email fields that **write that repo-local config** — the setting *is* the file, portable to any git client, per-board by nature (work and personal boards can differ). **The fields re-read the config at 2 s while they are visible** (blessed 2026-07-31; container amended 2026-08-07 — the poll rides with the fields, so it now lives and dies with the popover's Git tab rather than with the retired sheet): the watcher never delivers `.git`, so no board event can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and a visibility-scoped poll is the 15 s paused-state re-read's shape at form cadence (a focused field keeps its keystrokes; dismissing the surface stops the poll). **Writes append, reads take the last** (blessed 2026-07-31): the writer appends a plain `[user]` section and never edits existing sections or `[user "…"]` subsections (their semantics are tool-specific); the reader — like git itself — takes the last plain-section value, which is exactly what an append produces. The asymmetry lets the write always win without the writer ever reformatting what it didn't create — the frontmatter engine's never-reformat instinct applied to git config; the worst case is a slightly redundant file git reads correctly. A write whose keys already read back at their target values is skipped whole, so revisiting the fields never grows the file. **Clearing a key is the one sanctioned in-place edit** (ruled 2026-08-06): an empty field means "no repo-local opinion", and the config format spells absence one way only — the key not being there. The append-shaped alternative, an empty `email =` line, is an opinion in the wrong direction: a repo-level empty value *overrides* the user's global `~/.gitconfig` in their own terminal and fails their commits with git's empty-ident error. So a clear deletes every plain-section line for that key — deleting fewer than all of them changes nothing under last-wins — and drops any plain `[user]` header left with no keys under it; clearing both fields leaves the file with no plain-section identity at all, the state a never-configured repo is in. Subsections stay untouchable in both directions. Absent repo config, the **derived default** applies: the macOS account's full name plus `shortname@hostname` — git's own no-config fallback shape, zero ceremony. Commits pushed to a forge under the derived email won't link to a forge account; the identity fields are the fix when that matters. **The derived default is passed as an explicit per-commit signature, never written into repo config** (ruled 2026-07-31 — the signature-capable commit path gates the pro-m1 ship): repo config is the record of the user's popover edits and of adopted repos' own state, and an app-written identity there would outrank the user's global `~/.gitconfig` for their *own terminal commits* in that board. The build-time interim that materializes identity into a fresh repo's `.git/config` (SwiftGitX 0.4.0's signatureless commit + the sandbox's unreadable global config) is tolerated in-tree during pro-m1 construction and must die before release — the attribution rules above (per-commit author variation) require explicit signatures anyway. A debounce window containing both kinds is **split into two commits**, never mixed (flush-before-overwrite already orders them: foreign first, then the user's overwrite). Honest limit: the app distinguishes app-mediated from foreign, not human from agent — a hand edit in a text editor and an agent write look identical *unless the writer says otherwise via `modified-by` (below)*. Agents wanting precise attribution are encouraged (via the agent guide, 08-agent-integration.md) to commit their own changes; the app follows along.
**`modified-by` refines foreign attribution** (the self-reported provenance key — 01-storage-format.md): when every file changed in a foreign debounce window carries the same `modified-by: X`, that commit is authored as **X** with the synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part slugified; the domain marks self-reported identity, distinct from both the user and the generic external author). Any disagreement between stamps, any unstamped changed file, or any true deletion in the window falls back to `Lanework External` — a deletion leaves no file to stamp. **A folder move is not a deletion**: items match by id across the whole board (Commit messages above — the same matching that reads a move as a move, not delete+add), so a moved card attributes by its stamp like any changed file. But a bare `mv` rewrites nothing — the moved `index.md` still carries whatever the app last wrote (no stamp) and demotes the window under the unstamped-file rule — so the agent guide teaches re-stamping on move (08-agent-integration.md). Same trust level as self-committing — it's what the writer claims, accepted as such; the stale-stamp hand-edit case (01) is the known misattribution edge. Self-committing remains the precise path; the stamp is the lightweight middle.
**`modified-by` refines foreign attribution** (the self-reported provenance key — 01-storage-format.md): when every file changed in a foreign debounce window carries the same `modified-by: X`, that commit is authored as **X** with the synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part slugified; the domain marks self-reported identity, distinct from both the user and the generic external author). Any disagreement between stamps, any unstamped changed file, or any true deletion in the window falls back to `Lanework External` — a deletion leaves no file to stamp. **A folder move is not a deletion**: items match by id across the whole board (Commit messages above — the same matching that reads a move as a move, not delete+add), so a moved card attributes by its stamp like any changed file. But a bare `mv` rewrites nothing — the moved `index.md` still carries whatever the app last wrote (no stamp) and demotes the window under the unstamped-file rule — so the agent guide teaches re-stamping on move (08-agent-integration.md). Same trust level as self-committing — it's what the writer claims, accepted as such; the stale-stamp hand-edit case (01) is the known misattribution edge. Self-committing remains the precise path; the stamp is the lightweight middle.
**Two writers, one repository — the designed situation, not an edge case.** Self-committing agents mean the auto-committer shares the repo with concurrent `git` processes, and it must be graceful about it:
**Two writers, one repository — the designed situation, not an edge case.** Self-committing agents mean the auto-committer shares the repo with concurrent `git` processes, and it must be graceful about it:
- **`index.lock` contention is never an error.** If the auto-committer finds the index locked (an agent's commit in flight), it backs off briefly and retries; if the lock persists, it simply re-debounces — the pending changes are still pending, and the next quiet moment commits them. No banner, no log-worthy failure: a held lock is another writer doing its job. (Genuine commit failures — disk full, repo corruption — are different: files stay safe on disk but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried on the next debounce.) **The same posture covers every app-initiated operation** (settled): pull, push, branch switch, and undo restore meeting a held lock wait and retry briefly, silently; contention outlasting the brief retry surfaces as a *waiting* state in the operation's in-progress banner row ("waiting for another writer's git lock"), retrying on its cadence — never an error dialog, never a hammer — and a wait that persists implausibly long names the lock path (a crashed writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's own leftovers, Abnormal repo states above). An operation that fails *cleanly* — disk error, refused checkout; network and auth are 07-sync-collab.md's pause-and-badge story — surfaces as a one-shot banner failure naming the operation and the error, the tree left as it was; failure after the tree changed wholesale is instead 02-architecture.md's failed-final-reload lock.
- **`index.lock` contention is never an error.** If the auto-committer finds the index locked (an agent's commit in flight), it backs off briefly and retries; if the lock persists, it simply re-debounces — the pending changes are still pending, and the next quiet moment commits them. No banner, no log-worthy failure: a held lock is another writer doing its job. (Genuine commit failures — disk full, repo corruption — are different: files stay safe on disk but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried on the next debounce.) **The same posture covers every app-initiated operation** (settled): pull, push, branch switch, and undo restore meeting a held lock wait and retry briefly, silently; contention outlasting the brief retry surfaces as a *waiting* state in the operation's in-progress banner row ("waiting for another writer's git lock"), retrying on its cadence — never an error dialog, never a hammer — and a wait that persists implausibly long names the lock path (a crashed writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's own leftovers, Abnormal repo states above). **The wait is bounded — 30 s, then a clean failure** (blessed 2026-07-31; injectable): a genuine writer finishes in seconds, so only a stale lock ever reaches the bound, and an eternal spinner holding the wholesale bracket — and with it the board — is worse than a failure that names the path to delete. The bound expiring is the clean-failure case below, tree untouched. An operation that fails *cleanly* — disk error, refused checkout; network and auth are 07-sync-collab.md's pause-and-badge story — surfaces as a one-shot banner failure naming the operation and the error, the tree left as it was; failure after the tree changed wholesale is instead 02-architecture.md's failed-final-reload lock. **Form-anchored operations answer at the form first** (ruled 2026-07-31; container updated twice — by the popover/sheet split, then back by the 2026-08-07 reversal, so these forms live in the popover's Git tab): add-git — and later form-asked operations like verify-remote — fail into an inline caption in the tab's relevant posture while the popover is up (the user asked from a form still under their eye; dismissing the popover dismisses the stale error, retry is right there, VoiceOver reads it from the focused surface); if the popover has been dismissed before the answer arrives, the failure falls back to the one-shot banner above — inline is the primary surface, never a silence trap. The banner enumeration stays the posture for board-wholesale brackets that outlive any one surface — branch switch and undo restore included (blessed 2026-07-31: a switch's bounded lock wait can expire long after the popover dismissed). **The two rules compose rather than conflict** (ruled 2026-08-06, the create-and-switch overlap; simplified 2026-08-07 by the reversal): a board-wholesale operation asked from a form is both at once, and the form rule wins while the asking surface stands — create-and-switch and a plain switch are now asked from the same surface and answer at the same caption, the popover's, so there is one inline slot for the branch affordance rather than two that had to be kept from showing at once. The banner remains the posture for whatever outlives the asking surface — the form rule's own fallback generalized: **inline while the asking surface is up, banner once it is gone.** Banner-only-everywhere was weighed and set aside — it would answer a form's question away from the form still under the user's eye. Which window's strip carries the row is 02-architecture.md's hosted-by-the-window-of-origin rule — the card window hosts its own strip, and a ⌘Z pressed there surfaces its failure there (re-homing to the board window if the card window closes first, per 02).
- **A clean tree is the happy path, not a malfunction.** When the debounce fires and the tree has nothing to commit — the agent already committed its own work — the auto-committer no-ops silently. The agent's commit, under the agent's own authorship, *is* the record; that is precisely what the self-commit recommendation is for.
- **A clean tree is the happy path, not a malfunction.** When the debounce fires and the tree has nothing to commit — the agent already committed its own work — the auto-committer no-ops silently. The agent's commit, under the agent's own authorship, *is* the record; that is precisely what the self-commit recommendation is for.
- **An agent's `git add -A` can sweep up the user's not-yet-committed app-mediated changes** under the agent's authorship, muddying structural attribution for that window. Accepted limit — the app cannot police another process's staging; the agent guide (08-agent-integration.md) tells agents to commit only their own paths, which keeps well-behaved agents honest.
- **An agent's `git add -A` can sweep up the user's not-yet-committed app-mediated changes** under the agent's authorship, muddying structural attribution for that window. Accepted limit — the app cannot police another process's staging; the agent guide (08-agent-integration.md) tells agents to commit only their own paths, which keeps well-behaved agents honest.
## Repository hygiene
## Repository hygiene
- **`.gitignore` seeded at init, never touched after.** Adding git to a board writes a minimal `.gitignore` (`.DS_Store`) if none exists; the app never edits an existing one and never manages the file afterward — it's the user's from then on. (Repo-nested boards have no app-managed git, so no app `.gitignore` either.)
- **`.gitignore` seeded on every board, never touched after** (re-ruled 2026-07-31 — the file outgrew git: it is the one noise definition the loose-file relocation heal obeys, 01-storage-format.md ▸ Rules, so every board carries it, git or not). Board creation writes the minimal seed — `.DS_Store` plus the writer's temp pattern (`.*.lanework-*`) — and a board missing the file gains it by scheduled heal at open (the guide-refresh cadence; deletion is answered by re-seeding, and the escape hatch for wanting no exclusions is an *empty* file, which the app honors and never rewrites). The app never edits an existing `.gitignore` — it's the user's from the seed on, and fine-tuning what counts as noise over time means fine-tuning the seed. Repo-nested boards are seeded too (re-ruling the old no-app-`.gitignore` posture): the file serves the heal there, not any app-managed git; the visible untracked file in the user's repository is accepted on the agent-guide precedent, with the honest side effect that the enclosing repo's git reads it for paths under the board.
- **Repo growth is accepted.** Unbounded history is the price of never-rewrite, and every attachment version lives in the repo forever. The app may run safe libgit2 housekeeping (repacking loose objects) periodically — it rewrites nothing. Content-removing compaction is explicitly out (it would rewrite history); size tooling joins the wishlist if growth ever bites in practice.
- **Repo growth is accepted.** Unbounded history is the price of never-rewrite, and every attachment version lives in the repo forever. The app may run safe libgit2 housekeeping (repacking loose objects) periodically — it rewrites nothing.**"Periodically" has numbers** (blessed 2026-07-31, all injectable): the pack runs when the loose-object count crosses **6,700** — git's own `gc.auto` default, borrowed for the identical reason — **8 s after session activation** (outlasting the committer's launch catch-up), at background priority, skipped under a pause, a lock, or an in-flight commit, and **attempted at most once per session** — hygiene must never become a hot loop; a skipped or failed pack waits for the next open. **Packs accumulate — the pass never consolidates, rewrites, or deletes existing packs** (blessed 2026-07-31): it deletes only loose files it proved redundant against the pack it just wrote, so a long-lived board accrues roughly one pack per threshold's worth of objects, forever — the never-rewrite posture's accepted cost (git tolerates many packs gracefully; a terminal `git gc` consolidates at the user's discretion). **SHA-256 repositories are unsupported, safely** (blessed 2026-07-31): an adopted SHA-256 repo the engine cannot open takes the corrupt-repo loud-failure path — never a silent fall to mode-none; should one ever open, hygiene abstains by construction — its loose-object filter matches the SHA-1 filename shape only, so its work list is empty and the repo is left untouched (recognizing nothing and touching nothing is the fail-safe direction; a looser match that deleted half-recognized files would be the dangerous one). Content-removing compaction is explicitly out (it would rewrite history); size tooling joins the wishlist if growth ever bites in practice.
- **Deleting never forgets.** On a git board, deleting a card removes it from the board but never from history — every version of its content and attachments stays reachable in any git client, and even the future tombstone purge (01-storage-format.md) only cleans the working tree. This is part of the design; users should learn it here, not from a repo browser.
- **Deleting never forgets.** On a git board, deleting a card removes it from the board but never from history — every version of its content and attachments stays reachable in any git client, and even a trash purge (Empty Trash, or the future age-based auto-purge — 01-storage-format.md) only cleans the working tree. This is part of the design; users should learn it here, not from a repo browser.
## Changes from Kanban
## Changes from Kanban
- **Commit granularity resettled to Edit sessions**: body commits land at the Edit→Preview flip (the effective Save button), not at typing-settle; the committer stages around open sessions (Rules ▸ Auto-commit).
- **Commit granularity resettled to Edit sessions**: body commits land at the Edit→Preview flip (the effective Save button), not at typing-settle; the committer stages around open sessions (Rules ▸ Auto-commit).
- **Branch switch gates on explicit save-or-discard** for open Edit sessions instead of silently flushing (Branch switching).
- **Branch switch gates on explicit save-or-discard** for open Edit sessions instead of silently flushing (Branch switching).
- **Commit messages upgraded for external writers**: full schema-1 diff surface, foreign commits in the same vocabulary (no "External edit" prose), semantic launch catch-up, untitled-item rendering (Commit messages).
- **Commit messages upgraded for external writers**: full schema-1 diff surface, foreign commits in the same vocabulary (no "External edit" prose), semantic launch catch-up, untitled-item rendering (Commit messages).
- The rewrite should extract the old repo's AI-ANALYSIS-git-operations.md conclusions (forward-restore model, C3/C8) into a short normative doc rather than re-deriving them.
- The old repo's AI-ANALYSIS-git-operations.md conclusions (forward-restore model, C3/C8) are extracted into [14-git-operations.md](14-git-operations.md) rather than re-derived — cite it, not the pathfinder file.
**RETIRED 2026-08-08 as written** (strategy/01-git-excision.md): the git-rails sync this doc designed — remotes, pull/push, auth, the mode progression — died with the excision of app-managed git, and none of its remote half was ever built. The successor is the **ops-based sync service** (semantic ops over a server-side worktree API, the phone and eventually the web as worktree clients — 01-git-excision.md ▸ Successors), which arrives with its own workstream and its own docs; the popover's Sync tab (03-board-ui.md) holds the surface until then. The iCloud-Drive warning below outlives the retirement in spirit — iCloud remains unendorsed as a transport. Everything below is the record of the design as it stood.
**Tier scope: Lanework Pro** (12-editions.md). The free tier 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 old no-undo caveat is superseded in every tier by native undo (13-native-undo.md — the provider follows the board, re-ruled 2026-07-31). 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:
```
```
@@ -12,7 +16,7 @@ A board may be created plain — **without any git repository** (a pivot from th
## Mode: none (local-only)
## Mode: none (local-only)
Plain folders on local disk. **No git repository at all** — and therefore, since git is the undo substrate, no undo/redo (06-history-undo.md). FSEvents live-reload works as on any board. Adding git later initializes the repo and moves the board to git mode. Honest caveat: without git there is no commit-before-overwrite protection, so on a no-git board **real data loss is possible** (e.g. concurrent or external overwrites) — accepted; adding git is the remedy. Repo-nested boards share this caveat: a repo exists, but the app manages no git there (06-history-undo.md), so its protections never run — committing is the user's own workflow. Deletion is the exception: the tombstone trash (03-board-ui.md) makes deletes recoverable even without git — overwrites are the lossy case, and Empty Trash is deliberate.
Plain folders on local disk. **No git repository at all** — and therefore no git history; undo/redo binds the native stack in every tier (13-native-undo.md, re-ruled 2026-07-31). FSEvents live-reload works as on any board. Adding git later initializes the repo and moves the board to git mode. Honest caveat: without git there is no commit-before-overwrite protection, so on a no-git board **real data loss is possible** (e.g. concurrent or external overwrites) — accepted; adding git is the remedy. Repo-nested boards share this caveat: a repo exists, but the app manages no git there (06-history-undo.md), so its protections never run — committing is the user's own workflow. Deletion is the exception: the materialized trash (03-board-ui.md) makes card and lane deletes recoverable even without git — overwrites are the lossy case, and Empty Trash is deliberate.
## Mode: git
## Mode: git
@@ -20,27 +24,27 @@ 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 settings sheet names the remote it tracks (the popover's tracking badge shows it at a glance — the 2026-07-31 popover/sheet split, 03-board-ui.md), 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 sheet 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). 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). 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)
Everything above assumes credentials exist; this is where they come from. Constraints first, stated as honest limits: a sandboxed app with bundled libgit2 cannot read `~/.ssh` (no entitlement grants it — silent access to every key is exactly what the sandbox exists to prevent), cannot reach `ssh-agent` (a unix socket outside the container; this also rules out 1Password/Secretive/YubiKey agents), cannot run the user's credential helpers, and gains nothing by shelling out (children inherit the sandbox). The network-client entitlement is assumed. Auth is therefore app-native, and **the Keychain is the only credential store** — credentials never live in board files or repo config. That is the deliberate inversion of files-are-truth: secrets are the one thing that must never be a file in the board.
Everything above assumes credentials exist; this is where they come from. Constraints first, stated as honest limits: a sandboxed app with bundled libgit2 cannot read `~/.ssh` (no entitlement grants it — silent access to every key is exactly what the sandbox exists to prevent), cannot reach `ssh-agent` (a unix socket outside the container; this also rules out 1Password/Secretive/YubiKey agents), cannot run the user's credential helpers, and gains nothing by shelling out (children inherit the sandbox). The network-client entitlement is assumed. Auth is therefore app-native, and **the Keychain is the only credential store** — credentials never live in board files or repo config. That is the deliberate inversion of files-are-truth: secrets are the one thing that must never be a file in the board.
- **Transports: HTTPS and SSH, both Keychain-backed.**
- **Transports: HTTPS and SSH, both Keychain-backed.**
- **HTTPS (primary)**: username + token (forge PATs; plain basic auth for generic hosts), stored as a Keychain internet password keyed by **host + username** — git's own scoping model, shared across boards: one GitHub token serves every board, and two accounts on one host coexist as two usernames. **Which username a board uses is the remote URL's business** (git's own answer, and the HTTPS analogue of the SSH per-host table): a username in the URL (`https://alice@host/…`) selects the Keychain item `host + alice`, and the popover's credential capture stamps the entered username into the remote URL in repo config — the URL is the assignment record, no app-side state (the secret itself stays in the Keychain). A URL naming no username resolves to the host's sole stored username; when a host has several, the popover's username field becomes a picker and saving stamps the choice into the URL, while background operations treat the ambiguity as **Authentication needed** (pause and badge, never guess — the same posture as auth failure).
- **HTTPS (primary)**: username + token (forge PATs; plain basic auth for generic hosts), stored as a Keychain internet password keyed by **host + username** — git's own scoping model, shared across boards: one GitHub token serves every board, and two accounts on one host coexist as two usernames. **Which username a board uses is the remote URL's business** (git's own answer, and the HTTPS analogue of the SSH per-host table): a username in the URL (`https://alice@host/…`) selects the Keychain item `host + alice`, and the settings sheet's credential capture stamps the entered username into the remote URL in repo config — the URL is the assignment record, no app-side state (the secret itself stays in the Keychain). A URL naming no username resolves to the host's sole stored username; when a host has several, the sheet's username field becomes a picker and saving stamps the choice into the URL, while background operations treat the ambiguity as **Authentication needed** (pause and badge, never guess — the same posture as auth failure).
- **SSH — Keychain-resident keys, never key files.** Each Mac has a **Lanework key**: an app-generated ed25519 keypair whose private half lives as an ACL-protected Keychain item and is handed to libssh2 from memory — it never exists on disk. The board popover shows the public key with a Copy affordance; the user adds it to their forge like any machine key. An **existing key imports by paste or drag** (a one-time read under user intent): copied into the Keychain — passphrase entered once at import, stored under Keychain protection thereafter — and the original file is never referenced again. Per-machine identity, per-Mac revocable on the forge — the ssh-idiomatic shape. (Secure Enclave-backed keys — non-exportable, custom sign callback, P-256 — are a possible later hardening, not v1.)
- **SSH — Keychain-resident keys, never key files.** Each Mac has a **Lanework key**: an app-generated ed25519 keypair whose private half lives as an ACL-protected Keychain item and is handed to libssh2 from memory — it never exists on disk. The board settings sheet shows the public key with a Copy affordance; the user adds it to their forge like any machine key. An **existing key imports by paste or drag** (a one-time read under user intent): copied into the Keychain — passphrase entered once at import, stored under Keychain protection thereafter — and the original file is never referenced again. Per-machine identity, per-Mac revocable on the forge — the ssh-idiomatic shape. (Secure Enclave-backed keys — non-exportable, custom sign callback, P-256 — are a possible later hardening, not v1.)
- **Key scope: app-level objects, per-host assignment.** Keys are never board state — the machine key plus any imports live app-wide (Keychain), and each SSH host maps to one of them: default the machine key; importing a key during a host's setup assigns it to that host. A "host" is `hostname[:port]` parsed from the remote URL — the same endpoint identity the TOFU fingerprint store uses (OpenSSH's own `[host]:port` convention); the URL's username (`git@`) disambiguates nothing and stays out of it. The assignment table holds **only overrides** — no entry means the machine key, so the default costs zero records and removing an override self-heals to it. The popover's key picker is labeled per-host ("key for github.com"), which teaches the one cross-board consequence: switching a host's key switches it for every board on that host — the same rotate-once-follow-everywhere behavior as HTTPS tokens. Housekeeping stays small: an import referenced by no host row can be removed; the machine key only regenerates (confirm-gated — it invalidates the old public half on every forge), and that is the entire rotation story. The board popover is only the surface — it shows the key for *that remote's host*, the way the commit-identity fields front repo-local config. Known limit, accepted: two accounts on the *same* host can't be told apart by key (forges bind key→account globally; git's own answer is ssh-config aliases, which live in files the sandbox can't read) — a per-remote key override joins the wishlist if it ever bites.
- **Key scope: app-level objects, per-host assignment.** Keys are never board state — the machine key plus any imports live app-wide (Keychain), and each SSH host maps to one of them: default the machine key; importing a key during a host's setup assigns it to that host. A "host" is `hostname[:port]` parsed from the remote URL — the same endpoint identity the TOFU fingerprint store uses (OpenSSH's own `[host]:port` convention); the URL's username (`git@`) disambiguates nothing and stays out of it. The assignment table holds **only overrides** — no entry means the machine key, so the default costs zero records and removing an override self-heals to it. The sheet's key picker is labeled per-host ("key for github.com"), which teaches the one cross-board consequence: switching a host's key switches it for every board on that host — the same rotate-once-follow-everywhere behavior as HTTPS tokens. Housekeeping stays small: an import referenced by no host row can be removed; the machine key only regenerates (confirm-gated — it invalidates the old public half on every forge), and that is the entire rotation story. The board settings sheet is only the surface — it shows the key for *that remote's host*, the way the commit-identity fields front repo-local config. Known limit, accepted: two accounts on the *same* host can't be told apart by key (forges bind key→account globally; git's own answer is ssh-config aliases, which live in files the sandbox can't read) — a per-remote key override joins the wishlist if it ever bites.
- **Host verification is trust-on-first-use**: with no `~/.ssh/known_hosts` readable, the first connection to an SSH host confirms its fingerprint with the user; accepted fingerprints live app-side in Application Support (02-architecture.md's app-wide state home, host-scoped). A later mismatch **hard-blocks with an explanation** — that mismatch is the attack the check exists for.
- **Host verification is trust-on-first-use**: with no `~/.ssh/known_hosts` readable, the first connection to an SSH host confirms its fingerprint with the user; accepted fingerprints live app-side (02-architecture.md's app-wide state home, host-scoped). A later mismatch **hard-blocks with an explanation** — that mismatch is the attack the check exists for.
- **Setup verifies right there.** Adding or changing a remote (board popover — 03-board-ui.md) probes with authentication immediately (ls-remote): missing or rejected credentials surface **inline in the popover** — HTTPS shows username + token fields with a forge-appropriate hint; SSH shows the machine key to copy plus Verify. The user leaves the popover with a remote that demonstrably works, or knowingly not. Boards adopted from a terminal clone (whose auth lives outside the sandbox and can't be reused) hit the same inline flow at the first in-app operation that needs credentials.
- **Setup verifies right there.** Adding or changing a remote (board settings sheet — 03-board-ui.md, the 2026-07-31 popover/sheet split) probes with authentication immediately (ls-remote): missing or rejected credentials surface **inline in the sheet** — HTTPS shows username + token fields with a forge-appropriate hint; SSH shows the machine key to copy plus Verify. The user leaves the sheet with a remote that demonstrably works, or knowingly not. Boards adopted from a terminal clone (whose auth lives outside the sandbox and can't be reused) hit the same inline flow at the first in-app operation that needs credentials.
- **Auth failures pause; they never nag and never hammer.** A push or pull rejected for authentication (expired token, revoked key) is not retried — a dead credential cannot succeed, and hammering invites rate limits and lockouts. The push queue pauses and the popover badge switches to a distinct **Authentication needed** state carrying the error; the popover presents the same inline fields, prefilled where possible. Updating the credential (or fixing forge-side and hitting Verify) resumes the queue. Network failures keep the quiet auto-resume above — only auth pauses.
- **Auth failures pause; they never nag and never hammer.** A push or pull rejected for authentication (expired token, revoked key) is not retried — a dead credential cannot succeed, and hammering invites rate limits and lockouts. The push queue pauses and the popover badge switches to a distinct **Authentication needed** state carrying the error (the badge points at the settings sheet); the sheet presents the same inline fields, prefilled where possible. Updating the credential (or fixing forge-side and hitting Verify) resumes the queue. Network failures keep the quiet auto-resume above — only auth pauses.
- **Background operations never prompt.** Push-on-commit and the automatic fetch-rebase-push stay silent through auth trouble (badge only); credential capture happens exclusively in the popover, where the user already is when it matters (manual Pull/Push live there too).
- **Background operations never prompt.** Push-on-commit and the automatic fetch-rebase-push stay silent through auth trouble (badge only); credential capture happens exclusively in the settings sheet, one click behind the badge that says it's needed (manual Pull/Push stay in the popover — daily operations, the split's other half).
## 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.
@@ -11,24 +11,32 @@ AI agents are first-class users of Lanework boards — not through an API, but t
5.**Git trail** (06-history-undo.md) — on git-enabled boards, agent changes are auto-committed, attributed, and undoable like any others. Foreign changes are committed under a synthetic external author, structurally distinct from the user's own commits; an agent can claim its work lightly by stamping `modified-by` (guarantee 6 below), or precisely by committing its changes itself — the app follows along: a held `index.lock` just makes the auto-committer retry/re-debounce, and a tree the agent already committed is a silent no-op that preserves the agent's authorship (06-history-undo.md). The guide says so, and also tells agents to **stage only their own paths** (no `git add -A`) — a sweep would commit the user's not-yet-auto-committed changes under the agent's name.
5.**Git trail** (06-history-undo.md) — on git-enabled boards, agent changes are auto-committed, attributed, and undoable like any others. Foreign changes are committed under a synthetic external author, structurally distinct from the user's own commits; an agent can claim its work lightly by stamping `modified-by` (guarantee 6 below), or precisely by committing its changes itself — the app follows along: a held `index.lock` just makes the auto-committer retry/re-debounce, and a tree the agent already committed is a silent no-op that preserves the agent's authorship (06-history-undo.md). The guide says so, and also tells agents to **stage only their own paths** (no `git add -A`) — a sweep would commit the user's not-yet-auto-committed changes under the agent's name.
6.**`modified-by` self-stamping** (01-storage-format.md) — the lightweight attribution path: an agent stamps files it writes (`modified-by: <your-name>`), and the stamp renders on the card window's modified line and authors the agent's foreign commits (06-history-undo.md) — no git ceremony, and it works on no-git boards, where it is the *only* attribution there is. The app clears the stamp on its own writes, so agents should re-stamp on every write, not once — and on every *move*: a bare folder move rewrites nothing, so the moved card arrives unstamped and the commit falls back to the generic external author (06's fallback demotes on unstamped changed files and true deletions; a move is neither stamped nor a deletion until the agent re-stamps it).
6.**`modified-by` self-stamping** (01-storage-format.md) — the lightweight attribution path: an agent stamps files it writes (`modified-by: <your-name>`), and the stamp renders on the card window's modified line and authors the agent's foreign commits (06-history-undo.md) — no git ceremony, and it works on no-git boards, where it is the *only* attribution there is. The app clears the stamp on its own writes, so agents should re-stamp on every write, not once — and on every *move*: a bare folder move rewrites nothing, so the moved card arrives unstamped and the commit falls back to the generic external author (06's fallback demotes on unstamped changed files and true deletions; a move is neither stamped nor a deletion until the agent re-stamps it).
## The agent guide (`CLAUDE.md` at board root)
## The agent guide (`CLAUDE.md` at board root, twinned at `AGENTS.md`)
The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-facing rendition of the schema teaching any agent how to operate on the board directly:
The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-facing rendition of the schema teaching any agent how to operate on the board directly:
- Creating cards (mkdir UUID, write `index.md`, ordering rules) — plain UTF-8, no BOM, preserving each file's existing line endings (01-storage-format.md's encoding contract).
- Moving between lanes (folder move), reordering (gapped ranks, only touch the moved item).
- Moving between lanes (folder move), reordering (gapped ranks, only touch the moved item).
-Tombstone deletes, colors/icons.
-Deletes — move the card **or lane** folder into `<root>/.trash/` and restamp `modified` (the trash sorts newest-first by that stamp — re-ruled 2026-07-31, no rank to mint; never delete a folder outright unless permanence is meant); colors/icons.
-New in the rewrite: **attachments** (the `attachments/` convention, importing files).
-**`kind`** — common schema, written at creation of every object: include `kind: lane` / `kind: card` when creating anything (`kind: board` at board root), and stamp `kind: lane` when trashing a lane that lacks it. The *value* is what tells a trashed lane from a card inside the flat `.trash/` (01-storage-format.md ▸ Deletion); the app backfills a missing key on touch (01 ▸ Validation and healing), so omitting it is healable, never fatal.
-New in the rewrite: **`modified-by` self-stamping** — stamp files you write; re-stamp every write *and every move* (the app clears it on its own writes; a bare folder move leaves the card unstamped); self-commit instead when you need exact authorship.
-**Attachments** (the `attachments/` convention, importing files) — including the rule that **card files belong in `attachments/`**: a loose file written beside `index.md` will be relocated there by the app with a notice (01-storage-format.md's loose-file carve-out), so agents should put it there in the first place — and the card-level **`attachments` claimed name**: `attachments` inside a card folder is the app's (the card's file folder); never create a *file* by that name.
-New in the rewrite: a pointer to the optional **`CLAUDE.user.md`** (see below), instructing agents to read it when present.
-**`modified-by` self-stamping** — stamp files you write; re-stamp every write *and every move* (the app clears it on its own writes; a bare folder move leaves the card unstamped); self-commit instead when you need exact authorship.
- **The stamp discipline** of 01 ▸ `modified`'s scope: **reordering within a lane rewrites only `order`** — leave `modified` and `modified-by` alone — while **a move between lanes, between boards, or into/out of `.trash/` updates both** (stamp `modified`, re-stamp `modified-by`).
- **Comments** (`comments/` — 01-storage-format.md § Enhanced schema, shipped 2026-07-29): the folder shape (`comments/<uuid>/index.md`, no `title`/`order`), chronology as the ordering (sorts by `created` ascending — write real current UTC, distinct seconds for a burst), `author` as self-reported content that survives app writes (the deliberate contrast with `modified-by`, which the app clears), `comments/.draft` and `comments/.trash` as reserved names one level down the agent never writes into, and retraction by a follow-up comment rather than a rewrite. `comments/`**no longer appears on the reserved-tracker-keys list** below — it shipped, and the list now names only the fields tracker sync still reserves (`labels`, `assignees`, `due`, `remote`, `remote-state`).
- **Use the thread: journal your work** (added with the comments chapter, ruled 2026-08-09, card dc1314bb): the card's body is the spec, its comment thread is the journal. Starting a card gets a comment with the plan and every decision already made, written for a reader with none of the session's context; decisions get recorded as they're made, not reconstructed at the end; questions post as comments, since the app narrates arrivals live and a thread has to be re-read in full before a card is resumed; finishing gets a comment with verification evidence. Posting a comment never stamps the card's own `modified`/`modified-by`. Per-board process — lane cadence, commit conventions — stays `CLAUDE.user.md`'s, not the guide's.
- A pointer to the optional **`CLAUDE.user.md`** (see below), instructing agents to read it when present.
This list is **present-tense and normative for the current guide** (re-ruled 2026-07-30): it states what the shipped guide teaches *now*, and editing it means editing the guide literal in the same change. Per-version changelog bullets are deliberately gone — a hand-maintained version history beside the literal drifted within days of v6 (claiming teachings the shipped body lacked), the same failure class as README's hand-enumerated deferred list; version history lives in git.
Mechanics: version marker in the first line (`lanework-agent-guide vN`); rewritten when missing or older, never downgraded, left untouched when current or newer. To the loader it's just another ignored file.
Mechanics: version marker in the first line (`lanework-agent-guide vN`); rewritten when missing or older, never downgraded, left untouched when current or newer. To the loader it's just another ignored file.
**Ownership**: the board-root `CLAUDE.md` is **app-owned and not available for user editing** — its header says so, and user edits do not survive upgrades. This holds on every board, including repo-nested ones: the guide is auto-written there too (the untracked file in the user's repository is accepted — a board is agent-facing wherever it lives). A board-root `CLAUDE.md` found *without* the marker is displaced user content, not clobbered: its content is moved to `CLAUDE.user.md` if that name is free (otherwise the guide write is skipped with a log — user content is never destroyed), and the guide is then written.
**Ownership**: the board-root `CLAUDE.md` is **app-owned and not available for user editing** — its header says so, and user edits do not survive upgrades. This holds on every board, including repo-nested ones: the guide is auto-written there too (the untracked file in the user's repository is accepted — a board is agent-facing wherever it lives). A board-root `CLAUDE.md` found *without* the marker is displaced user content, not clobbered: its content is moved to `CLAUDE.user.md` if that name is free (otherwise the guide write is skipped with a log — user content is never destroyed), and the guide is then written.
**`CLAUDE.user.md`** is the user's extension point: an optional, user-authored file at board root carrying board-specific agent instructions. The generated guide tells agents to read it when present, so it lands in agent context without the app ever touching it — the app never writes, upgrades, or validates it.
**The `AGENTS.md` twin** (ruled 2026-08-09, card dc1314bb): the app writes the identical guide a second time, at `AGENTS.md` — the vendor-neutral filename most non-Claude coding agents read, alongside `CLAUDE.md` for Claude's own pickup. Same lifecycle as `CLAUDE.md` in every particular — seeded at board creation, rewritten on a version bump, left untouched when current, a markerless file on the name rescued and a squatter displaced exactly as `CLAUDE.md`'s own are — because each name gets the identical decision run against it independently, not a copy of the other's outcome. **A duplicate, deliberately not a symlink**: iCloud Drive's symlink handling is unreliable, and the two files are written from the one generator on every install, which makes drift structurally impossible rather than a discipline someone has to maintain. Both names join the claimed board-root list the Git section already tells agents to leave to the app.
On git boards, a guide write rides the normal watcher → auto-commit path with an honest message ("Update agent guide (v3)"), not "External edit" (06-history-undo.md).
**`CLAUDE.user.md`** is the user's extension point: an optional, user-authored file at board root carrying board-specific agent instructions. The generated guide tells agents to read it when present, so it lands in agent context without the app ever touching it — the app never writes, upgrades, or validates an *existing*`CLAUDE.user.md`; the one exception is its creation, once, to rescue displaced content (the markerless-`CLAUDE.md` relocation above, which only runs when the name is free — and which `AGENTS.md` shares: a markerless file on *either* claimed name rescues to this same single destination, never a second `AGENTS.user.md`).
On git boards, a guide write rides the normal watcher → auto-commit path with an honest message ("Update agent guide (v3)"), not "External edit" (06-history-undo.md ▸ Commit messages — the non-snapshot-files rule: the committer stages the whole root, and the composer reads the subject's version from the guide's marker line, a pure function of file content).
## Agent conventions worth specifying (new in the rewrite)
## Agent conventions worth specifying (new in the rewrite)
@@ -27,7 +27,7 @@ All ten pathfinder templates carry over: Basic, Classic Kanban, Software Project
### Instantiation
### Instantiation
Creating a board from a template: copy the tree — **skipping tombstoned items** (Save as Template already strips them, but hand-dropped user templates can carry them; a new board isn't born with trash) — **mint fresh GUIDs** for every lane/card folder, stamp `created`/`modified` fresh (the stated exception to 01-storage-format.md's copies-keep-`created` rule — a new board is born today, not forked from the template), seed the save panel's suggested name from the template title, and **set the new board's `title` to the user-chosen document name** (per 01-storage-format.md's board-naming rule, so display name and folder name start out matching). The `template:` key is kept — inert on an ordinary board. **`.git` is never copied** — a template is content, not history, and a hand-dropped user template that carries one must not produce boards that are silently in git mode (06-history-undo.md's no-silent-auto-init — the principle is *never give the user a repo they didn't ask for*, and it scopes to instantiation: File ▸ Duplicate deliberately carries `.git`, because a duplicate of a git board is a fork of its history — 03-board-ui.md): every instantiated board starts at mode `none`. Beyond the `template:` residue, the result is indistinguishable from a hand-built board.
Creating a board from a template: copy the tree — **skipping `.trash/`** (Save as Template already strips it, but hand-dropped user templates can carry one; a new board isn't born with trash) — **mint fresh GUIDs** for every lane/card folder, stamp `created`/`modified` fresh (the stated exception to 01-storage-format.md's copies-keep-`created` rule — a new board is born today, not forked from the template), seed the save panel's suggested name from the template title, and **set the new board's `title` to the user-chosen document name** (per 01-storage-format.md's board-naming rule, so display name and folder name start out matching). The `template:` key is kept — inert on an ordinary board. **`.git` is never copied** — a template is content, not history, and a hand-dropped user template that carries one must not produce boards that are silently in git mode (06-history-undo.md's no-silent-auto-init — the principle is *never give the user a repo they didn't ask for*, and it scopes to instantiation: File ▸ Duplicate deliberately carries `.git`, because a duplicate of a git board is a fork of its history — 03-board-ui.md): an instantiated board is never in *git* mode — its actual mode follows 06's nearest-`.git`-wins detection at the destination the save panel chose: mode `none` in a plain folder, repo-nested when saved inside an existing repository (no app-managed git, no add-git — the popover explains; native undo still serves, 06-history-undo.md). Beyond the `template:` residue, the result is indistinguishable from a hand-built board.
### Why this format
### Why this format
@@ -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. 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 the app-side store; 02-architecture.md ▸ Live-reload resilience has the settled scoping). Because a template *is* a board, the copy is nearly literal: **`.git` is not copied** (a template is content, not history — copying it would embed the board's full repo, every attachment version included, in the template store; see 06-history-undo.md's repo-growth note), `.trash/` is dropped, a `template:` key is added — or, when the board already carries one (e.g. it was itself instantiated from a template), its stale `order` is overwritten — with an order appended after existing user templates; GUIDs are left as-is (instantiation mints fresh ones anyway), and timestamps are kept per 01-storage-format.md's copies-keep-`created` rule (equally inert — instantiation restamps them).
Two edges, settled:
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`, `.trash/`) — `CLAUDE.user.md`, a seeded `.gitignore`, and other non-schema files carry through Save as Template *and* instantiation alike. Deliberate: a template is the folder, and `CLAUDE.user.md` carrying a board's custom agent instructions into boards born from it is a feature. The app-owned `CLAUDE.md` copies inertly and self-heals to the current guide version when the new board is opened (08-agent-integration.md).
**Storage (settled): Application Support** (`…/Lanework/Templates/`, inside the app container) as the canonical store — friction-free sandbox writes, no location ceremony — kept honest by a **Reveal in Finder** affordance in the template chooser: revealed, it's plain board folders, hand-editable and agent-writable, and a board folder dropped in becomes a template — **no `template:` key required**. Chooser order: bundled templates by `template.order`, then keyed user templates by `template.order`, then keyless user boards last, sorted by display name (`title` ?? folder name — 01-storage-format.md's board naming). 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 (re-homed 2026-07-30 with the one-app collapse): the app's Application Support container** (`…/Application Support/<bundle id>/Templates/`, beside the registry per 02-architecture.md ▸ Per-board app state's app-wide-state rule) as the canonical store — friction-free sandbox writes, no location ceremony. Templates are plain board folders — no bookmark grant ceremony, the container is the app's own. 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
@@ -57,4 +57,4 @@ Keeping the Swift-struct catalog (pathfinder approach). Simpler to ship, but it'
## Open questions
## Open questions
None currently — the storage location is settled (Application Support as the canonical store, kept honest by Reveal in Finder; a user-visible or configurable location was set aside as ceremony, revisit if template sharing becomes a real workflow).
None currently — the storage location is settled (the app's Application Support container as the canonical store, re-homed 2026-07-30 with the one-app collapse; kept honest by Reveal in Finder; a user-visible or configurable location was set aside as ceremony, revisit if template sharing becomes a real workflow).
@@ -10,47 +10,50 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
## The board through VoiceOver
## The board through VoiceOver
- **Tree shape**: window → lanes (accessibility containers, in lane `order`) → cards (leaf elements, in card `order`). A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like the visible badge (04-interactions.md). The lane header's new-card button is a labeled child ("New card in ⟨lane⟩"). A card is **one flattened element**: label = title (or the untitled placeholder), value carries the attachment count when present, selected state via trait. Face icon and chips are decorative — folded into the element, never separately focusable. The sole-selection **attachment carousel** (03-board-ui.md) is decorative too — page dots and paging included, nothing focusable: the flattened element already carries the attachment count in its value, and the accessible attachment surface is the card window's keyboard-native section (below).
- **Tree shape**: window → lanes (accessibility containers, in lane `order`) → cards (leaf elements, in card `order`). A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like the visible badge (04-interactions.md). The lane header's collapse chevron is a labeled child ("Collapse ⟨lane⟩"). A card is **one flattened element**: label = title (or the untitled placeholder), value carries the attachment count and the comment count when present (comments joined 2026-08-09, card e729e30a — 03-board-ui.md § Card face), selected state via trait. Face icon and chips are decorative — folded into the element, never separately focusable: the flattened element carries both counts in its value, the accessible attachment surface is the card window's keyboard-native section (below), and the accessible comment surface is the card window's comments column ("Comments, N" — Comments below); the face itself has no media presentation (03-board-ui.md's no-carousel resettlement).
- **Logical order, not masonry position** (decided): within a wide lane, VoiceOver reads cards by `order` — the interior grid columns are presentation only. This deliberately diverges from on-screen geometry; the spatial arrow-key model (04-interactions.md) remains available alongside, since board keyboard navigation keeps working with VoiceOver running.
- **Logical order, not masonry position** (decided): within a wide lane, VoiceOver reads cards by `order` — the interior grid columns are presentation only. This deliberately diverges from on-screen geometry (narrowed by the 2026-07-31 column-major masonry: walking down one column now *is* consecutive `order`; the divergence that remains is a geometry-sorted reading-order sweep — left-to-right, then down — which interleaves the columns); the spatial arrow-key model (04-interactions.md) remains available alongside, since board keyboard navigation keeps working with VoiceOver running.
- **VO cursor and app selection are independent** (Finder-style): moving the VoiceOver cursor never mutates selection. VO-Space on a card toggles its selection (the ⌘-click analogue — a toggle, never plain click's replace, 04-interactions.md ▸ Selection); ⌘↩ opens the card window; arrow keys and ⇧-arrows drive selection exactly as without VoiceOver. Selection state is always readable from the element (trait), and cut cards expose their dimmed pending state in the value ("cut, pending paste").
- **VO cursor and app selection are independent** (Finder-style): moving the VoiceOver cursor never mutates selection. VO-Space on any selectable element — card or lane header — toggles its selection (the ⌘-click analogue — a toggle, never plain click's replace, 04-interactions.md ▸ Selection; ruled 2026-07-29: a replace would silently wipe a multi-element selection, and one uniform VO-Space rule means the user never has to know the element kind to predict Space); ⌘↩ opens the card window; arrow keys and ⇧-arrows drive selection exactly as without VoiceOver. Selection state is always readable from the element (trait), and cut cards expose their dimmed pending state in the value ("cut, pending paste").
- **Actions come from the context menu.** Context menus are the single inventory of per-item actions (Open, Rename, Delete, Put Back, Delete Immediately, width stepper, …), reachable the standard way (VO-⇧-M); where SwiftUI additionally surfaces menu items as custom accessibility actions, that's free improvement, not a separate design surface.
- **Actions come from the context menu.** Context menus are the single inventory of per-item actions (Open, Rename, Delete, width stepper, …), reachable the standard way (VO-⇧-M); where SwiftUI additionally surfaces menu items as custom accessibility actions, that's free improvement, not a separate design surface.**The custom-action cut** (confirmed 2026-07-29): every plain button row of an item's context menu becomes a custom action; rows that open their own accessible surface (Style…'s popover) and non-action controls (the quick-style swatch picker) stay menu-only — the context menu remains the full inventory either way. Each action must call the same method as its menu row, so the two surfaces cannot drift.
- **Rotor**: lane titles are headings, so the headings rotor jumps lane-to-lane — on a one-dimensional board that *is* structural navigation; no custom rotors unless practice shows the need.
- **Rotor**: lane titles are headings, so the headings rotor jumps lane-to-lane — on a one-dimensional board that *is* structural navigation; no custom rotors unless practice shows the need.**The title doubling is accepted** (ruled 2026-07-29): entering a lane reads the container label ("Doing, lane, 3 cards") and then the heading ("Doing, heading") — two elements, two purposes: the label gives boundary-crossing context, the heading feeds the rotor. This is the platform-standard landmark-plus-heading pattern; collapsing it would cost the on-entry announcement, the more valuable half.
- **Trash quasi-lane**: when shown (View ▸ Show Trash), it is the last container, labeled as Trash with its count; toggling visibility is announced. Tombstoned cards read their deletion state and expose Put Back / Delete Immediately via the context menu; there is no Open (03-board-ui.md's no-editing-in-the-trash). A tombstoned lane's single entry reads "⟨title⟩, deleted lane, N cards" and exposes the same actions; keyboard reachability follows 04-interactions.md's homogeneous-by-kind trash rules.
- **Trash lane**: when shown (View ▸ Show Trash), it is the last container, labeled as Trash with its count; toggling visibility is announced — "Trash shown" / "Trash hidden" (confirmed 2026-07-29: resulting state, not the action, so a mis-hit tells the user where the board ended up and the toolbar toggle reads like the menu checkmark; the count stays on the container label, which VO reads on arrival, rather than duplicated into the announcement). Its cards are ordinary card elements (resettled 2026-07-28 — the materialized trash: no special states beyond the container) exposing Delete and Reveal in Finder via the context menu; there is no Open (03-board-ui.md's no-editing-in-the-trash), and restore is drag-out or the ⌘X/⌘V keyboard path; keyboard reachability follows 04-interactions.md ▸ The trash. A trashed **lane** (rejoined 2026-07-29) is one flattened opaque element — "⟨title⟩, deleted lane, N cards" — never a container: its cards are not in the tree (the opaque unit, 03 ▸ Trash), its actions are the same Delete / Reveal in Finder, and its restore paths are the same drag-out or ⌘X/⌘V.
- **Search**: filtered-out cards leave layout and the accessibility tree together — the filter is the single source of truth for "what's on the board" (04-interactions.md), and the tree is one of its readers. Lane counts announced reflect the filter.
- **Search**: filtered-out cards leave layout and the accessibility tree together — the filter is the single source of truth for "what's on the board" (04-interactions.md), and the tree is one of its readers. Lane counts announced reflect the filter.
## Moving without dragging
## Moving without dragging
- **Cards**: clipboard. ⌘X the selection, move selection to the destination (arrows), ⌘V — between lanes, within a lane (paste lands after the anchor card), and across boards (04-interactions.md's staged clipboard). This is the committed drag-free move story; it needs no VoiceOver-specific machinery because selection and paste targeting are already keyboard-native.
- **Cards**: clipboard. ⌘X the selection, move selection to the destination (arrows), ⌘V — between lanes, within a lane (paste lands after the anchor card), and across boards (04-interactions.md's staged clipboard). This is the committed drag-free move story; it needs no VoiceOver-specific machinery because selection and paste targeting are already keyboard-native.
- **Lanes**: the defect this doc originally named (lanes had no keyboard-move path) is closed by the keyboard map — with a lane selected, ⌘←/⌘→ move it (Board ▸ Move Left / Move Right, 04-interactions.md); cards gain ⌥⌘↑/⌥⌘↓ within-lane sorting, and cross lanes drag-free via cut/paste (04-interactions.md's clipboard rules). Lanes carry the clipboard too (resettled, 04-interactions.md ▸ Clipboard), so cross-board lane copy/move — once drag-only, the contract's last gap — is ⌘C/⌘X, then ⌘V with the destination board frontmost.
- **Lanes**: the defect this doc originally named (lanes had no keyboard-move path) is closed by the keyboard map — with a lane selected, ⌘←/⌘→ move it (Board ▸ Move Left / Move Right, 04-interactions.md); cards gain ⌥⌘↑/⌥⌘↓ within-lane sorting, and cross lanes drag-free via cut/paste (04-interactions.md's clipboard rules). Lanes carry the clipboard too (resettled, 04-interactions.md ▸ Clipboard), so cross-board lane copy/move — once drag-only, the contract's last gap — is ⌘C/⌘X, then ⌘V with the destination board frontmost.
- **Lane resize**: the header context menu's width stepper (03-board-ui.md) — and its keyboard face, the Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘←, 04-interactions.md) — is the accessible path; edge drag is enhancement only.
- **Lane resize**: the header context menu's width stepper (03-board-ui.md) — and its keyboard face, the Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘←, 11-command-nexus.md) — is the accessible path; edge drag is enhancement only.
- **Attachments**: the card window's sidebar items expose Open / Reveal in Finder / Remove via context menu, and the section is keyboard-navigable outright (arrows, Space-QuickLook, Return, ⌫ — 05-card-window.md); adding files drag-free is File ▸ Add Attachment… (⇧⌘A, 11-command-nexus.md) alongside Finder-drop.
- **Attachments**: the card window's sidebar items expose Open / Reveal in Finder / Remove via context menu, and the section is keyboard-navigable outright (arrows, Space-QuickLook, Return, ⌫ — 05-card-window.md); adding files drag-free is File ▸ Add Attachment… (⇧⌘A, 11-command-nexus.md) alongside Finder-drop.
- **Comments** (05-card-window.md ▸ The comments column): the pane is a labeled container ("Comments, N"); each comment is **one flattened element** — author, date, edited state, body — with its context-menu rows (Edit / Delete / Reveal in Finder) riding as custom actions per the cut; the composer is a labeled text field (⌘↩ posts) and the header's sort control is Tab-reachable. Foreign comment arrivals announce path-shaped ("New comment on '⟨card⟩'") — the window-scoped read never blocks the announcement, which composes from the path alone. **Edits and deletes speak the same family** (pinned 2026-07-31): "Edit comment on '⟨card⟩'" / "Delete comment on '⟨card⟩'" — 06-history-undo.md's path-shaped verb family verbatim, arrivals leading by precedence, plurals folding ("3 new comments on 'X'"); the exact strings live in AccessibilityPhrases, one home.
## Live board announcements
## Live board announcements
- **Foreign changes announce, app-mediated echoes never do.** The auto-committer already classifies every observed change as app-mediated or foreign and synthesizes diff summaries for commit messages (06-history-undo.md); announcements reuse that summarizer — one polite (non-interrupting) digest per reload debounce ("Board changed: 2 cards edited, 1 card added"), never per-file chatter. On no-git boards the same classifier runs without the committer — announcements don't depend on git mode.
- **Foreign changes announce, app-mediated echoes never do.** The auto-committer already classifies every observed change as app-mediated or foreign (the EchoLedger — 02-architecture.md ▸ Components) and synthesizes diff summaries for commit messages (06-history-undo.md); announcements reuse that summarizer — one polite (non-interrupting) digest per reload debounce ("Board changed: 2 cards edited, 1 card added"), never per-file chatter. **The announcer consumes the ledger's per-file facts on every reload origin, reconciling included** (ruled 2026-07-29 — the EchoLedger builds pre-release in base, not with Pro's committer): files changed during a blind window (sleep, deactivation) carry no receipts, so they classify foreign and announce — the launch-catch-up doctrine ("the app never vouches for changes it didn't witness") applied to speech; a reconciling reload that reveals external changes is never silent. On no-git boards the same classifier runs without the committer — announcements don't depend on git mode. **The digest covers the trash only while the trash lane is shown** (ruled 2026-07-29): with View ▸ Show Trash on, trash cards are ordinary elements of the visible board (▸ Trash lane above), so a foreign purge, restore, or Empty Trash joins the digest like any lane's churn — a user working in the shown trash must hear it emptied under them; while hidden, trash churn stays silent, matching "no layout side effects from a foreign edit". **A shown trashed-lane row's held count speaks when it changes** (ruled 2026-07-31): foreign churn *inside* a trashed lane's subtree is invisible to the snapshot, but the row's count is part of its visible face — a sighted user sees the number move, so the digest says it ("Deleted lane 'Doing' now holds 6 cards", plural-folded as usual), sourced from the disk-side held count. The opacity doctrine holds: the digest narrates the row's visible value, never the interior.
- **A vanishing focus is called out specifically.** If the selected or VO-focused card disappears in a reload (deleted externally, or hidden by a lane tombstone), the announcement names it ("Card 'Fix login' was deleted externally") and focus recovers to the card's lane (mirroring selection's reload-survival rules, 02-architecture.md).
- **A vanishing focus is called out specifically.** If the selected or VO-focused card disappears in a reload (deleted externally, or gone with its deleted lane), the announcement names it ("Card 'Fix login' was deleted externally") and focus recovers to the card's lane (mirroring selection's reload-survival rules, 02-architecture.md).**Naming and recovery are independent axes** (ruled 2026-07-29): when the head of a multi-selection vanishes but co-selected cards survive, the announcement still names the vanished head — the thing under the cursor was deleted, and that is what the rule exists to say — while the focus move is vetoed by the survivors (02's re-resolution rule: the head re-anchors within the surviving selection; a reload never edits a selection the user still partly holds). Vanished non-head members stay unnamed and fall to the digest's counts. **When the lane itself vanished, recovery walks up then sideways** (settled): focus lands on the lane now occupying the vanished lane's position — the next lane by `order`, else the previous one — and on the board container only when no lanes remain (the ⌫-successor pattern, 04-interactions.md, applied to external change; never into the trash, which stays hidden — no layout side effects from a foreign edit). The announcement then names the *lane*, not the card ("Lane 'Doing' was deleted externally, with 5 cards") — the implied-events-don't-steal-the-subject discipline of 06-history-undo.md's composer, applied to speech.
- **Bracketed operations announce once, at completion** ("Pulled 3 commits", "Switched to branch 'redesign'") — never their internal churn (02-architecture.md's bracketing). The live-reload-resilience banner (02-architecture.md) is an accessibility element and is announced when it appears and when it clears — including the read-only lock after a failed bracketed reload.
- **Bracketed operations announce once, at completion** ("Pulled 3 commits", "Switched to branch 'redesign'") — never their internal churn (02-architecture.md's bracketing). The live-reload-resilience banner (02-architecture.md) is an accessibility element and is announced when it appears and when it clears — including the read-only lock after a failed bracketed reload.**Banner transitions are origin-independent** (confirmed 2026-07-29): "app-mediated echoes never announce" governs the change digest — never narrate the user's own edits — but a banner appearing or clearing is surface liveness, visible to a sighted user regardless of cause, so it speaks on any reload origin (an app write whose reload clears a breakage is exactly a moment the user should hear "cleared"). Precedence ladder: raised condition > bracket completion > cleared condition > vanished focus > digest.
- **Card window**: standard controls, standard labels. The attributes sidebar is a labeled container of labeled sections; attachment rows are elements labeled by filename; the Details section's unknown-key rows read as static text ("⟨key⟩, ⟨value⟩"); the bottom actions are ordinary buttons. **Preview renders to the accessibility tree as structured text** — headings navigable by rotor, lists and tables read as such; task-list checkboxes are real accessible checkboxes, toggleable without the pointer (05-card-window.md's live checkboxes); body images use Markdown alt text when present, else the filename. Edit and the raw-source outlet are ordinary accessible text editors; the Preview/Edit toggle (⌘E) announces its state.
- **Card window**: standard controls, standard labels. The attributes sidebar is a labeled container of labeled sections; attachment rows are elements labeled by filename; the Details section's unknown-key rows read as static text ("⟨key⟩, ⟨value⟩"); the bottom actions are ordinary buttons. **Preview renders to the accessibility tree as structured text** — headings navigable by rotor, lists and tables read as such; task-list checkboxes are real accessible checkboxes, toggleable without the pointer (05-card-window.md's live checkboxes); body images use Markdown alt text when present, else the filename. Edit and the raw-source outlet are ordinary accessible text editors; the Preview/Edit toggle (⌘E) announces its state.
- **Welcome window**: recents rows are elements labeled "⟨name⟩, ⟨location⟩, N lanes, M cards" (registry-cached counts — 02-architecture.md); row actions (Open / Reveal in Finder / Forget) via context menu; unavailable rows say so ("unavailable — board not found").
- **Welcome window**: recents rows are elements labeled "⟨name⟩, ⟨location⟩, N lanes, M cards" (name, icon, and counts all registry-cached — 02-architecture.md; the row never reads a board's files); row actions (Open / Reveal in Finder / Forget) via context menu; unavailable rows say so ("unavailable — board not found").
- **Template chooser**: templates are elements labeled by title; the mini per-lane previews are decorative and hidden from the tree.
- **Template chooser**: templates are elements labeled by title; the mini per-lane previews are decorative and hidden from the tree.
- **Board popover**: labeled controls throughout; the ahead/behind indicator's information — counts, queued pushes, last error — must be readable as text, never conveyed by color or shape alone.
- **Board popover**: labeled controls throughout; the ahead/behind indicator's information — counts, queued pushes, last error — must be readable as text, never conveyed by color or shape alone.
- **Board settings sheet** — *retired 2026-08-07* (03 ▸ Board settings sheet; the 2026-07-31 popover/sheet split reversed). Its bar carries over to the popover's Git tab, which now hosts what it held: headers VoiceOver can navigate by (the Commit Identity block keeps its heading trait); labeled controls throughout; inline probe/verify outcomes announced from the focused surface; every control Tab-reachable under Full Keyboard Access. The original objection stands as the standing test — Tab-walking two dozen controls in an *untitled, unsectioned* surface fails this bar — and the tab strip plus per-block headings are what answer it now that the controls are back in the popover.
- **Style editor** (card sidebar section, board popover, Style… popover — 03-board-ui.md ▸ Styling ▸ Controls): grids are arrow-navigable, every well Tab-reachable and labeled by name (palette color, symbol name; leading wells "None" / "Default"); the current value is stated by trait, and a batch selection's mixed state reads as "mixed", never conveyed by highlight alone.
- **Style editor** (card sidebar section, board popover, Style… popover — 03-board-ui.md ▸ Styling ▸ Controls): grids are arrow-navigable, every well Tab-reachable and labeled by name (palette color, symbol name; leading wells "None" / "Default"); the current value is stated by trait, and a batch selection's mixed state reads as "mixed", never conveyed by highlight alone.
## Text scaling & visual accommodations
## Text scaling & visual accommodations
- **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.
- **Board zoom is that scaling's user-facing control** (settled 2026-08-02; behavior in 03-board-ui.md ▸ Layout, rows in 11-command-nexus.md). macOS ships no system text-size setting, so the commitment above had nothing to move it — the point size the whole board derives from is read once and never changes. View ▸ Zoom In / Zoom Out / Actual Size supply the multiplier: one ladder, one effective body size, and every em multiple and every text style scaling off it together. This is an accessibility feature before it is a convenience one, which is why it is a first-class menu command with a chord rather than a setting buried in a pane, and why **the strip's own truncation rules are the acceptance test** — 03's graceful-truncation promise "at every scale" is only checkable now that a scale exists. **A zoom change announces its new level** ("Zoom 125%") through the ordinary announcement path: it is chrome, not information — nothing about the board's meaning changes — so no label, value, or trait anywhere else moves with it.
- **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). The 2026-08-06 color-combo reversal (03 ▸ Styling ▸ Controls) changes none of this: a panel-picked color is stored as the palette name when it lands on one, else as hex, and either spelling renders through the same runtime ink seam — in-app picking gained the freedom hand-editing always had, and the ink math was already waiting for it. No warning surface exists or is owed; the app's answer to a low-contrast pick is to choose readable ink, not to argue. 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. Palette names and hex share one ink-selection code path — the palette's AA claim is pinned by a computed-contrast test over all 12 backgrounds in both appearances (ratified 2026-07-29). **The AA obligation binds the primary label tier** (ruled 2026-07-29): the ink seam moves the whole label hierarchy with the primary, and subordinate tiers (.secondary, .quaternary) inherit the system vocabulary's own contrast posture, which sits below 4.5:1 on any background including the system's — the platform-standard reading; the strict path for users who need more is Increase Contrast, which raises accents and washes to full alpha (▸ Visual accommodations). Increase Contrast strengthens borders and the selection indicator.
- **State is never color-alone**: selection is a ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled — all already patterned; kept as a rule.
- **State is never color-alone**: selection is a ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled — all already patterned; kept as a rule.
- **Reduce Motion**: reflow-on-drag, search animate-out, the drag replica, rubber-band feedback, and trash animations all get reduced variants (crossfade or instant). **Reduce Transparency**: glass underlays (carousel page dots) go solid.
- **Reduce Motion 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 go solid, wherever they appear.
- **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.**"Every control" is literal and includes banner-row buttons** (ruled 2026-07-29): a Dismiss or Cancel on a banner must be a Tab stop — FKA serves sighted keyboard-only users, to whom VO custom actions are invisible, and Cancel on an in-progress operation is exactly the control that cannot require a pointer. This coexists with the VoiceOver presentation (one combined row-sentence with Dismiss/Cancel as custom actions): the AX combine and the FKA focus loop are independent surfaces; the implementation may uncombine conditionally under FKA if the focus system requires it.
## Verification
## Verification
- **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`) runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome, template chooser, board popover.
- **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`) runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome, template chooser, board popover.*(Amended 2026-08-07: the board settings sheet retired and its controls rehomed into the popover's Git tab, so the inventory is one surface shorter; auditing that tab specifically — the popover opens on Info — is an open card, tracked with the manual pass in `KanbanUITests/AccessibilityVerification.md`.)***Pro surfaces audit through a fixture-gated tier override** (ruled 2026-08-06): the audit's every-surface claim reaches tier-gated UI (the settings sheet's Pro sections, remote/credentials, the card History section in its present state) via a launch flag honored **only when `UITestLaunch.isFixtureLaunch` is also set** — the fixture flag already redirects registry and app state to a scratch container and builds a throwaway board, so the override grants Pro on a disposable sandbox and never over real boards; a bare tier flag in the shipping binary would be a subscription bypass, and this one is not (a Terminal user gains a Pro-looking toy, not a working subscription). The weighed alternatives lose on the audit's own terms: a permanent manual-checklist carve-out would asterisk the every-surface claim, and a debug-build-only override would leave release builds unauditable. Surfaces whose reachable-state audit is genuinely partial pin what the free fixture can reach (the disabled settings row, the absent History section) *and* audit the full surface under the override — both states are shipping states, both audit.
- **A manual VoiceOver smoke script** lives with the test plan: create lane → create card → rename → cut/paste to another lane → external edit lands (announcement heard) → delete → Put Back → Empty Trash. Run per release; it is the canonical "does the board actually work blind" check.
- **A manual VoiceOver smoke script** lives with the test plan: create lane → create card → rename → cut/paste to another lane → external edit lands (announcement heard) → delete → restore from the trash (⌘X/⌘V) → Empty Trash. Run per release; it is the canonical "does the board actually work blind" check.
## Changes from Kanban
## Changes from Kanban
@@ -58,4 +61,4 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
## Open questions
## Open questions
None currently — the lane-move gap and the Add Attachment menu path are both closed by the keyboard map (04-interactions.md).
None currently — the lane-move gap and the Add Attachment menu path are both closed by the keyboard map (04-interactions.md's contract, inventoried in 11-command-nexus.md).
| **C** | Configuration control | Form-like controls (board popover, chooser, welcome); the keyboard path is reachability (Board Info ⌘I + Tab-reachable controls), not bindings — 04's configuration carve-out. |
| **C** | Configuration control | Form-like controls (board popover, chooser, welcome); the keyboard path is reachability (Board Info ⌘I + Tab-reachable controls), not bindings — 04's configuration carve-out.*(Container amended 2026-08-07: the board settings sheet and its Board ▸ Board Settings… path retired with the reversal of the 2026-07-31 popover/sheet split — 03 ▸ Board settings sheet.)* |
**Toolbar presence is a separate axis**, orthogonal to the classes: toolbar items mirror menu commands, their presence per window is user-customizable (Customize Toolbar — 03-board-ui.md ▸ Toolbar; defaults and catalogs live there), and a toolbar is never a function's only home. Labels match menu titles except Undo/Redo, whose toolbar labels stay static (03).
**Toolbar presence is a separate axis**, orthogonal to the classes: toolbar items mirror menu commands, their presence per window is user-customizable (Customize Toolbar — 03-board-ui.md ▸ Toolbar; defaults and catalogs live there), and a toolbar is never a function's only home. Labels match menu titles except Undo/Redo, whose toolbar labels stay static (03).
@@ -18,37 +18,56 @@ The single source of truth for **every command and action the app can perform**
| Menu | Command | Default | Context |
| 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 Card | ⌘N | Board window; disabled on a zero-lane board. Target rule: 04 |
| File | New Lane | ⇧⌘N | Board window |
| File | New Lane | ⇧⌘N | Board window |
| 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 | Import Board… (open panel → format detected, never user-picked — Obsidian Kanban frontmatter marker, `.csv`, lenient Markdown outline as the unfailable fallback → save panel places the fresh `.kanban`, which opens the ordinary way) | — (no default) | Everywhere, no board needed in front; 15-import-export.md |
| File | Export ▸ Obsidian Kanban Markdown… / Markdown Outline… / CSV… (three named rows, no format popup — menu titles are API here, and a title whose meaning lives in a popup would be one title standing for three commands; each row a save panel pointed at the right extension; omissions post a loss row, silent when lossless) | — (no defaults) | Board window; 15-import-export.md |
| 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 |
| File | Share… (the board, staged as a `.zip`, `NSSharingServicePicker` anchored to the board window's toolbar or its center) | — (no default) | Board window; design ruling 2026-08-09, card 72691b11 — a faithful copy like Duplicate's (`.git` the sole exclusion; attachments, comments and `.trash/` carried), never gated on the read-only lock (a share is a read, `Print…`'s own posture), disabled only while an inline title editor is focused |
| File | Reveal in Finder | — (no default) | Board window: the selection's folder(s), or the board root with nothing selected; card window: the card's folder — the selected attachment's file instead when the attachments section is focused; welcome: the selected recent's folder (disabled on unavailable rows) — the context-menu entry's required twin |
| File | Add Attachment… | ⇧⌘A | Card window |
| File | Add Attachment… | ⇧⌘A | Card window |
| File | Delete | ⌘⌫ | Board window, live selection (chord twin of Put Back — validation enables exactly one). Deliberately **not** extended to the card window: an enabled ⌘⌫ key equivalent would steal delete-to-line-start from the window's text surfaces, so there the card's delete is the sidebar Actions button (05) |
| File | Delete Card | — (no default, deliberately chord-less) | Card window (2026-08-09, card bcd3b323 — the retired sidebar Actions row's menu twin, required because its toolbar item needs one and ⌘⌫ stays reserved for the window's text surfaces; 05 ▸ Actions) |
| File | Put Back | ⌘⌫ | Board window, tombstoned selection (chord twin of Delete) |
| File | Set as Hero | — (no default) | Card window; the attachments section's selected row, when it is an image the card is not already using (2026-08-09 — 03 ▸ Card face ▸ Hero image). Twin of the row's own context entry; **two rows rather than one that renames itself**, since titles are the remapping key |
| File | Delete Immediately | ⌥⌘⌫ | Board window, tombstoned selection; confirmed on boards without git history (mode none / repo-nested), immediate on git boards — 03 ▸ Trash |
| File | Remove Hero | — (no default) | Card window; the selected row when it *is* the card's hero — the key goes, the file stays. See Set as Hero for why the pair is two rows |
| File | Add Comment | — (no default) | Card window (all tiers — 12); if Show Comments is off, turns it on (persisted, the same user choice) and focuses the composer — 05 ▸ The comments column |
| File | Delete | ⌘⌫ | Board window, any card or lane selection — staged by place (resettled 2026-07-28; lanes rejoined 2026-07-29): board cards and lanes move to `.trash/`, trash selections delete permanently (03's recoverability confirm — freight-counting for lanes). 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). **Delete Immediately (⌥⌘⌫) is deliberately absent** (removed 2026-07-30): permanence is only reachable inside the trash — 03 ▸ Trash |
| File | Print… | ⌘P | Board window: prints **the board** as a linear document — lanes in left-to-right order, each lane's cards top-to-bottom, never a graphical snapshot of the strip; the trash is unreachable (it is a sibling container of `lanes`, not a lane — 01 ▸ Deletion). Card window: prints **that card**. **The sandbox needs an entitlement to print at all** (owner-reported 2026-08-09, board window frontmost, row enabled): `Kanban.entitlements` carried no `com.apple.security.print`, so a sandboxed `NSPrintOperation` was denied with the system alert "This application does not support printing" regardless of which code path invoked it — the fix is the entitlement key, not app logic. **Also revised the same day, as hardening rather than as the fix**: the row is now **always enabled** — a print is a read, so neither the read-only lock nor the focused-editor rule ever closed it (Reveal in Finder's posture), and the row claims ⌘P unconditionally rather than disabling, because a disabled `Button` still owns its key equivalent — a window with neither scope (welcome, the template chooser, Settings, the restore-bootstrap window, a card window whose board hasn't joined) used to drop the unclaimed chord into AppKit's own nil-target print handling, whose stock failure happens to be the identical alert text by a wholly separate mechanism. Scope now resolves at the moment ⌘P is pressed instead: board, then card, then — with neither — a one-sentence app-drawn refusal ("Nothing to Print" / "Open a board or a card to print it."), the same voice the empty-document refusal already uses. Finder's own `printFiles` Apple Event (File ▸ Print… on a selected board, a drag onto a printer queue, a print-and-open service) is implemented rather than left unhandled — its own separate route to the same stock alert: `AppDelegate` loads the path headless through `BoardLoader` and either prints it or gives the identical one-sentence refusal. Options ride in a **print-panel accessory** with the system's live preview: which components (title, icon+labels line, rendered body, comments — off by default, oldest- or newest-first), page breaks (continuous / between lanes / between cards, real sheet boundaries), one base font face and size every other size derives from, and a toggleable header/footer (board title, print date, page numbers, custom line). The option sets persist app-side as **named print profiles** with a reserved "Last Used" pseudo-profile that auto-captures the most recent settings — `UserDefaults`, never board data (02 ▸ Per-board app state), the zoom level's own argument. **Page Setup… is deliberately absent**: the paper questions are answered in the print panel's own page-setup group, so a second dialog would be a second place to set one margin |
| File | Close | ⌘W | Any window; flushes per 02 ▸ Windows |
| File | Close | ⌘W | Any window; flushes per 02 ▸ Windows |
| Edit | Undo / Redo (M−) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards |
| Edit | Undo / Redo (M−) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards, during 06's abnormal-state pause (detached HEAD, in-progress merge/rebase), and under the read-only lock (02) |
| Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copy-out only (card and lane entries), ⌘X disabled (04 ▸ The trash); text editors: standard text clipboard |
| Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copies out and ⌘X/⌘V is the keyboard restore path (resettled 2026-07-28 — 04 ▸ The trash); paste never targets the trash; **⌘V's image-data branch** (2026-08-09 — 04 ▸ Clipboard) lands a pasteboard picture in the anchor card's `attachments/`, and in a card window in that window's card; text editors: standard text clipboard |
| Edit | Select All | ⌘A | Board: all visible live cards (filter-respecting); text editors: the text |
| Edit | Paste as Board Background | — (no default) | Board window; enabled with image data on the pasteboard and a writable board — no selection needed, since a backdrop has one value per board (2026-08-09 — 03 ▸ Styling ▸ Capabilities) |
| Edit | Select All | ⌘A | Board: all visible cards on the active board side (filter-respecting); on the active trash side it selects all visible trash rows, both kinds — the container boundary decides which "all" is meant, and trash selection is kind-blind (04 ▸ The trash); text editors: the text |
| Edit | Find Next / Find Previous | ⌘G / ⇧⌘G | Card window: the find bar's stepping; disabled in the board window — board search is a live filter, not a cursor. **Use Selection for Find (⌘E) is deliberately absent**: the chord belongs to View ▸ Edit Body, which outranks the text view's binding; a user who wants it back remaps Edit Body system-natively |
| Edit | Find Next / Find Previous | ⌘G / ⇧⌘G | Card window: the find bar's stepping — the rows enable only while the comments-thread find bar is up and step *that* bar; otherwise they disable and the chord falls through the responder chain to the focused text surface's own NSTextFinder stepping (pinned 2026-07-31 — routing by focus applied to find); disabled in the board window — board search is a live filter, not a cursor. **Use Selection for Find (⌘E) is deliberately absent**: the chord belongs to View ▸ Edit Body, which outranks the text view's binding; a user who wants it back remaps Edit Body system-natively |
| Board | Open Card | ⌘↩ | Board window, sole selected live card; during an inline title edit (placeholder or rename), commits it and opens — the one board command enabled mid-edit (04 ▸ Grammar) |
| Board | Open Card | ⌘↩ | Board window, sole selected live card; during an inline title edit (placeholder or rename), commits it and opens — the one board command enabled mid-edit (04 ▸ Grammar) |
| Board | Copy Link | — (no default) | Board window, sole selected live card; writes the card folder's `file://` URL and plain absolute path to the pasteboard, one write, two representations; disabled on multi-selections and wherever edit-shaped actions disable (design ruling 2026-08-09) |
| 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 trash cards |
| Board | Move Left / Move Right | ⌘← / ⌘→ | Lane selection only (one slot; never into the trash) — cards cross lanes by drag or Cut/Paste, not ⌘-arrows |
| 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); 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 | Zoom In | ⌘+ | Board window; steps the board's zoom one rung up the ladder (03 ▸ Layout — zoom). Disabled at the top rung, and while a drag session is in flight (a drag freezes geometry the level feeds — the dragged run's heights and the resting-layout cache) |
| View | Zoom Out | ⌘− | Board window; the twin, one rung down. Disabled at the bottom rung and under the same guard |
| View | Actual Size | ⌘0 | Board window; returns to 100%, where the strip renders exactly what it rendered before zoom existed. Disabled when already there, and under the same guard. The level is app-wide and persisted across restarts (the Show Comments precedent) — a zoom is a viewing comfort, not a property of any one board |
| 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 |
| View | Show Comments (checkmark toggle) | — (no default) | Card window; app-wide, persisted across restarts (re-ruled 2026-07-29 — no content-derived auto-show; 05 ▸ The comments column) |
| View | Show Sidebar (checkmark toggle) | — (no default) | Card window; app-wide, persisted, default on (2026-08-09 — 05 ▸ The attributes sidebar); twin of the card toolbar's `sidebar.right` item, the fourth default beside Edit Body / Raw Source / Add Attachment |
| View | Comments Beside Body (checkmark toggle) | — (no default) | Card window; checked = side-by-side (default), unchecked = body over comments; app-wide, persisted (05 ▸ Composition) |
| View | Raw Source (checkmark toggle; toggling off = Apply) | ⌥⌘E | Card window |
| 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 |
| View | History | — (no default) | Card window; focuses the sidebar History section (05); git boards only — section absent, item disabled on mode none / repo-nested |
| View | Appearance ▸ Auto | — (no default) | Everywhere (no board or card window needed); app-wide, persisted across restarts — follows the system appearance; radio-exclusive with Light/Dark, checkmark on the active one (03-board-ui.md ▸ Toolbar) |
| View | Appearance ▸ Light | — (no default) | Everywhere; app-wide, persisted across restarts; radio-exclusive with Auto/Dark |
| View | Appearance ▸ Dark | — (no default) | Everywhere; app-wide, persisted across restarts; radio-exclusive with Auto/Light |
| Window | Welcome to Lanework | — (no default) | Everywhere; shows (or focuses) the welcome window (02 ▸ Launch and window lifecycle) |
## Fixed grammar keys (G)
## Fixed grammar keys (G)
@@ -61,10 +80,14 @@ All board grammar keys are inert while a title editor is focused, and menu dispa
| Return | Board, lane selected | Create card at its bottom (placeholder; Return commits and re-selects the lane) |
| Return | Board, lane selected | Create card at its bottom (placeholder; Return commits and re-selects the lane) |
| Return | Board, sole selected card | Inline rename; committing empty removes `title`; inert on multi-card selections |
| Return | Board, sole selected card | Inline rename; committing empty removes `title`; inert on multi-card selections |
| Escape | Board window | One layer per press: abandon editor, else clear search (focus to board), else deselect |
| Escape | Board window | One layer per press: abandon editor, else clear search (focus to board), else deselect |
| ⌫ | Board, live selection | Tombstone — plain-key synonym of File ▸ Delete, kept grammar so no second "Delete" title exists (04) |
| ⌫ | Board, any selection | Delete, staged by place — plain-key synonym of File ▸ Delete (board side moves to `.trash/`, trash side deletes permanently with 03's confirm), kept grammar so no second "Delete" title exists (04) |
| Return | Card window, title field | Commit title, focus into body |
| Return | Card window, title field | Commit title, focus into body |
| ⌘↩ | Card window, comment composer or inline comment editor focused | Post the draft (rename + restamp, one commit) / end the edit session at its commit point — 05 ▸ The comments column; twinned by the Comment / Save buttons |
| Escape | Card window, comment composer focused | Focus moves out, draft file untouched — Escape never discards a draft (ruled 2026-07-29; 05 ▸ The comments column) |
| Escape | Card window, inline comment editor focused | Cancel — revert to session-start bytes and end the session, the Cancel button's keyboard twin (05) |
| Escape | Card window, title field | Abandon: revert to the on-disk title, focus into body — routes by focus, winning over Edit-mode's Escape while the field is focused (05) |
| Escape / ⌘↩ | Card window, source mode | Cancel / Apply (leaving-by-toggle is Apply too — 05) |
| Escape / ⌘↩ | Card window, source mode | Cancel / Apply (leaving-by-toggle is Apply too — 05) |
| Arrows / Space / Return / ⌫ | Card window, attachments section focused | Row navigation / QuickLook / open / Remove to *system* Trash (05) |
| Arrows / Space / Return / ⌫ | Card window, attachments section focused | Row navigation / QuickLook / open / Remove to *system* Trash (05) |
@@ -74,31 +97,35 @@ All board grammar keys are inert while a title editor is focused, and menu dispa
- **Drag & drop — the locality model** (04): within-board move / cross-board copy; **⌥ always forces copy, ⌘ always forces move**; multi-drag; lane header is the lane drag surface; drag-to-restore from the trash.
- **Drag & drop — the locality model** (04): within-board move / cross-board copy; **⌥ always forces copy, ⌘ always forces move**; multi-drag; lane header is the lane drag surface; drag-to-restore from the trash.
- **Lane edge drag** (03 ▸ Lane): window-growing resize between integer widths — the one width control that moves the window.
- **Lane edge drag** (03 ▸ Lane): window-growing resize between integer widths — the one width control that moves the window.
- **Finder file drops** (04): onto a card = attach; onto lane empty space = one card per file; anywhere on the card window = attach (05's payload-split precedence).
- **Finder file drops** (04): onto a card = attach; onto lane empty space = one card per file; anywhere on the card window = attach (05's payload-split precedence).
- **Preview** (05): task-list checkbox toggle (the one interactive exception), link opens, text selection; carousel paging on the card face (03).
- **Preview** (05): task-list checkbox toggle (the one interactive exception), link opens, text selection. **In-content controls aren't P-only** (settled): checkboxes and links are real controls in the focus/accessibility tree, so Full Keyboard Access + Space and VO-Space reach them (05 ▸ Task-list checkboxes) — content rides the system focus model rather than earning command rows; "a command absent here doesn't exist" scopes to commands, not content.
- **The card window's attachments-section quiet add affordance** (05): a pointer twin of File ▸ Add Attachment…, no separate behavior. (The lane header once carried a matching "+" new-card button with its own click-names-target carve-out over 04's ⌘N rule; it is removed — every creation path now goes through the selection-derived rule, Return on a selected lane, or empty-space double-click.)
- **Attachment rows** drag out their file URL (05). **Welcome rows**: single click selects, double click opens (03).
- **Attachment rows** drag out their file URL (05). **Welcome rows**: single click selects, double click opens (03).
## Context menus
## 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 |
| Surface | Entries |
|---|---|
|---|---|
| Card / lane | Open (cards), Rename, Style…, quick-style recents row (03), Delete |
| Card | Four groups, separators between (reshaped 2026-08-09, Pipeline card fe66c461): **1** Open, Copy Link (kept — same-day feature, 2026-08-09), Rename, Style ▸ Symbol / Color (both open the Style… popover; no per-section focus yet — flagged); **2** Copy, Cut, Paste, Paste Special ▸ Paste Image into Card (enabled when the pasteboard carries an image); **3** Navigation ▸ Move Left / Move Right (**live since 085a84a, 2026-08-09** — a real per-card cross-lane move: clicked card widened to the selection Copy/Cut-style, index-preserving landing in the adjacent live lane through the drop's own rank machinery, disabled on lane-spanning selections, enablement via cheap per-lane compared parameters); **4** Send to Trash (the board-side word for the staged `.trash/` move — "Delete" stays the trash lane's). Amended same day (cards 28c79ffe/a4462d28): group 1 gains **Labels ▸** (twelve frequency-then-recency ranked checkmark toggles for the clicked card, then More… — a dialog over all used labels + creation) and Copy Link moved from group 1 into a new **Copy Special ▸** submenu in group 2, mirroring Paste Special. The quick-style recents row left this menu in the reshape (flagged for review; the ⌥-alternate Delete Immediately row had already retired with its command, 2026-07-30) |
| Trash entries | Put Back, Delete Immediately, Reveal in Finder (inspection before a purge; twin of File ▸ Reveal in Finder, which is not edit-shaped and stays enabled on tombstoned selections — 04 ▸ The trash) |
| Lane | Five groups, separators between (reshaped 2026-08-09, Pipeline card 22f660b0; invocation unchanged — header or lane empty space): **1** Rename, Properties… (deliberately inert placeholder for a future pane), Style ▸ Symbol / Color (one shared popover, the card menu's v1 posture); **2** Copy, Cut, Paste (Copy/Cut widen to the clicked lane — the card menu's explicit-target twins; Paste stays generic); **3** Width ▸ Increase / Decrease / Reset (Reset = width 1 through the same write, key removed at default, disabled when already there); **4** Collapse Lane / Expand Lane (title-flip row); **5** Send to Trash (relabeled from "Delete"; lanes rejoined the trash 2026-07-29). Quick-style recents row dropped for symmetry with the card menu (flagged for review) |
| Lane header | Width control (stepper — menu twins Increase/Decrease Lane Width) |
| Trash selection | Delete (permanent — 03's recoverability confirm), Reveal in Finder (inspection before a purge; twin of File ▸ Reveal in Finder, not edit-shaped, enabled on trash selections — 04 ▸ The trash) |
| Attachment row | Open, Remove (system Trash) — twins of the focused section's grammar keys (Return / ⌫ — 05); Reveal in Finder — twin of File ▸ Reveal in Finder in its attachments-focused context |
| Attachment row | Open, Remove (system Trash) — twins of the focused section's grammar keys (Return / ⌫ — 05); Reveal in Finder — twin of File ▸ Reveal in Finder in its attachments-focused context; **Set as Hero / Remove Hero** (2026-08-09 — 03 ▸ Card face ▸ Hero image) — one slot with two words, present on image rows only and absent rather than greyed where neither applies; twins of the two File rows below, which address the section's *selected* row |
| Comment | Edit (inline session — 05 ▸ The comments column), Delete (immediate, undoable — 01), Reveal in Finder |
- **Board popover** (Board Info ⌘I — 03 ▸ Board popover; **the board's one configuration surface** since the 2026-08-07 reversal of the 2026-07-31 popover/sheet split): board rename and the board glyph; the Theme tab's preset backgrounds; the Info tab's dossier; and the Git tab — posture lines (repo-nested explanation, unreadable/paused states — 06); branch display and switch picker with its New Branch… reveal; add-git (mode none, 06); commit-identity name/email (06); ahead/behind with Pull/Push buttons and the status badges.
- **Board settings sheet** — *retired 2026-08-07* (03 ▸ Board settings sheet). Its contents rehomed into the popover above, and Board ▸ Board Settings… left this document's menu inventory with it. 07-sync-collab.md's own surfaces — add/change remote with inline verify, 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), the Authentication-needed capture and TOFU confirms, the push-on-commit toggle — need a home ruled on that card; they were inventoried here as the sheet's and are homeless until then.
- **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.
- **Comments header** (05 ▸ The comments column): the sort-direction control (ascending/descending, app-wide persisted) — Tab-reachable beside the count.
## Standard macOS furniture
## 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. **Print is the app's own row now** (added 2026-08-09, retiring "No Print story in v1 (⌘P unused)"): File ▸ Print… ⌘P above replaces the platform's nil-target item outright, for Undo/Redo's reason — the standard row resolves through the responder chain and this app's print target is the document behind the focused *window*, which no responder vends. Customize Toolbar… per system convention (03). **Window tabbing stays enabled** (settled): the system's Show Tab Bar / tab items appear with their standard chords — ⇧⌘T is the system's, which is why Show Trash ships without a default (Menu commands above); tabbed board windows are ordinary system behavior, each tab still a full board window (a tab's saved per-board frame applies when it stands alone — 02-architecture.md).
Lanework ships as **one Mac App Store app** — `dev.rzen.indie.Kanban`, free, 2.0 updating the existing record — built from one codebase and one on-disk format, with **Lanework Pro as an auto-renewable subscription** unlocking the git tier. This doc owns the tier axis: what each tier is, how the gate is engineered (the provider seam, the entitlement), and which features land where. Individual docs stay tier-agnostic where they can — they conditionalize on **board mode** (none / git / git+remote — 07-sync-collab.md), and this doc defines which modes each tier ships.
**PIVOT 2026-08-07 — git leaves the paywall.** Git integration — detection, adoption, auto-commit, git-backed undo/history, branches (06-history-undo.md), and remotes/auth when they ship (07-sync-collab.md) — is **tier-independent**: every tier composes the git stack on git-mode boards exactly as Pro did. The base/Pro feature split is being re-decided, and git isn't going to be it. Until the new split is ruled: the subscription **machinery stays built and tested but dormant** — the entitlement's mechanics (local read, composition-time, offline grace, the recorded session tier) are unchanged and correct for whatever the next split gates, but the Settings Pro section is not rendered and no surface names or sells Pro. The free-only git postures are **retired**: the inert-`.git` stance and the popover's Pro pointer describe a gate that no longer exists — a `.git` at a board root is live in every tier, detection runs at every board open, and every board carries the popover's Git tab (the mode-driven postures: no-repository door, repo-nested, unverifiable, branch). What the pivot does **not** change: git stays **opt-in per board** (06 — creating a local repository is the user's deliberate choice, never auto-initialized), and a board without app-managed git binds conventional native undo/redo (13) exactly as before — the provider still follows the board. The sections below describing the git gate (the tier matrix's git rows, the inert posture, no-grandfathering) stand as record of the pre-pivot design and are not restated; read them through this note.
**PIVOT 2026-08-08 (second, same day) — the tier axis is mooted; one version.** User-ruled after the excision below: the base/Pro split leaves the product for now — Lanework ships as a single version, everything free, and the subscription machinery this doc engineered goes from dormant to **excised** (`Kanban/Tier/` — Tier, ProEntitlement, ProProducts, ProStorefront, the unrendered ProSettingsSection — with TierTests, `Configuration.storekit`, and its project.yml/scheme wiring; the network-client entitlement stays, the future sync service needs it regardless). Pro returns later, alongside the iPhone companion and the ops-based sync capability, as a **fresh design pass with its own doc** — a reintroduction, not a resurrection of this doc's matrix; the entitlement mechanics recorded below (local read, composition-time, offline grace) remain the reference for that pass. The provider-seam rationale survives independently (13-native-undo.md carries it now). Everything below stands as record.
**PIVOT 2026-08-08 — git excised entirely** (strategy/01-git-excision.md, the second step past the 2026-08-07 pivot): the git stack the first pivot made tier-independent is now removed from the app — engine, UI surfaces, seam bindings, SwiftGitX/libgit2 — with the message engine harvested to `Kanban/Changes/` as the future activity-feed narrator and the pre-excision state tagged `pre-git-excision`. The format's git-friendliness (per-card files, stable UUIDs, byte-fidelity, the seeded `.gitignore`) is the surviving promise, free forever; boards inside user repositories stay first-class and untouched. The tier machinery stays dormant exactly as the first pivot left it; the next split is expected to be "your board beyond your Mac" — the iPhone companion plus the ops-based sync service (the strategy workstream's charter) — and is not ruled by this note. 06 and 14 are retired, 07 is retired as written (06's Undo routing migrated to 13, now the sole substrate's doc); read this doc's git rows, modes, and matrix as record.
**Re-ruled 2026-07-30 — the one-app collapse.** This supersedes the 2026-07-27 two-app split (separate base and Pro targets) and the 2026-07-29 App Group ruling that served it. The split's compile-time purity (base never links libgit2, no network entitlement) dragged permanent coexistence machinery behind it: a shared App Group, per-edition grant slots (security-scoped bookmarks never cross sandboxes), registry freshness stamping between two live processes, UTI-ownership twins, a both-apps-installed rulebook — a tax on every layer that generated a steady stream of design findings, all serving a state (two sandboxed apps sharing app-side state) that existed only because the packaging created it. One app makes that state unrepresentable. Costs accepted with eyes open: libgit2 rides dormant in the free download, and the one app declares the network-client entitlement (exercised only under Pro) — the "free app provably has no network access" story is traded for "no network use until you subscribe," which is honest but weaker.
## The tiers
- **Lanework** (free) — 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, comments (when they ship), agents, accessibility.
- **Lanework Pro** (subscription) — 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** — tracker integration over the reserved enhanced schema (`remote`/`remote-state`, tracker-*synced* comment threads — comments themselves ship in every tier). **Deferred** — no design pass; probably a separate app when it comes. Whatever shape it takes, it will **never share an app group or any cross-app state** with Lanework (ruled 2026-07-30) — files are the only interchange this family recognizes.
The strategic reason for the seam stands unchanged (settled): Teams' card sync must be **backend-agnostic** — it has to work over git and over a range of trackers — so history and sync sit behind a genuine provider seam. The free tier's native undo is the first proof the seam is real: two working history providers before a third arrives.
## Distribution (re-ruled 2026-07-30)
One record: `dev.rzen.indie.Kanban`, free, all territories, 2.0 as an update — the 1.x listing simply grows the subscription. The `.kanban` package UTI (`dev.rzen.indie.kanban-board`) is declared and exported once, by the one app — no ownership twins, no default-claim choreography. **Lanework Pro is an auto-renewable subscription** (StoreKit 2), purchased and managed in a **Pro section of Settings (⌘,)** — subscribe, manage, restore purchases. Teams' eventual monetization is deferred with Teams. **2.0 ships only when both tiers are ready** (ruled 2026-07-31 — RELEASE.md): the Settings Pro section never faces a store without its product, so its unreachable state is only ever a true sentence.
**No grandfathering** (ruled 2026-07-30): 1.x shipped git-backed undo free; 2.0's free tier is native undo over the inert-`.git` posture (below). Existing users' boards keep working untouched, their histories stay intact and inspectable in any git client — the app just stops *extending* them until Pro is subscribed, and git resumes exactly where it left off (the committer's whole-root staging collapses the gap into one catch-up commit). No receipt-date logic exists.
## The entitlement (ruled 2026-07-30)
- **A local read, never a network call.** Pro state is read from StoreKit's signed on-device transaction store at **board-session composition** — the open path gains no network dependency (02-architecture.md's hang-avoidance doctrine extends here). Offline with an active subscription is indistinguishable from online.
- **Subscribe takes effect at each board's next open** — the provider binding is a composition-time fact, the design the seam was built for. The purchase flow offers to reopen open boards so the upgrade feels immediate.
- **A lapse never interrupts an open session**: an open board finishes with the provider it composed; the next open composes the native stack over inert `.git`. Unsubscribed and lapsed are **one state** — the inert posture, nothing lost, histories frozen not forfeited.
- **Offline grace resolves toward the paying user**: an on-disk expiry passing while offline, with the last known state *active and auto-renew on*, holds the entitlement until StoreKit actually refreshes and answers. A cancellation (auto-renew off) lapses at expiry, offline or not. Either wrong-for-a-window direction costs nothing: a wrong lapse pauses auto-commits into one catch-up commit; a wrong hold gives away days of local commits — Apple's own billing grace makes the same trade.
- **A fresh install that has never been online** has no cached transactions and reads as the free tier until the first refresh — honest and self-correcting.
## The provider seam
History (and later sync) is a provider behind one protocol boundary, bound per board session at composition from the entitlement:
- **HistoryProviding** — the undo/redo substrate. **The provider follows the board** (re-ruled 2026-07-31): gitless boards bind the native undo stack (13-native-undo.md: NSUndoManager over inverse `WriteOperation`s) in every tier — an upgrade never removes undo — while Pro binds the git provider on git boards (06-history-undo.md: undo as forward restore commits over HEAD's first-parent ancestry); repo-nested boards bind native too (re-ruled 2026-07-31 — the stack touches no git, so what matters is the absence of *app-managed* git, and the upgrade story is exceptionless). Teams inherits Pro's. Add-git swaps native → git mid-session by the branch-switch discard-and-reseed precedent (13).
- **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 tier-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 subscribing (or lapsing) relearns nothing.
## The free tier and `.git` — the inert posture (settled; now also the lapsed posture)
The free tier generalizes the repo-nested stance to every `.git` it meets: **any `.git` is inert**. Opening a board that has one (a formerly-subscribed user's board, a 1.x 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 the free tier, `.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 free-tier session leaves behind — a free-tier edit is just a foreign change to the git provider's next composition. The watcher's `.git` event filtering is unconditional (it exists to ignore git churn, which lapsed-and-resumed boards will produce).
The free tier's popover git slot (03-board-ui.md ▸ Board popover) does not offer add-git. **Its posture is contextual** (settled — ruled 2026-07-27, carried through the collapse): 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; it is also the one in-context pointer to Settings' Pro section. The card window's absent History section follows the same pattern: absent, no placeholder.
## Tier matrix
The feature sort. Everything not listed rides with "board experience" and is identical everywhere.
Docs 06 and 07 are **Pro-tier docs**; every other doc applies to all tiers, with mode-conditioned passages (undo availability, popover git surface, the permanent-delete confirmation branch) resolving per the modes the tier ships. The free tier ships exactly one mode: `none` (with the inert-`.git` posture above); Pro ships the full state machine.
## The target (re-ruled 2026-07-30)
**One app target.** The 2026-07-27 target split retires wholesale: the `KanbanPro` target, scheme, bundle id, module-alias test arrangement, `verify-editions.sh`, and the edition-twin files (EditionAbout, EditionTypes — Info.plist-posture twins existed only because two bundles claimed different ownership) all come out; the UTI is exported once. libgit2 links into the one target when the git provider is built (pro-m1) — dormant code behind the entitlement gate, not a second binary. Entitlements: the current minimal set plus `network-client` (exercised only under Pro; Keychain needs no access group — groups exist for sharing across apps). The pro-m1/pro-m2 milestones are unchanged in content — the git HistoryProvider and remote sync, built behind the seam — they now compile into the one target and activate by subscription.
## App-side state (re-ruled 2026-07-30)
One sandbox: the board registry and its Application Support peers (02-architecture.md ▸ Per-board app state) home in the app's **ordinary sandbox container** — the App Group is removed wholesale, superseding the 2026-07-29 group ruling. No group entitlement, no per-edition grant slots (one bookmark per record), no per-edition open-now flags (one flag), no cross-process freshness stamping (one process — macOS apps are single-instance), no "Also open in…" awareness line, no group-id provisioning risk. The clipboard staging store, template store, and scalar defaults follow the same collapse.
## Tier naming in the free app (settled — ruled 2026-07-27, carried through the collapse)
**Quiet signposts.** The free tier presents as a complete app, not a demo: Pro is named in exactly three places — one line in the About box, the contextual popover line on `.git` boards (above), and the Settings Pro section where the subscription actually lives. Nothing on the welcome screen, nothing in banners. The iCloud/network-volume warning (07-sync-collab.md) is written for the free tier without a git recommendation — it warns on its own merits (eviction, silent forks) and recommends a local folder; no Pro pitch in a warning (a warning that sells reads as manufactured).
## Open questions
None currently — the one-app collapse, subscription shape, entitlement semantics, App Group removal, and no-grandfathering were ruled 2026-07-30; the popover slot posture and quiet signposts carry from 2026-07-27.
The undo/redo substrate for **every board** (amended 2026-08-08 — strategy/01-git-excision.md: app-managed git is excised, and with it the git provider this doc used to hand off to; the earlier text here described the mode split and the add-git mid-session swap, both gone with the stack). The provider seam survives the excision deliberately — `HistoryProviding` is nil-safe and default-native (01-git-excision.md ▸ Reversibility) — so a future provider (the deferred foreign-change journal, an ops-backed history, or even git again) re-binds without re-plumbing; until one exists, this stack is the only substrate any board binds, repo-nested and user-repo boards included (this stack never touches git — memory-only, journal-free, session-scoped — so leave-strictly-alone holds by construction). 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
- **Two levels: one stack per board, one per open card window** (re-ruled 2026-07-31 — the session-coarsening model, superseding the pure one-stack rule): the **board stack** is owned by the board session and shared by board surfaces; a **card window owns its own stack** for the session it represents — every gesture issued in that window (comment post/delete/edit, body Edit sessions, style/details changes, attachment ops where undoable) registers there at fine grain, and the window's command surface answers with it (app-owned rows and toolbar targets — the command-surface rule below, re-ruled 2026-08-08; the delegate-vended `window.undoManager` premise this sentence used to state fell to the SwiftUI latch recorded there). Disk stays live throughout — files-first untouched; this is history granularity only. **Window close coarsens**: the session's net effect registers on the board stack as **one coarse step named "Changes to '⟨card⟩'"** (ruled 2026-07-31 — the board row reads "Undo Changes to 'Fix login'": plural and scope-flavored, distinct from every fine verb, honest about folding many kinds; the fine body-edit wording never leaks onto the board menu), values-based, whose undo restores the card subtree to its session-start state — deleted comments included — and whose redo reapplies the net effect; a session with no net change registers nothing. The coarse step is transactional at apply time: staleness validation runs per component (the field-level predicate below), and any stale component skips the whole step — never a partial session revert. **Session steps anchor by card identity, never by path** (ruled 2026-07-31): the coarse step — and the window's fine steps it folds — stores the card's UUID plus expected values, and apply-time validation resolves the card's *current* folder exactly the way the window itself always resolves its card (the per-snapshot UUID walk; `writeCardBody` already resolves trash locations on purpose). A tracked relocation — a lane move mid-session or after close, a trash move — therefore never stales the step; only genuine content changes do, which is what the validation exists to catch. A card that resolves nowhere (purged, or moved out of the board) is the honest skip. 06 ▸ Undo routing applies unchanged — text-editing surfaces get their session-scoped text undo above either stack. **Two stacks over one open card are the blessed shape** (2026-07-31): a board-issued gesture on a card whose window is open registers on the board stack while the window's own gestures register on the window stack — no ordering relation between the two, interleaving decided by ⌘Z focus (06 ▸ Undo routing); routing board gestures into the open window's stack was considered and rejected, since board ⌘Z must never see card-session steps.
- **Registration at the Writer boundary.** Every app-mediated mutation already passes through the Writer as a `WriteOperation` (02-architecture.md) — that closed enum is the exact inventory of undoable operations. Each Writer call site registers the inverse operation, computed from the pre-write snapshot the store already holds: move → move back (original lane, original `order`); reorder → restore original `order`; rename → restore title; restyle → restore prior style; resize → restore prior width; Edit-session body save → restore prior body bytes; card or lane delete (⌫) → move back out of `.trash/` (lanes rejoined the trash 2026-07-29 — the recreate-from-capture inverse retires with the last destructive delete); restore-by-move → move back in; create → remove the created folder.
- **What is not undoable** (settled): **Permanently delete** (the trash's Delete, 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. **The duplicate-id remint** (01-storage-format.md — a silent scheduled heal since 2026-07-29, formerly the user-gated Repair) — heals aren't user gestures, so nothing enters the stack, and undoing one would recreate the duplicate id it exists to remove. Permanently delete matches its existing "destructive, confirmed, final" posture; the remint sits outside undo as all heals do. **Raw Source Apply** (blessed 2026-07-31): the hatch writes byte-for-byte outside every contract — no `modified` stamp, no attribution clear, and no history step at either level; an Apply-only session folds to no coarse step, and an Apply mixed into a session is invisible to the fold. The hatch's story is "you edited the file," and files-are-truth covers it (05-card-window.md's carve-outs are the same statement from the stamping side).
- **Coalescing follows commit granularity** (settled; window scoping added 2026-07-31): 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) **on the card window's stack**, like every window gesture; the window close registers the one coarse session step on the board stack (Rules above); 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. (The excised git substrate's survive-relaunch property went with it; persistence is a future provider's to offer through the same seam.)
- **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, whose designated successor is the deferred foreign-change journal (strategy/01-git-excision.md ▸ Successors). 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 disk — a fresh read of the target at ⌘Z time (blessed 2026-07-29: not the store snapshot, which is by construction one reload behind the app's own writes; a rapid ⌘Z run validated against the snapshot would compare pre-state and false-skip every step). **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. **Delete steps validate their undo by existence only** (blessed 2026-07-31): a delete's forward write sets nothing but the `modified` stamp (the arrival rank mint retired 2026-07-31), and a clock reading is not a choosable after-value — pinning it would false-skip the restore whenever an agent touched the trashed card; the undo therefore expects only that the trashed folder still exists, while the redo stays field-level via the restore's `order` 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.
- **The command surface is app-owned** (ruled 2026-08-08 — superseding the delegate-vended-manager mechanism): Edit ▸ Undo/Redo are the app's own rows (`CommandGroup(replacing: .undoRedo)`) and the toolbar pair carries explicit targets, both reading the focused session's `BoardUndoManager` through SwiftUI focus (`FocusedValues` — the ⌘I board-info precedent; board windows publish the session's manager, card windows their own): titles from `undoMenuItemTitle`/`redoMenuItemTitle` (the platform still composes and localizes the verb over the bare 06-vocabulary phrase), enablement from `canUndo`/`canRedo` (locks and empty stacks, unchanged). The original mechanism — the system's nil-target `undo:`/`redo:` resolving through `windowWillReturnUndoManager` — is unreachable on a SwiftUI window: NSWindow reads and **permanently latches** its own empty undo manager during window creation, before any app code can install the delegate, so the hook is never consulted (diagnosed 2026-08-07 by live probe, standalone-repro-verified; the green command-surface tests install the delegate before the first read and so never reproduce it). Alternatives rejected: registering steps into the latched manager re-opens the substrate problem the two-arrays decision answered (a `.failed` crossing must leave the step in place; NSUndoManager pops a group before running it and offers no way back); intercepting nil-target `undo:` from the responder chain is preempted by NSWindow handling the action itself, ahead of its delegate. The rows enact ▸ Undo routing themselves — the same predicate, relocated from the window delegate to the command layer: while a text surface holds focus, ⌘Z goes to the first responder's own `undoManager` (an editor's delegate-vended manager, or a field editor's window-latched one, which is exactly where its typing undo registered), and otherwise to the focused board or card stack. The `windowWillReturnUndoManager` hook stays wired — it is the correct answer wherever AppKit itself asks a delegate for a manager — but nothing of the command surface depends on it any more.
## Undo routing
(Migrated here 2026-08-08 from 06-history-undo.md at that doc's retirement — the rule was always tier- and substrate-independent, and this is its live home now; four docs cite it by this heading.)
**Routing is by focus** — the platform's first-responder 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 board history. Text undo works on **every** board, and so does board-level undo: every board binds a provider (the native stack today; the seam admits future providers), so "no undo" is not a state any board is in. **Control-class text fields route the same way** (settled): the search field (04-interactions.md ▸ Search), the popover's rename field, and the popover's other configuration fields own ⌘Z/⇧⌘Z as field-local text undo while focused — "board menu commands stay enabled" never hands Edit ▸ Undo to board history while a text-bearing control has focus; a reflexive undo over a typo must never become a board-level restore. With focus outside every text-bearing surface — editor or control — Edit ▸ Undo/Redo are, **in a card window, that window's own session stack** (the two-level model in Rules above — fine-grained window gestures; the coarse close unit is one board step), and on board surfaces board history — the board's bound provider (disabling is locks and empty stacks). **No fall-through**: exhausting a focused editor's — or the window's — stack beeps; it never reaches board history. **The routing decision is enacted at the command layer** (re-ruled 2026-08-08, mechanism only — the rule itself is unchanged): the app-owned Undo/Redo rows apply this section's predicate themselves (Rules ▸ the command-surface rule), because the window-delegate hook the platform route rode is never consulted on a SwiftUI window.
## Interaction with the trash
⌫'s undo is the move back — a delete is a move into `.trash/` (cards resettled 2026-07-28; lanes rejoined 2026-07-29), so its undo is the ordinary inverse move, returning a card to its source lane and rank, a lane to its strip position (subtree intact — it never left the folder); a restore-by-move undoes the same way in reverse. The stack and the trash never conflict — they are the same folder moves addressed by recency instead of by selection. The old lane-delete recreate-from-capture inverse is **retired** — no destructive delete remains outside a trash, so nothing needs byte capture. A **permanent delete registers no step** — the trash's Delete and Empty Trash are not undoable (Rules above), lanes and their freight included; the confirm is the safety.
**Comments keep the no-capture rule true — on the window stack** (re-ruled 2026-07-31, superseding the board-stack routing): a comment delete is a move into the card's `comments/.trash/` (01-storage-format.md ▸ Enhanced schema — the materialized-trash pattern one level down), its inverse the ordinary move back, and the step lives on the **card window's own stack** (Rules above) — the board stack never carries a granular comment step, so the old stale-after-close skip scenario cannot arise. **The purge of `comments/.trash/` defers with the coarse step** (re-ruled 2026-07-31, superseding purge-at-close): the coarse close step's undo restores deleted comments, so their backing lives as long as the step does — the purge runs when the coarse step leaves the board stack **cleanly** — undone-and-superseded, or dropped off the end — or when the board session ends; **a stale-skipped step's backing instead survives to board-session end** (ruled 2026-07-31, decoupling skip from purge): the skip banner says nothing was applied, and an irreversible purge riding that gesture would be surprise loss — the skip is exactly when the user may want to inspect what the collision left; crash residue still sweeps at the next card-window open — **and residue is defined by the purge-deferral condition itself** (ruled 2026-07-31): `comments/.trash/` content referenced by a live coarse step on the board stack is a step's backing, not residue — the open-time sweep consults the stack and skips owned content, re-arming when the owning step leaves the stack (which is exactly when the deferred purge wanted to run; one condition, two consumers). Reopening a window can therefore never destroy its prior session's undo backing. Unowned content sweeps as before, armed-then-cleared like every heal memo. **Every purge of `comments/.trash/` is per-entry behind the ownership gate** (ruled 2026-08-06 — the container-whole retirement purge retires): a step's retirement and a no-step close remove only entries no live step still backs — the same `backedContent` inventory the sweep consults, making it one condition, *three* consumers. The container-whole purge assumed one owning step per card's comment trash, and two sessions over the same card broke it: the second step's retirement — or a mere reopen-and-close that registered nothing — emptied the first step's backing out from under it, silently killing an undo the stack still promised. Under the gate a purge cannot stale a live step by construction; an entry that outlives its owner is collected by whichever consumer runs next (the next retirement, close, open-time sweep, or session end — convergence, not a leak). One carve-out: **an open card window is itself an owner of its card's comment trash** — a retirement firing while the card's window is open defers its purge to that window's close, because entries deleted in the live session are backed by the window's fine steps, which the board-stack inventory cannot see; the close then settles by the same gate (its coarse step becomes the owner, or the no-step close purges the unowned). Purge timing follows the undo substrate's need — a future provider with durable history may ride its own flush instead, through the same seam.
## 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 foreign-change gap (now the deferred journal's charter — strategy/01-git-excision.md ▸ Successors). 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.
**RETIRED 2026-08-08** (strategy/01-git-excision.md): a superseded record — the operations these conclusions shaped were built (forward-restore, the safe checkout, housekeeping), shipped in no release, and excised whole; the stack is recoverable at tag `pre-git-excision`, and the forward-restore *principle* — history only ever moves forward — survives as precedent for whatever durable history the deferred foreign-change journal or the ops service one day offers. Everything below is the record of the extraction as it stood.
**Tier scope: Lanework Pro** (12-editions.md). This doc extracts the settled conclusions from the pathfinder's git-operations analysis (`../../Kanban/AI-ANALYSIS-git-operations.md`, 2026-07-23) so the git milestone has a citable in-repo source. It resolves the design corpus's outstanding tbd — the git-operations doc extraction the Implementation board's root index names as one of two TBDs graduating into work items. The source file catalogued issues and deliberately made no decisions; the decisions were made in 06-history-undo.md and 07-sync-collab.md, and this doc records which of the source's conclusions those docs build on — and which of its leanings later design deliberately settled otherwise. Issue tags (A1, C3, …) are the source file's.
## The forward-restore model (C3, C9) — the load-bearing extraction
**Every restorative operation moves history forward. Nothing the app does ever rewrites a published commit: no reset, no force-push, no revert-by-rewrite.**
- Undo (⌘Z) and redo (⇧⌘Z) restore earlier states as **new forward commits** ("Undo: ⟨subject⟩"). Reset-based undo is forbidden once any second observer exists — a remote, another machine, an agent reading the repo — because it rewrites history that observer may already have seen (C3). And the observer assumption is not optional: agents-as-concurrent-editors is a product requirement, so the safe case for reset (a never-pushed, never-read local tip) is never provable. Forward-only sidesteps the entire class.
- Under sync, forward-only undo makes an undo **just another change for the sync loop to carry** — nothing special, no divergent realities for anyone who saw the abandoned state (C9). Reset-based undo would instead manufacture divergence with every ⌘Z.
- The **one deliberate rewrite** in the app is pull's rebase of **unpushed local** commits (07-sync-collab.md) — local-only history, never published, rebased so the shared trail stays linear. Published history is never touched, by anyone, for any reason.
This is the conclusion 06-history-undo.md's "Undo never rewrites history" rule builds on, and the reason a rewritten remote (an outside agent force-pushed) is treated as fresh divergence to merge — never mimicked locally, never force-pushed back (B7).
## Persistence across relaunch (C8) — as later design settled it
The source left C8 open between an in-memory stack that dies with the app and a git-enabled "restore to any point" browser. Later design settled it past both options, and the settled form is what m7 builds:
- **No sidecar undo state, ever.** The undo stack **reseeds from HEAD's first-parent ancestry on load**; redo starts empty. In-session it behaves as classic dual stacks; after relaunch, past restore commits reappear as ordinary undoable steps (06-history-undo.md ▸ Rules). Nothing is stored beside the repo, so nothing can drift from it.
- **The board-wide history browser / timeline is explicitly out of scope** — it is a tracker-integration-era feature (07-sync-collab.md's deferred story), not part of the git milestone. The commit trail remains its natural substrate whenever it happens; the forward-restore model is all the preparation it needs.
- **The one deliberate carve-in**: the card window's read-only per-card History section (05-card-window.md) — a scoped log view, not a browser. Per-row restore and lane history stay on the wishlist.
## What did not carry over
Three of the source's leanings were settled **differently** by 06/07; they are recorded here so this doc cannot be read as endorsing them:
- **C1, the undo substrate**: the source leaned toward a hybrid — model-layer inverse operations for ⌘Z, with git as a recorder. Settled differently: on git boards **the commit trail itself is the substrate** — the stack *is* HEAD's first-parent ancestry, and undo applies forward restore commits (06-history-undo.md). Inverse operations exist, but as the **free tier's** native undo substrate (13-native-undo.md) behind the same HistoryProviding seam — a tier seam, not a hybrid inside one system.
- **A1, iCloud coexistence**: the source's lean (via the pathfinder wishlist) was dual mode per document — cloud-synced boards silently get no git. Settled differently: **iCloud Drive is not supported at all** — no accommodations, a thorough once-per-board warning that recommends local disk plus git remote, and no hard block (07-sync-collab.md ▸ iCloud Drive). Mode is decided by `.git` presence alone, never by the board's location.
- **B6, sync cadence as the divergence budget**: the source framed cadence as the decision that determines how good a silent merge engine must be. Settled differently: **there is no merge engine** — auto-commit keeps the tree clean by pull time, pull rebases with local-wins on conflicting hunks, and rejected pushes fetch-rebase-push automatically, so conflicts are structurally impossible rather than managed (07-sync-collab.md). Cadence survives only as 06's history-legibility constraint (a shared board's log must stay bearable), not as a divergence budget.
## Already absorbed — pointers, not restatements
The rest of the source's conclusions that motivated 06/07 live there in settled form; cite those docs, not this one: repo maintenance and growth posture (A2 → 06 ▸ Repository hygiene), the commit-failure taxonomy and lock handling (A3 → 06 ▸ Interaction with external writers), the repo-state gate for foreign states (A4 → 06 ▸ Rules ▸ Abnormal repo states), auth and transport limits (B5 → 07 ▸ Remote authentication), history as a read surface and per-mutation semantic messages (B8, C2, and the source's strongest cross-cutting signal → 06 ▸ Commit messages), undo scope and focus routing (C6 → 06 ▸ Undo routing), and the connect-time privacy sentence (B9 → owed by the remote-setup surface; 07 ▸ Mode: git).
## Open questions
None — this doc extracts settled material only. The source file's eight "open decisions" are all closed: 1 (A1), 3 (B6), 4 (C1) as recorded above; 2 (A4), 5 (C2/B8), 6 (C4), 7 (C5) in 06-history-undo.md's Rules and Commit messages; 8 (C8) as recorded above.
> **Status: v1 settled and built** (2026-08-09, Pipeline card 332e4377). The format inventory below is the whole of the brainstorm that produced it; the v1 set is shipped, and the deferred and rejected lists are recorded so neither gets re-litigated by accident.
Lanework's on-disk format is already the most open thing about it: a board is folders and Markdown, and anybody's tools can read it. Import and export are therefore not a data-liberation feature — the data is already liberated — but a **shape** feature. What a foreign format carries is not the bytes of a card but the arrangement of them: which lane, in what order. Everything below follows from that.
## The formats (v1)
Three, both directions, all three converting a **whole board** to or from a **single file**.
### Obsidian Kanban plugin Markdown
One `.md` in the shape mgmeyers' plugin writes: `kanban-plugin: board` frontmatter, `##` headings as lanes in lane order, `- [ ]` list items as cards in card order, multi-line bodies as continuation lines indented two spaces under their item.
- The frontmatter is spelled the way the plugin spells it — blank lines inside the `---` delimiters — so a file exported from here is byte-shaped like one the plugin wrote.
- **No `#` board title is written**: the plugin has no such concept (a board's name is its file's name), so an H1 would read in Obsidian as a stray heading with no cards under it.
- **Nothing is exported checked**, including a lane called Done. Lanework has no done flag, and matching lane titles against a word list to synthesize one would be the exporter inventing data. The import side ignores the marker for the same reason: `- [x]` becomes an ordinary card.
- The plugin's trailing `%% kanban:settings %%` block and its `## Archive` section are **not written**. Both are the plugin's own state about a board rather than the board; a settings block synthesized from defaults would be this app asserting preferences inside another app's file. On import both are handled: the comment block is skipped rather than read as a card, and an `## Archive` heading arrives as an ordinary lane named Archive — the honest landing place for cards in an app with no archive.
### Plain Markdown outline
The same document minus the plugin marker: an optional `#` board title, `##` lanes, `- ` items, the same indented continuations. The "paste this board into a doc, a PR, or a chat message" format, and nearly free once the flavour above exists.
### CSV
RFC 4180, UTF-8, no BOM, CRLF record terminators, header row `lane,title,body,created,modified` — one row per card with the lane title repeated down the column. Bodies are quoted and escaped, so a multi-line card survives the trip.
CRLF is the one place the app's own LF-everywhere rule (01-storage-format.md § Encoding and line endings) deliberately does not reach: that rule governs the files a *board* is made of, and a CSV is an interchange document written for other people's tools, where the standard names one terminator and every reader takes both.
The two stamps are **export-only**. Nothing reads them back — see *An imported board is born today*.
## Order is position, in every direction
Every one of these formats encodes order as **document position**: heading order, list-item order, row order. So an export writes nothing down about ranks, and an import mints them in parse order — 1024, 2048, 3072 — which is exactly where the app's own append (`Ranks.append(toVisible:)`) would have put them had the user typed the cards in that sequence. The symmetry is most of why all three formats are cheap, and it is why the round-trip suite is this feature's primary proof rather than a nicety.
## Export is three rows; import is one
**File ▸ Export ▸ {Obsidian Kanban Markdown…, Markdown Outline…, CSV…}** — the frontmost board, each row opening a save panel already pointed at the right extension. Three named rows rather than one row with a format popup: a popup makes the menu row itself uninformative (nothing about "Export…" says CSV is on offer), and menu titles are API in this app (11-command-nexus.md ▸ Configurable bindings), so a row whose meaning lived in a popup would be one title standing for three commands.
An export runs the close flush first, like Duplicate, Save as Template and Share before it, so a card window's unsaved keystrokes are on disk before the snapshot is serialized. **An export is a read**: it writes nothing into the board's own tree, so the read-only lock does not close the rows — a board on a read-only volume is exactly the board somebody wants a copy of. The one carve-out kept is the focused-inline-editor rule, since an open rename holds the one pending change no flush can reach.
**File ▸ Import Board…** — one row, and **no format picker**: the format is detected. The plugin's frontmatter marker identifies an Obsidian Kanban file outright; a `.csv` extension answers for the table; a Markdown extension answers for the outline; a file with no useful extension is sniffed (a consistent multi-column table with no Markdown structure anywhere in it is CSV, everything else is outline). The fallback is the lenient outline parser, which cannot fail. Asking users to classify their own file would make them do the one part of this the app can do reliably.
The row sits beside File ▸ Open… rather than beside Export, and is available with no board in front: the two rows next to each other are the two ways a board arrives from disk.
## An import always creates a fresh board
Open panel (which file), save panel (where the board goes), then the board opens through the ordinary open path — registering, bookmarking and titling itself like any other. **There is no merge-into-the-open-board in v1.** An import that landed lanes inside a live board would need every reconciliation rule the paste path already carries, for a gesture nobody has asked for; and a fresh board keeps the whole feature reversible by deleting one folder.
The **save panel's name wins** over any title parsed out of the document, so display name and folder name start out matching (01-storage-format.md § Board naming) — the parsed title is what seeded the suggestion, not what overrides the answer. That is 09-templates.md ▸ Instantiation's rule, read one flow over.
The tree is built by the ordinary Writer (`createBoard`/`createLane`/`createCard`/`writeBody`), so an imported board is indistinguishable from a hand-built one: `schema: 1`, `kind` keys, lowercase-UUID folder names, a seeded `.gitignore`, a current agent guide. Atomicity is the template engine's, verbatim — the destination is created by the import and removed by it on every exit that is not a board, and an occupied name is refused rather than clobbered.
**An imported board is born today.** No importer reads a `created` or `modified` cell: the create path stamps both from one fresh `Date`, exactly as 09-templates.md rules for instantiation ("a new board is born today, not forked"). Backdating an import would claim a provenance the app cannot verify from a spreadsheet cell.
## Lossy exports say so
None of the three formats can carry comments or attachments. So an export that leaves either behind posts a **warning-tone loss row** naming the counts — "Exported without 12 comments and 3 attachments — CSV carries neither" — through the same `BannerCenter` machinery and in the same voice as the relocation and skipped-folder notices (02-architecture.md § The banner surface).
The class is the right one for the same reason the skipped-folders notice is: the operation succeeded and only the payload the destination cannot hold stayed behind. A signpost would rank last and may collapse behind "+N more", and an export the user believes is complete is exactly the harm; a one-shot would be a lie, since it carries a `BoardWriteError` and the write succeeded.
**A lossless export says nothing at all.** The file is where the user pointed, and a row confirming that would be noise.
Two smaller omissions are recorded here rather than counted in a banner, because unlike comments and attachments they are fields most boards leave empty or would not miss: a **lane's body** (its description or WIP policy — a Markdown lane *is* its heading line, and CSV's grain is one row per card), and every styling key (`background`, `icon`, `iconColor`, `width`, `collapsed`) plus the reserved keys. Two lanes that share a title also merge on a CSV re-import, which is an inherent property of a flat table rather than a choice.
## Convert once; there is no sync
Every one of these conversions is **one-shot**. Exporting does not create a link, importing does not create a link, and nothing watches an exported file for changes. Editing a board in Obsidian's plugin and reopening it here does not merge — it imports again, as a second board.
This is ruled out explicitly rather than left unsaid, because a real round trip is a materially bigger feature than an importer and an exporter: it needs a file-watching story, a conflict story, and an identity story for objects that have no stable id in any of these formats (position is the only key a Markdown outline has, and position is exactly what editing changes). Whatever durable two-way story Lanework eventually wants belongs to the sync workstream, not here.
## Obsidian vault interop is free, and should be said out loud
**A Lanework board folder is already very nearly a valid Obsidian vault**, and this costs no code at all:
- Every card is one `index.md` with YAML frontmatter — Obsidian's own file shape.
- Attachment links are relative paths into the card's `attachments/` folder (01-storage-format.md § Attachments), which render in Obsidian's preview exactly as they render here.
- Unknown frontmatter keys ride along untouched in both directions, so Obsidian's properties and this app's schema coexist without either rewriting the other's.
- Nothing in the board tree is a database, a cache, or a binary sidecar that a second editor could corrupt by writing normally.
What Obsidian does *not* see is the board shape: it reads a flat collection of notes, because lanes and order live in folder names and an `order` key rather than in anything Obsidian models. That is the honest half of the claim, and the Obsidian Kanban plugin export above is what closes it for anyone who wants the board shape inside their vault.
This is a **positioning** note as much as a technical one. "Files-first" is easy to say; "point Obsidian at your board folder and it works" is the demonstration.
## Deferred — real, but not v1
- **Trello JSON import** — the highest-demand row by a distance (every switcher has a Trello board), and the reason it is not in v1 is that it is not a shape transform: attachments are cloud URLs that need re-downloading and can expire, and comments have to be pulled out of a capped activity log with an authorship question attached. Worth doing after the cheap wins prove the import surface once.
- **Canonical JSON dump** — the only format that could be fully lossless (comments, colours, `modified-by`, timestamps). Cheap once the serialization is being touched anyway, and the natural wire shape for scripting. Sequenced late deliberately: it competes head-on with "just zip the `.kanban` package", which already wins on fidelity for zero app code and which File ▸ Share… already does.
- **TaskPaper and OPML** — both are the outline exporter with different delimiters, so both are nearly free whenever that code is next open; the audience is small and aging. Bundle them together rather than scheduling either alone.
- **Notion import** — a genuine demand pool, with a real risk of overselling: Notion's value is databases and relations that do not map onto lanes and cards, and the importer needs a user-driven mapping step (which property is the board view) that none of the v1 formats need. Wait for somebody to ask.
- **OmniFocus (via OPML) and Reminders (via EventKit)** — plausible and individually cheap, no demand signal. Reminders in particular is a live OS API rather than a file format and probably belongs with a future quick-capture feature rather than here.
## Rejected
- **Tracker sync, GitHub Projects included** — 00-vision.md's explicit non-goal for this scope ("No tracker integrations… the schema reserves `remote`/`remote-state`… nothing here is designed for them"). GitHub Projects has no export file at all: it is a GraphQL API behind OAuth, which makes it a live-sync feature, not an import format. It belongs to whatever Teams pass eventually happens.
- **Things (official export)** — there is no first-party export surface to target. The only routes are reading its private SQLite database or asking the user to run a community script first, and neither is a target worth building against. If it is ever asked for, point people at the community TaskPaper exporters and let a deferred TaskPaper importer pick it up secondhand.
- **Print/PDF as an interchange format** — already shipped as presentation (File ▸ Print…, board or card, configurable components, named profiles). Noted here only so it is not re-proposed as an export row.
## Open questions
- **The v1 rulings on this page were made by the implementing session on the owner's behalf, not by the owner** — the owner filed the brainstorm card and moved it to Approved after the brainstorm was posted; the session read that move as approval of the v1 recommendation and ruled the open questions itself: the export and import surfaces, the fresh-board-only import, the loss-row posture, the convert-once ruling, and the decision to treat vault interop as documentation. Each is flagged for review on the Pipeline card's comment thread.
A ground-up rewrite of the Kanban app, to ship as **Lanework** on the Mac App Store. The old repo (`../Kanban`) was a pathfinder — it never shipped, but it is the reference implementation and the source of hard-won decisions. This design starts from a clean slate and keeps only what earned its place. The rewrite keeps the internal codename `Kanban` (bundle id `dev.rzen.indie.Kanban`).
A ground-up rewrite of the Kanban app, to ship as **Lanework** on the Mac App Store. The old repo (`../../Kanban`, one level above this repository — not the rewrite's own `Kanban/` source folder, which shares the codename) was a pathfinder — it never shipped, but it is the reference implementation and the source of hard-won decisions. This design starts from a clean slate and keeps only what earned its place. The rewrite keeps the internal codename `Kanban` (bundle id `dev.rzen.indie.Kanban`).
Each document covers one aspect of the design. Within each:
Each document covers one aspect of the design. Within each:
@@ -13,21 +13,27 @@ Each document covers one aspect of the design. Within each:
| Doc | Aspect |
| Doc | Aspect |
|---|---|
|---|---|
| [00-vision.md](00-vision.md) | What Lanework is, who it's for, goals and non-goals |
| [00-vision.md](00-vision.md) | What Lanework is, who it's for, goals and non-goals |
| [07-sync-collab.md](07-sync-collab.md) | Board modes and git-rails sync — **retired 2026-08-08 as written**; successor: the ops-based sync service workstream |
| [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 tiers — **mooted 2026-08-08**: one version, everything free, tier machinery excised; Pro returns with the iPhone/sync era as a fresh pass (the doc is record: entitlement mechanics, distribution, the git rows) |
| [13-native-undo.md](13-native-undo.md) | macOS-native undo/redo — the history substrate (sole since 2026-08-08); owns Undo routing |
| [15-import-export.md](15-import-export.md) | Import/export: interchange formats — Obsidian Kanban, Markdown outline, CSV — and the Obsidian vault interop posture |
## 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), styling controls (03-board-ui.md ▸ Styling ▸ Controls), and comments (01 ▸ Enhanced schema + 05 ▸ The comments column — designed 2026-07-29, shipping post-2.0) each had their focused pass and are settled.
**The authoritative list of open design passes is the findings board** (Lanework Redesign.kanban ▸ Issues to Resolve) — this section stopped enumerating by hand after drifting twice. Standing examples as of 2026-07-29: the attachment-undo staging design and the EchoLedger foreign-undo bridge (both flagged in 13-native-undo.md), Teams' tracker integration (no design yet), the fail-fast decision surface (01 ▸ Refuse), and the drop-release settle presentation (03 ▸ Motion — deliberately last in line).
<pclass="subtitle">The triangle mesh swept on the full axis set: vertex count (columns), color count (mono · complementary duo · contrasting trio), tone, and saturation. Brightness stays a narrow per-triangle jitter around the tone base. Hue wheel narrowed to four representatives this round — amber, forest, sky, rose — the full wheel returns for finals. 3 color counts × 2 tones × 4 hues × 3 saturations × 3 densities = 216 swatches. Reroll to reseed geometry and color assignment under the same recipes.</p>
<divclass="recipe-item"><dt>Brightness</dt><dd>narrow band — tone base (light ≈85–90%, dark ≈17–21%) ±4.5 per triangle; all hues in a swatch share it</dd></div>
<divclass="recipe-item"><dt>Hue</dt><dd>amber 38° · forest 140° · sky 215° · rose 335° (±3° per triangle)</dd></div>
<divclass="recipe-item"><dt>Randomness</dt><dd>geometry and per-triangle color assignment reseed on reroll; the swatch ID names the recipe</dd></div>
<pclass="recipe">A flat pastel ground holds four to six large soft-edged gradient blobs that overlap and drift toward one corner, mesh-gradient style.</p>
<pclass="recipe">A top-left radial gradient ground carries a power-law scatter of circles — a few large, more medium, many small — mixing filled discs with stroked rings.</p>
<pclass="recipe">A near-neutral ground is covered in about fifty-five small irregular polygon chips across four related colors, like stone terrazzo.</p>
<pclass="recipe">A horizontal gradient ground is layered with six translucent sine-wave bands, phase-shifted and stacked toward the bottom third like dunes.</p>
<pclass="recipe">A deep vertical gradient ground holds large soft gradient blobs and a faint horizon glow low in the frame — moody, no small shapes.</p>
<pclass="recipe">A flat dark ground carries the same wavy contour lines as Contours, with small accent dots resting on the lines — dark topographic.</p>
Status: **sweep 1 + faceted gallery published, awaiting swatch review** (2026-08-07). Not a numbered design doc — this is working material for producing a set of bundled background images; the schema and rendering side is already settled in 03-board-ui.md § Styling ▸ Capabilities.
## What this is
A set of shippable background images for boards. The app side needs nothing new: `background: {image: <path>}` relative to the board root, drawn scaled-to-fill and cropped under the transparent title bar with the frosted strip (BoardBackdropImage.swift), decoded at a 3072px ceiling (`BoardBackdrop.maximumPixelSize`), no in-app editor — the raw file is the escape hatch. So the deliverable is purely the artwork: PNG files generated from procedural recipes.
Deliberately left out of sweep 1 (add to surviving families in sweep 2): grain/noise texture, two-layer depth (large blurred behind small crisp), clustered placement, outlined-only variants.
- **Generator source**: `board-backgrounds-gallery.html` (this folder) — single self-contained HTML, all swatches procedural SVG off a seeded PRNG (xmur3 + mulberry32, seeded from the swatch ID string), so IDs are stable references across reloads and rebuilds. To republish after edits: publish this file via the Artifact tool passing the URL above as `url` to keep the link.
- **Board overlay toggle** draws translucent lane plates + opaque card plates + title strip over every swatch — the judging condition, since real backgrounds sit under exactly that stack (lanes are a translucent quaternary wash, cards opaque plates).
| waves | 6 layered sine dunes toward the bottom |
| dusk | dark gradient, large glow blobs, horizon ellipse |
| starfield | tiny stars w/ gradient halos + faint nebulae on dark radial |
| slate | dark topographic lines + accent dots on flat dark ground |
Rendering constraints baked into the generator (keep for sweep 2): no per-swatch SVG filters (all glow via radial gradients fading to alpha 0 — 96 filters would crawl), ≤~120 filler elements per swatch, gradient defs namespaced by swatch ID, light grounds ≥82% lightness / dark ≤20%.
Separate from the sweep-1 axes model: patterns designed to the user's own criteria — canvas tiled by shapes, per-shape **brightness jitter within a narrow band** doing the drawing, base brightness set by light/dark tone. Geometry is random on every render (that's part of the concept), so the gallery has a **Reroll** button; a swatch ID names the recipe, not a fixed layout.
- **Artifact**: https://claude.ai/code/artifact/d27ad77f-9298-4cf1-9093-19d13a186752 (republished in place each round)
- **Generator source**: `board-backgrounds-faceted.html` (this folder) — same seeded-PRNG + board-overlay-toggle infrastructure as sweep 1; seed = swatch ID + generation counter.
- **facets**: grid-jittered dot scatter over an extended canvas → Bowyer–Watson Delaunay triangulation, each triangle stroked with its own fill color to kill antialiasing seams. Reference image: low-poly example on the Redesign board.
- **bubbles**: same color logic on ~64 opaque circles, power-law radii, drawn large-first. **Set aside after round 1** ("we'll need more work on that") — round-1 recipe kept in git history of the generator file.
**Round 2 (current, facets only)** — axes per the user: vertex count, color count, tone, saturation; brightness stays the narrow in-image jitter. 216 swatches = 3 color strategies × 2 tones × 4 hues × 3 saturations × 3 densities. Hue wheel narrowed to 4 representatives (amber/forest/sky/rose) to keep the cross reviewable; full wheel returns for finals.
- Densities (columns): coarse 5×3 cells ≈20 triangles · medium 9×6 ≈97 · fine 14×9 ≈230.
- Color strategies (sections): mono · duo = complement H+180° weighted 65/35 · trio = triad H±120° weighted 50/30/20, hue picked per triangle.
- IDs `facets-<hue>-<strategy>-<density>-<tone><sat>`, e.g. `facets-sk-duo-f-l2` = sky duo fine light mid.
- Jitters: hue ±3°, sat ±15%, brightness ±4.5 L around tone base (light 85–90, dark 17–21, rich rows get slight headroom shifts).
**Shipped in-app (2026-08-07)**: the recipe is live in the board popover's Background tab (03-board-ui.md § Board popover ▸ Background tab) — Swift port in `Kanban/UI/Board/Backgrounds/`, rendered to a static `facets.png` at pick time (never a live view — the perf/sync ruling). One deliberate deviation from the gallery: the scatter grid gained a sacrificial boundary ring per density (coarse 7×5 @ margin 0.305, medium 10×7 @ 0.14, fine 15×10 @ 0.12 — interior cell size unchanged) because the gallery's fixed 0.075 margin let the ground notch the frame edge (coarse by up to 0.163 of the width — visible flat borders on the reviewed coarse swatches; medium 0.044, fine 0.004). Full-bleed coverage is now a tested invariant (`margin ≥ 0.92 × cell` both axes). Packaging question from sweep 1 is thereby answered for facets: generated on demand, not bundled.
## Next steps
1. Review with overlay on; pick surviving families / specific IDs and direction tweaks ("aurora but duskier", "contours denser").
3. Settle final recipes → render real PNGs at 3072px long edge (canvas render of the same recipes, or a small script) → decide packaging (bundled set offered how? templates carry them? drag-in only?) — packaging is an open question, nothing ruled yet.
Where the drag experience actually spends its time, which pre-checks earn their place on the hot path and which don't, and where optimistic allowances are worth making. Written 2026-08-06 from a full read of the pipeline (`BoardDrops.swift`, `DragSession.swift`, `DragAutoScroller.swift`, `DropSlotMath.swift`, the commit path in `BoardStore.swift`, the watcher in `FolderWatcher.swift`) against the model in DRAG-REORDER.md and the measurements in RENDER-INSTRUMENTATION.md. This is an analysis document: findings are prioritized and sequenced but nothing here is implemented yet.
The headline, stated up front: **the mid-drag hot path is already in good shape, and the felt latency lives almost entirely in the release.** The per-sample arithmetic is microseconds on any realistic board; the pause between mouse-up and the card being real is a 200ms debounce plus a whole-strip render pass, and one honesty bug can stretch a *failed* drop into a 1.5-second freeze. The biggest wins are not in doing less checking mid-drag — they are in making the echo arrive fast, making the landing frame cheap, and making the handoff structurally incapable of blinking.
## Anatomy of a drag, as the profiler sees it
**Pickup** (once per gesture): the handle's `.onDrag` closure guards `!isReadOnly, !isEditingInline`, resolves the dragged run's lanes with one O(board) scan, JSON-encodes the payload, and calls `DragSession.begin` — which freezes `cardHeights`/`laneUnits` (the only frozen geometry), clears the `RestingLayoutCache`, and arms the watchdog and the modifier-flip monitor. All synchronous, all trivial. The expensive thing that *also* happens at pickup is not the drag's fault: click-select precedes the drag, and a selection change re-runs **every card body on the board** (RENDER-INSTRUMENTATION.md — "Selection is O(board) in card bodies").
**Per sample** (every `dropUpdated`, and again on every autoscroll frame that actually scrolled): `revalidateProposal()` — an O(lanes) `.contains(where:)` — then the shared retarget: cursor conversion, a `RestingLayoutCache` lookup (hit = a struct-equality key check), `DropSlotMath` zone arithmetic over one masonry column, and a `propose()` that early-outs when the slot didn't change. The strip-side retargets (`retargetLanes`, `retargetCardsFromStrip`) allocate a filtered lane array and a display-units array per sample — the one piece of per-sample work the cache does not cover. On a ≤12-lane board this whole chain is single-digit microseconds; at 120Hz it is not where frames go.
**Release**: `commitDrop()` re-runs the guard ladder once (revalidate, this-board check, survivors, mixed-kind, the `TrashDrop.accepts` re-ask), dispatches to the store writer — a synchronous `performWrite` whose disk work is one `order`-field rewrite or one folder rename — and arms the **committed-overlay hold**: the board keeps drawing the proposed arrangement until the echo snapshot lands or `CommittedHold.timeout` (1.5s) gives up.
**Echo**: `performWrite`'s bracket closes → `FolderWatcher.endBracket()` schedules the mandatory post-bracket reload → **200ms trailing debounce** → off-main tree walk (cheap; `ParseMemo` re-parses only the touched files) → main-actor `land()` with the animated snapshot assignment → `BoardView`'s `snapshotGeneration` watch calls `DragSession.handOff`, dissolving the hold.
So the release pause = write (~1–10ms) + **200ms debounce (dominant)** + walk (a few ms warm) + apply/render. The `drop-release-pause` signpost measures exactly this span, and its healthy outcome today is bounded below by the debounce.
## Findings, prioritized
### P1 — Expedite the app-mediated echo (~180ms off every drop)
The 200ms trailing debounce exists to coalesce foreign FSEvents bursts. But a drop commit is not a burst the watcher has to wait out — the store *knows* it just wrote, the bracket already owes exactly one delivery, and the user is staring at the gap. Add a surgical `FolderWatcher.expedite()`: if `bracketDepth == 0 && pendingOrigin != nil`, cancel the armed debounce and fire the owed delivery now. Expose it through the store and call it from `commitDrop()` right after the writer returns.
This is deliberately **not** a shorter global debounce — `endBracket` serves every `performWrite` (card saves, inline edits, heals), and dropping the settled 200ms figure everywhere would turn write bursts into per-write walks. It is also **not** a violation of one-way flow: nothing mutates the snapshot from the write path; the delivery the bracket already owed just fires earlier, and the store still learns the new order by walking disk.
Races, analyzed: our own FSEvents still in kernel flight arrive after the fast reload and schedule a `.foreign` delivery → one redundant memoized walk → value-equal → assignment skipped, `snapshotGeneration` unmoved, nothing visible. A foreign write landing between our write and the fast reload folds under the `.appMediated` label — the same accepted blur `WatchOrigin.merged` already documents. POSIX guarantees the walk sees the completed writes, because `performWrite`'s FileManager work returned before `expedite` was called. All benign; the cost is one extra no-op walk per drop.
Expected result: `drop-release-pause` outcome `echo` at ~10–50ms instead of ~250–400ms. This also all but closes the rapid-successive-drag window (see P5). Tests: FolderWatcherTests additions (expedite fires the owed delivery once, respects open brackets, no-ops with nothing pending).
### P2 — Honest failure path: don't arm a hold for a write that didn't happen
`commitDrop()` arms `session.commit(into: store)` unconditionally (`BoardDrops.swift:978`), but the drop-path store methods return `Void` and swallow failure via `try? performWrite`. A *failed* write therefore posts its banner immediately — and then leaves the dropped arrangement frozen on screen for the full 1.5s `CommittedHold.timeout` before animating back. By this repo's own definition (`drop-release-pause` outcome `timeout` is "a bug, not a slow frame") and 03-board-ui.md § Motion's promise ("a failed write discards the proposal and the board animates back"), this is a bug, not a design.
The fix is plumbing a fact the writers already compute: make `moveCards`, `copyCards`, `moveLanes`, `restoreLanes`, `receiveCards`, `receiveLanes`, `deleteByDrag`, `deleteLanesByDrag` return `@discardableResult Bool`; arm the hold only on `true`, else `cancelDrop()` — the immediate animated snap-back, banner already posted. A refused no-op arrangement also stops arming a pointless 1.5s hold, which de-noises the signpost and makes `CommittedHold`'s own doc comment ("every drop path refuses a no-op arrangement before it opens a write bracket") true end-to-end.
### P3 — The headerInk hoist: make the landing frame cheap
Every echo reload — the one *inside* the release window — currently re-runs every lane body on the board, because `LaneView.body → headerInk` reads `store.snapshot.background` and Observation tracks whole properties (RENDER-INSTRUMENTATION.md — "the lane gate is never asked on a snapshot change"). The fix is already designed there: resolve the ink once in `BoardView` and pass it down as a compared parameter, the way `slotWidth` and `columns` already are. The tripwire is armed: `theLaneCostFollowsTheBoard` fails in the good direction when this lands, and the container budget in `aOneCardEditIsNotAWholeBoardRebuild` drops from `laneCount` to the pathfinder's 4.
This matters more once P1 lands: with the debounce gone, apply/render becomes the dominant share of the pause, and this is the cheapest way to shrink it. It also smooths the *other* moment the drag model cares about — a foreign reload landing mid-flight, where a whole-strip body storm currently rides the re-grounding.
### P4 — Shadow identity handoff: the echo becomes a content swap
Today the hold renders `DragShadow`s keyed `"shadow:\(index)"`, and the echo swaps a shadow-identity ForEach element for a card-identity element — an insert/remove, exactly the shape the create placeholder deliberately avoids ("a committed placeholder is already keyed by the arriving card's identity… the ForEach element is neither inserted nor removed — only its content changes", `LaneView.swift` at the placeholder). While the session is settled (`hold != nil`), key each held shadow slot by its member's `ItemID` — the session knows `members` in flatten order. The handoff then swaps content in place, structurally incapable of running the appear transition or a one-frame blink, under Reduce Motion or not.
With P1 this largely answers 03-board-ui.md § Motion's reopened release-presentation question with the cheapest possible answer: **nothing moves at all** — the card face materializes in its slot ~30–60ms after mouse-up, under AppKit's own drag-image fade. (Shortening that fade is not reachable: SwiftUI's `.onDrag` never exposes the `NSDraggingSession`.) A brief landed-highlight pulse in the selection-wash vocabulary remains available as optional polish, but prototype it only after P1+P4 — the fast echo may make any additional presentation unnecessary.
### P5 — Rapid successive drags: dissolved by P1, document only
Drag #2's `begin()` clears drag #1's hold, and until the echo lands the board regresses to the stale arrangement — the just-dropped card visibly snaps back, then jumps forward mid-drag when the echo re-grounds the zones. Today that window is ~250–400ms and reachable by a fast user; after P1 it is ~30–60ms and effectively unreachable. `handOff` itself is race-free (all main-actor, root+generation-guarded hold, `expire` self-checks, `begin` cancels the timeout). No mechanism needed; note the residual micro-window in DRAG-REORDER.md when P1 lands.
### M1 — Selection-gated card bodies: the pickup frame
`CardFaceView.body` reads `store.selection`, so selecting one card re-runs all 180 faces — and pickup *is* a selection change, so this O(board) body storm lands on the exact frame the pickup lift starts. The fix is the design change RENDER-INSTRUMENTATION.md already names: each face takes its own selected-ness (and the selection count, for the badge) as compared parameters through `CardFaceView.==`, making a selection change cost only the faces whose state flipped. Preserve the counter-invariant (`selectionStillRepaints`: a selected card must still repaint) and update `ViewEquatableTests`' comparison list. This is the likeliest source of a *pickup* hitch on large boards — the O(board) payload scan is not (see the rejected list).
### M2 — File-drop importable-count cache
The Finder-drop path runs `FinderDrop.importableCount`**twice per sample** (`acceptsFileDrop`, then the shadow count), each doing UTType-database conformance checks per provider — the only genuinely non-trivial per-sample system call in any drag mode. Cache the count on `DragSession` beside `fileTarget`, computed on the first sample of a hover, cleared with the file target and the file watchdog, keyed defensively on provider count. The commit is unaffected (it counts resolved URLs, not providers).
`retargetLanes` and `retargetCardsFromStrip` allocate a filtered lane array and a display-units array per sample, and `revalidateProposal` scans lanes per callback. A sibling cache to `RestingLayoutCache` — same keying discipline, same session lifecycle — holding the display-units array, its hidden-filtered variant, and a lane-ID `Set` (making revalidation O(1)) removes all of it. Honest sizing: at ≤12 lanes this is microseconds; the value is allocation pressure and pattern consistency, not visible frames. Do it last, or not at all if profiling says done. The reactive alternative — invalidating the proposal from the `snapshotGeneration` watch instead of polling per sample — is not recommended: it trades a provably-cheap check for an ordering dependency, and the re-grounding contract ("at the top of every callback and again at release") is pinned in prose and behavior.
### Wishlist — pre-flush the git seam at pickup
`GitAutoCommitter.noteWillWrite()` is rare (only when the window holds an uncommitted foreign change) but is the single worst possible release stall when it fires: a synchronous stage/commit plus HEAD materialize plus loader walks, all before the drop's write. The optimistic allowance: when a drag **begins** on a board whose window `holdsForeignChanges`, kick the flush then — mid-drag reloads are already a designed-for scenario (the re-grounding trio), and the release then finds nothing to flush. Medium complexity; keep as a wishlist note until the stall is ever observed in a trace.
## Rejected, with reasons
These were analyzed and turned down; recorded so they aren't re-litigated.
- **Full optimistic snapshot mutation at commit.** The `CommittedHold` already renders the proposed arrangement — members lifted, siblings reflowed, slot held — so the only perceptual delta versus mutating the snapshot is card-face-vs-shadow at the slot, which P1+P4 close for ~50ms of exposure. Breaking the one-way-flow invariant would buy that sliver at the price of reconcile-on-echo logic, snapshot rollback on write failure (the current failure story is trivially honest *because* the snapshot never lies), and re-deriving `EchoLedger`/`BoardDiff` semantics. The invariant stays.
- **Incremental/targeted reload.**`ParseMemo` already makes the echo walk parse ~2 files; what remains is directory enumeration, deliberately never memoized because attachments and loose files don't touch `index.md`. A touched-lanes re-parse would fork "the snapshot is rebuilt purely from disk" into two code paths to save single-digit milliseconds. P1 removes 200ms; this would remove ~5.
- **Async off-main `BoardWriter`.** Same-volume APFS renames and one-file order rewrites are sub-millisecond metadata ops; async buys nothing and costs the failure-after-hold problem — a banner about a drop the user watched succeed. If the one unbounded case (cross-board copy of a lane with heavy attachment trees) ever shows in a trace, handle that operation with a progress affordance, not the drop architecture.
- **Collapsing the commit-time guard ladder.** It runs once per release — not on any frame path — and the `TrashDrop.accepts` re-ask exists because ⌥ can change after the proposal stood with no callback reporting it; removing it converts a promised copy into a delete. `ModifierFlipTests` pins this. Zero smoothness gain, real correctness risk.
- **Dropping the commit no-op guard.** The `DropSlotMath.applied` recompute is once per release, ~30 comparisons, and is what keeps a drag that ends where it started from stamping `modified` and minting a git commit — a disk/history invariant, not a UI one.
- **Indexing the pickup payload scan.** ≤360 iterations once per gesture, microseconds. A cardID→laneID index invalidated per snapshot buys nothing measurable; the pickup hitch, if felt, is M1's selection storm plus replica rendering.
- **Caching `TrashDrop.accepts` per hover.** Six boolean clauses, no allocation, deliberately uncached for modifier freshness. Already free.
- **Allocation-free `DropSlotMath` / caching `MasonryPlacement.frames`.** The frames depend on `placement.origin`, which moves with every autoscroll step — the registry's live placement is deliberately uncached ("derived at event time", four stores and a divide). Caching origin-relative frames and translating per sample is the same O(n) with more machinery, churning the most heavily tested pure math in the app.
- **Gating the autoscroll retarget.** Already correct: `step()` returns before `didScroll` unless the clamped target moved > 0.01pt; frames where nothing scrolls cost one cursor conversion and an engagement test.
## Validation
No new instrumentation is needed; the existing instruments were built for exactly these claims.
- **P1/P2**: the `drop-release-pause` signpost — healthy drops move from ~250–400ms `echo` to ~10–50ms; failed drops stop producing `timeout` outcomes at all.
- **P3**: `theLaneCostFollowsTheBoard` fails in the good direction and gets rewritten to pin the fixed behavior; the container budget in `aOneCardEditIsNotAWholeBoardRebuild` tightens from `laneCount` to 4.
- **M1**: `selectionStillRepaints` keeps its non-zero floor; a new budget pins selection cost to the flipped faces.
- **M2/M3**: `RestingLayoutCacheTests`-style build/reuse counters on the new caches; `DropSlotMathTests`, `DragAutoScrollMathTests`, `ModifierFlipTests`, `DragWriteTests` all continue to pin the behavior none of this may change.
## Sequencing
P2 first (small, fixes a real bug, de-noises the signpost) → P1 (the latency win; validate with the signpost) → P3 (rides the same window; flips the tripwire) → P4 (handoff identity) — then reassess the open release-presentation question with the fast echo in hand before designing any pulse. M1 next if pickup hitches are felt on large boards; M2 with any file-drop work; M3 only if profiling still shows the strip allocations after everything above.
How dragging reorders items on a board. Ported from the pathfinder's document of the same name and rewritten for Lanework's two layouts: the **lane strip** (horizontal, mixed widths via the `width` unit multiplier) and the **card masonry** inside every lane (as many interior columns of standard width as the lane has units, each column stacking independently). The pathfinder wrote its model for columns and kept a "Generalizing to 2D" coda for the day cards could differ in size; in Lanework that day is the first one, so the coda is the present tense and lives inline.
Implementation, in two halves.
**The arithmetic**: `Kanban/UI/Board/DropSlotMath.swift` (pure zone math), `Kanban/UI/Board/MasonryLayout.swift` (`MasonryPlacement`, the resting grid), `Kanban/UI/Board/DragAutoScrollMath.swift` (edge autoscroll geometry), `Kanban/UI/Board/LaneLayoutMath.swift` (the strip's analytic resting layout), and the store's drop commits in `Kanban/LiveStore/BoardStore.swift` (`moveCards`, `copyCards`, `moveLanes`, `receiveCards`, `receiveLanes`, `restoreByDrag`, `receiveRestoredCards`). Tests in `KanbanTests/DropSlotMathTests.swift`, `KanbanTests/DragAutoScrollMathTests.swift` and `KanbanTests/DragWriteTests.swift`.
**The session**: `Kanban/UI/Board/DragPayload.swift` (the exported UTTypes and the pasteboard JSON), `Kanban/UI/Board/DragSession.swift` (the app-wide session state, `DragLocality`, the `CommittedHold`, the watchdog), `Kanban/UI/Board/BoardDrops.swift` (`BoardDropContext`'s shared retargets and commit, plus the three drop delegates and the measured-geometry registry), `Kanban/UI/Board/DragAutoScroller.swift` (the ticking driver), and `Kanban/UI/Board/DragShadow.swift` (the shadow and the multi-drag count badge). The gestures live where the design puts the handles — `LaneView` (the header's `.onDrag`, the card face's, and each lane's drop target) and `TrashLaneView` (a row's). Tests in `KanbanTests/DragSessionTests.swift`; the delegates and gestures are deliberately thin over the tested values.
## The pieces
A drag session involves four visual actors:
1. **Drag handle** — the affordance that starts the drag (a lane's title bar and empty space, 03-board-ui.md ▸ Lane; a card's whole face). Only the handle initiates; everything else about the session is about the item, not the handle.
2. **Drag replica** — the image travelling under the cursor. A faithful, full-size replica of the dragged item (the whole lane, not the strip of title bar that was grabbed), fanned with ghosts and a count badge for multi-drags. Its tracking is verbatim input echo and never animates; only its bracketing transitions do — the pickup lift and the release's fly-to-slot or fly-back (03-board-ui.md § Motion, settled).
3. **Shadow placeholder** — a dashed outline occupying the item's proposed landing spot in the layout. At drag start it replaces the item's original space; thereafter it marks wherever the current proposal is. A multi-drag shows **N contiguous shadows**. The drop always lands exactly where the shadows show.
4. **The reflow** — siblings animating aside to make room when the proposal moves ("pre-drop"), under the structural-voice spring keyed on the proposal and nothing broader (03-board-ui.md § Motion).
## Resting-layout zones
While a drag is in flight, the proposal (an insertion index) is computed geometrically against the **resting layout**: the visible siblings laid out with the dragged items removed and no placeholder inserted. Each slot `i` owns a *zone*: item `i`'s entire resting extent plus half the inter-item gap on each side. Zones tile the container with no dead space between them; before the first item and past the last item lie the outermost slots.
The proposal is a pure function of the cursor over these fixed zones. This is deliberately **not** derived from per-item hover events, which feed back off the very reflow they cause (items move under the cursor → a different item fires → the proposal moves again) and jitter. Because the zones are reconstructed analytically rather than measured, they do not move when the placeholder does.
**The resting layout follows the effective operation: a move lifts the dragged run out, a copy leaves it in.** 04-interactions.md says the originals stay for both copies — the within-board ⌥-drag and the cross-board default — so the source board draws what it will still hold: the originals stand in place, dimmed (`ClipboardTreatment.dimmedOpacity`, the same treatment a trash row being dragged out wears), and the shadow run opens beside them. A move's originals are lifted out at pickup and stay out until the echo lands. The single expression is `DragSession.hiddenMembers(onBoardRooted:)`, which every surface that builds a resting layout goes through.
Pressing or releasing ⌥/⌘ mid-drag therefore reflows the source board **once**, and that one-shot reflow is the point rather than a cost: the modifier's whole job is to change what the drop will leave behind, and the board answering the question is the feedback being asked for. (This retires an earlier carve-out that lifted the run out for every operation to keep the board still. The stillness was real; it was bought by never answering the question.) The flip lands on the next `dropUpdated`, because that is where the operation is re-resolved — a modifier pressed with the mouse perfectly still waits for the next motion. Once a release has settled, the operation is frozen for the duration of the committed hold (`DragSession.resolveOperation` guards on `hold == nil`, exactly as `propose` does), so a settled copy keeps its originals on screen and a settled move keeps them lifted until the echo snapshot lands.
The index-space consequence is real and is carried by the writers rather than papered over: **the drop index means different things for `moveCards` and `copyCards`**, and each one is counted in the layout its own gesture was showing. A move's index counts slots among the cards the run vacates; a copy's counts them among the lane's full rendered set, originals included. Neither writer has to guess — the operation picks the writer and the operation picked the layout — and a within-board ⌥-copy therefore lands exactly at the shadow's slot, including when the shadow sits below the dragged run in the same lane (see **The drop commits** below).
Lanes get this for free and the invariant is worth stating: a within-board lane drag never resolves to `.copy` (⌥ is ignored there), so the strip's zones never see a re-admitted lane under the cursor; a **cross-board** lane copy re-admits the lane into the *source* strip while the cursor is over the foreign board, where none of the source strip's zones are being consulted. `moveLanes` and `receiveLanes` keep the index space they always had. The strip's lane rendering has no dim of its own, so a re-admitted lane reads as an ordinary lane rather than as the source of the drag — the card face and the trash rows are the only surfaces wearing that treatment today.
Two stability rules on top:
- **Exact-boundary tie** — a cursor resting on a boundary pixel keeps the current proposal when it adjoins that boundary; the shadow can never oscillate on a single pixel.
- **Own-slot pickup** — zones derive from the resting layout, so picking an item up over its original spot proposes its own slot: a no-op, no reflow, and the store's commits refuse to write for it. The own slot is also **seeded at `begin`** (`DragSession.begin`'s `seed`), in the same transaction that lifts the run out, so "at drag start it replaces the item's original space" is true from the very first frame: without the seed the vacated gap closes un-animated and springs back open at the first `dropUpdated` — a shuffle carrying no information. The seed bypasses `propose` (a pickup is not a new landing spot, so no alignment tick), and the first real sample's re-propose of the same slot is the early-out's ordinary silence. A ⌥-pickup seeds nothing — a copy's resting layout keeps the originals in place, so there is no vacated space to hold, and the first sample answers as it always did.
## The lane strip's resting layout is arithmetic
The strip has no scroller and no measured frames worth reading: every lane is always on screen because the window's width divides across the lanes' width units (03-board-ui.md § Layout — full visibility). So the resting layout is a closed-form expression of `standardWidth(stripWidth:totalUnits:gap:)`, `slotWidth(units:standard:gap:)` and the unit counts of the visible lanes *minus the dragged run* — `LaneLayoutMath`'s own arithmetic, reused rather than restated (`DropSlotMath.laneExtents`). The first slot starts at `gap`, because the strip's outer margin is one gap wide — the same origin `LaneLayoutMath.laneIndex` hit-tests against and the session's cursor conversion lands in.
`standard` is **not** recomputed with the dragged lanes removed. It is a function of the board's unit total, and a lane in flight is still a lane on the board — the shadow occupies its units. Recomputing would re-divide the whole strip at pickup and again at release, which is the "motion feeds back into logic" failure this model exists to avoid. For a cross-board arrival the destination board's own `standard` is the one that counts, and the arriving run is measured in the destination's units.
The trash quasi-lane consumes one unit while shown and is never a landing spot for anything (04-interactions.md ▸ The trash: no move or paste ever targets the trash). It is excluded from the strip's slot list, and the terminal slot's uncapped reach past the last real lane is clamped by the session rather than by the arithmetic.
## Span-capped trigger regions (mixed sizes)
When items can differ in size, "hovering anywhere over an item" is the wrong trigger. Dropping a 1× lane before a 3× lane puts the 1× lane at the 3× lane's *leading edge* — so a cursor over the 3× lane's far side is nowhere near where the dragged lane would actually land, and reflowing there feels wrong and twitchy. 04-interactions.md ▸ Drag and drop asks for exactly this: "no reflow until the cursor reaches where the dragged lane would actually land".
The rule: slot `i` triggers only while the cursor is over the span the dragged run would **actually occupy** once dropped there —
where `leading(i)` is item `i`'s resting leading edge and `draggedSpan` is the total extent of the dragged run (sum of the dragged items' slot widths plus the gaps between them — `DropSlotMath.laneRunSpan`). Note this is exactly where the shadows will sit if the proposal is accepted: the trigger region *is* the shadow run's future footprint.
The remainder of a wider item's zone — beyond the cap — is a **dead region**.
Two slots are never capped, because nothing beyond them could be confused for a different target:
- the **end slot** (past the last item): appending is the only reading;
- the region **before the first item**: slot 0 is the only reading (it falls out of the arithmetic — the cap only ever truncates a zone's far side).
If the dragged run is at least as large as the item whose zone it crosses, the cap covers the whole zone and behavior is identical to the uncapped model.
## Hysteresis
A dead region changes nothing: the current proposal — and therefore the shadow — **holds** until the cursor enters another slot's live trigger region. Combined with the tiling zones (a zone is left only by entering another) this gives the drag its hysteresis: the shadow never bounces while the cursor drifts through ambiguous territory, it only moves when a genuinely new landing spot is reached.
The math says this by **returning `nil`**, not by returning the caller's own value back to it: `nil` is "hold", and a session that has no proposal yet still has none. That is the difference between the hold and the fresh-entry case below, and it is why the API is `Int?`.
Edge case: if a dead region is hovered with **no valid prior proposal** — a fresh cross-board entry, or the first sample after a reload invalidated the last proposal — the containing slot is proposed anyway. A drag in flight over a live target must always have *some* landing spot.
## The card masonry (2D)
A lane lays its cards out with `MasonryLayout`, dealing **column-major** (ruled 2026-07-31, replacing the pathfinder's round-robin): with `n` cards and `C` columns, `base = n / C` and the first `n % C` columns take one more, so column `c` holds the contiguous run `[start(c), start(c+1))` and logical order runs *down* each column before crossing to the next. Each column stacks its cards top-aligned and independently, with no row alignment across columns (03-board-ui.md § Lane). Card widths are uniform — the column width — and heights vary, so the grid is genuinely two-dimensional and the span-cap applies on the vertical axis.
The resting grid is **re-run, not measured**: `MasonryPlacement.frames(heights:)` replays the same placement over the lane's rendered cards minus the dragged ones, from `(columnCount, columnWidth, spacing, heights)`. `MasonryLayout` itself places subviews through that one function, so the resting grid a drag reasons about and the grid SwiftUI draws cannot drift apart.
**Heights are frozen at drag start** and passed in, never measured mid-flight — the animation-proof rule below, and the reason a lane whose cards are reflowing under a ~0.18s spring still resolves stable proposals.
Cursor → proposal, in three steps (`DropSlotMath.cardSlot`):
1. **Column.** The interior columns' x-bands tile the lane's card area — column `c` plus half a spacing on each side — and the cursor's band picks `c`. Outside the outermost bands the cursor clamps inward, so the lane's padding and its header target the nearest column rather than nothing. Exact-boundary ties keep the current proposal's column, as in 1D.
2. **Row.** Column `c`'s cards are the contiguous logical range `[start(c), start(c+1))`; their vertical extents feed the *same* 1D span-capped machinery the strip uses, with `draggedSpan` = the **first dragged card's frozen height** (the run's footprint at the landing spot; the remaining shadows stack below it, and the trigger rect that matters is the one the cursor is over). Dead regions hold, the tail slot below the column's last card is uncapped, and the region above the first card is uncapped.
3. **Logical index.** Column `c`, row `r` is logical position `start(c) + r` — no clamp needed, since `r` never exceeds the column's own card count. A non-final column's tail slot is a **genuine mid-list position** (`start(c+1)`, immediately before the next column's first card); only the last column's tail is the end slot — appending. This is a landing spot the round-robin deal could not offer, where every column's tail collapsed to the end.
The insertion index is therefore always a position in the lane's **logical card order**, which is what the store writes and what 10-accessibility.md's logical-order rule requires. Because the columns re-deal on every count change, inserting at index `k` slides later cards down within their columns and moves at most one card across each column boundary — far gentler than the round-robin deal this replaced, which sent every later card sideways. `MasonryLayout` is a `Layout` over a single `ForEach` precisely so the moves that do happen animate as positional slides rather than as remove/insert blinks. One presentation consequence of the re-deal, accepted with the ruling: a *tail* proposal's shadow draws at the head of the **next** column — exactly where the card will sit once the columns re-deal around it — so the shadow is not always directly under the cursor; the drop still lands exactly where the shadows show.
Dragging any member of a multi-selection drags the whole selection (04-interactions.md ▸ Drag and drop). Three rules follow, and the first two are the whole of what the math has to know:
- **One proposal for the whole run.** A multi-drag proposes a single insertion index and inserts contiguously there. There is no per-item targeting and no interleaving.
- **The run's span is the run's span.**`draggedSpan` sums the dragged items' extents plus the gaps between them, so a two-lane drag has to travel twice as far before a wider neighbour's slot triggers. In the masonry the vertical cap uses the first dragged card's frozen height, since that is the shadow the cursor is over.
- **Preserved relative order** is *flatten order* — "lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom)" — the same order the ⌘N target rule and paste anchor on. `SelectionGrammar.liveCards` is its single definition; the drop commits sort their members through it rather than through the `Set`'s iteration order, which has none.
N contiguous shadows are rendered by the session; the *index* is all this document's arithmetic produces.
## Cross-board sessions and the locality model
**Locality picks the default — the Finder volume model** (04-interactions.md ▸ Drag and drop, settled). Within a board a drag is a **move**: rearranging. Between boards it is a **copy**: transferring, with the system copy badge showing over the foreign board. **⌥ always forces copy** and **⌘ always forces move** — Finder's exact modifier grammar — and each is a no-op where its behavior is already the default. The badge tracks the effective operation live as the cursor crosses a board boundary, which means the operation is a function of (source board, board under the cursor, modifiers) sampled every frame, not a decision taken at pickup.
Two carve-outs:
- **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a clean reorder and the badge never shows copy. The within-board lane duplicate exists, but its home is the clipboard (04-interactions.md ▸ Clipboard, Lane paste) — the usual shape, where the keyboard path is canonical and the drag is the enhancement.
- **A lane copy strips tombstoned cards**; a lane **move** carries them whole, and they land in the destination's trash by rendering. Copies transfer content, and trash isn't content (09-templates.md's instantiation precedent).
Geometry does not change across the boundary. The destination board's own resting layout answers the proposal, in the destination's own `standard` and gap; the arriving run's span is its unit counts measured against the destination's standard. What changes is only which commit runs and on which store — see below.
**"Sampled every frame" means sampled on every drop callback, and drop callbacks arrive only while the mouse moves.** A modifier pressed against a perfectly still pointer therefore reaches nothing: the operation decides what the *source* board's resting layout holds (a copy leaves its originals standing — see Resting-layout zones), whether the trash column takes the drop at all, and which index space the shadows are counted in, and all three used to wait for the next twitch of the mouse. So a `.flagsChanged` watch runs for exactly the drag's lifetime — armed at `begin`, stopped at `end`, which is where every way a drag can finish already funnels — and publishes a counter; the board window under the cursor turns that counter back into **the one shared retarget** (`BoardDropContext.retargetAfterModifierFlip`), the same seam the autoscroll driver's every scroll step goes through. The flip carries no location and needs none: the retargets read the physical mouse, so a stationary pointer is simply the cursor they already read. The window that answers is the one whose surface resolved the standing proposal — recorded per retarget as a `LaneDropRegistry`, which is per board *window* — so a cross-board drag re-proposes against the board being hovered and never against the one it came from. A settled release ignores flips entirely, the same freeze the committed-overlay hold applies to `propose` and to the operation itself.
## The drop commits
The commit is the store's, and it is one `performWrite` bracket per gesture whatever the set's size: one app-mediated reload, and (on git boards) one commit rather than N. Every one of them takes an index counted **against the destination's rendered items as the resting layout showed them** — so the number the geometry produced is the number the writer consumes, unrewritten. For every move that means "with the dragged run removed"; for the within-board ⌥-copy it means "with the originals still there", because a copy leaves them there and the zones counted them (see **Resting-layout zones**). Cross-board arrivals never face the question: the destination never held the originals.
| Gesture | Store method | Writer |
| --- | --- | --- |
| Within-board card drag | `moveCards(_:toLane:at:)` | `moveItem` per card — same parent degrades to a rank rewrite |
| Cross-board cards | `receiveCards(_:operation:toLane:at:)` on the **destination** store | `copyItem` / `moveItem` |
| Cross-board lanes | `receiveLanes(_:operation:at:)` on the **destination** store | `copyItem` + tombstone strip / `moveItem` |
| Trash → live lane, same board | `restoreByDrag(cardID:intoLane:at:)` | `restoreItem` then `moveItem` |
| Trash → another board | `receiveRestoredCards(_:operation:toLane:at:)` | `copyItem`/`moveItem` then `restoreItem` |
Three properties of that table are load-bearing:
- **Ranks are inserted, never permuted.** A drop writes only the dragged items' `order` — the siblings' files are not touched, so `modified` (and a git commit) stays honest about what actually moved. `Ranks.insertionRanks(amongVisible:at:count:)` produces the N ranks the contiguous run needs; `nil` from it is the renumber trigger, exactly as an exhausted midpoint is everywhere else, and the fallback compacts the destination and places again (`moveLane`'s and `sortSelection`'s pattern).
- **A copy's ranks are computed against the destination's *full* rendered set**, because the originals stay and are still on disk holding their ranks. A move's are computed against the set the moving members vacate. One expression covers both: the ranks are placed among the rendered cards minus whatever will actually leave — which is also, exactly, the layout each gesture's zones were built over, so the index needs no translation on the way in.
- **Cross-board writes are executed by the destination store**, inside *its* bracket. The source board's tree changes outside its own store's bracket, which is correct and needs no coordination: the source store's watcher sees a foreign change and reloads, which is what a foreign change is.
Identity follows 01-storage-format.md exactly. A copy mints fresh UUIDs at every level and keeps `created` (a copy is a fork). A move keeps the UUID; only the **import boundary** remints, per folder, at the finest grain — a lane arriving with one colliding card is still a lane move with one reminted card.
## The mid-drag re-grounding trio
**A foreign reload mid-drag re-grounds the drag, never corrupts the drop** (04-interactions.md ▸ Drag and drop, settled — a two-second drag racing agent edits is the designed concurrency). Three rules compose, and each one is a property of something already in this document:
1. **Geometry re-derives.** The only inputs frozen at drag start are the *dragged items'* sizes and nothing else; the resting zones are recomputed against each new snapshot. A foreign lane add or tombstone re-divides the strip, the zones move with it, and the next proposal targets the board as it now is. Nothing is cached across a reload because nothing needs to be.
2. **Proposals re-validate by liveness.** A proposal whose target lane was tombstoned or vanished in the reload is invalidated — tombstoned lanes are never drop targets — the shadow withdraws, and no proposal stands until the pointer reaches a live target. **Release with no valid proposal cancels**: items return, nothing is written, and a card is never filed under a `deleted:` parent. The store's commits enforce the same rule independently (a destination lane that is gone or tombstoned is a silent no-op), so the gesture and the write cannot disagree.
3. **An emptied drag cancels itself.** Drag membership is a UUID set that vanished items leave silently (02-architecture.md); when the *last* dragged item leaves it, the replica dissolves and release is a no-op. Partial vanishing drops the survivors, matching the pending-cut precedent.
## The committed-overlay hold
At release the write goes to disk and the *snapshot does not change*. The watcher's bracket closes, a reload runs, and only then does the board show the new order — one-way flow, deliberately (02-architecture.md). In between, for one round trip, the snapshot still describes the pre-drop arrangement.
That gap is what makes "the replica flies to its slot" hard to reconcile with the one-way flow: the slot it should fly to is a position that does not exist yet, and dropping the drag state at release would snap every sibling back to the pre-drop layout for a frame before the reload lands.
The resolution is the **committed-overlay hold**, and it is the new-card placeholder's `awaitingArrival` precedent applied to the drag: at release the session flips from *proposing* to *committed*, keeps rendering the arrangement it was showing, and stands until the app-mediated reload that carries the write arrives — then hands off and dissolves. The hand-off condition is the same shape as the placeholder's: the overlay watches the snapshot for the state it is standing in for, and discards itself the moment the snapshot has it, because holding a moment longer would draw the arrangement twice.
Like the placeholder, it is store-transient overlay state and a **named exception** to the one-way flow rather than a hole in it: it renders nothing that is not already on disk or already refused, and every failure path — a write that throws, a reload that fails, a session emptied mid-flight — dissolves it and lets the snapshot be the authority again. The banner says what went wrong; the board shows what is true.
This is the drag session's mechanism, not the math's — it belongs to the same milestone's second half.
**Motion never feeds back into logic** (03-board-ui.md § Motion, a hard constraint, inherited from the pathfinder's rule of the same name). Every proposal change animates a reflow (~0.18s). During that window, anything *measured* is mid-flight: item frames, the placeholder's frame, and even the drop location reported by the system (it is expressed in the drop target's space, and that view may itself be moving). Retargeting from measured values while the board animates produces garbage zones and a proposal that thrashes — the shadow chases the cursor and all hysteresis is lost. Three rules follow:
- **Compute resting zones analytically, never from measured frames.** The strip's layout is a closed form over `(stripWidth, gap, unit counts)`; the masonry's is a closed form over `(columnCount, columnWidth, spacing, frozen heights)`. Both are stable no matter what is animating.
- **Read the cursor from the physical mouse** (`NSEvent.mouseLocation`, converted through the window), not from the drop callback's location. This is also what `LaneResizeSession` and `MarqueeSession` already do.
- **Freeze the dragged items' sizes at drag start.** The pickup transition fires geometry updates while the dragged item lifts and scales; its lingering "last measured frame" is a few per cent off, which would mis-size the shadow and the span-cap.
One lifecycle trap in the same family, recorded because the pathfinder paid for it: a finished session's phase events can be delivered *after the user has already started the next drag*, and a naive cleanup handler wipes the new session's state (no shadow, drop dead). Cleanup on session-phase events must be gated on the physical button being up; a mouse-polling watchdog remains the guaranteed termination path.
SwiftUI/macOS delivers a drag session to the **deepest drop region under the cursor — with no fall-through**, not even when that target's declared content types don't match the session's payload. A region whose topmost target only understands one drag type is therefore a *dead zone* for the other type: no hover callbacks, and a release there snaps back instead of committing.
Consequence: every drop delegate must accept **all session types** — card, lane, and external Finder file drags (04-interactions.md ▸ Drag and drop: files onto a card become attachments, files onto lane empty space become cards) — and route internally. Without file support at every fall-through layer, the same dead-region hit-testing bug would strand a file session: it would fall through to a target that only declares the board's own types, get no hover callbacks, and refuse the drop outright, with no highlight and no snapback to explain why.
The lane body's delegate resolves card sessions against its masonry zones, forwards lane sessions (cursor converted to strip space) to the strip's logic, and resolves file sessions against the same masonry zones; the strip delegate (gaps, margins, placeholder regions) retargets lane sessions, retargets card and file sessions against an analytically reconstructed per-lane grid — the safety net for a lane whose own drop region goes dead — and commits the current proposal on release: the drop always lands where the shadows show. Shadow placeholders are hit-transparent, so the strip target stays live beneath them.
## Edge autoscroll
A lane's cards live in a scroll view, so a lane taller than its viewport has landing spots below the fold. Nothing in the model above can reach them — the proposal is a function of the cursor over the *visible* resting layout — so a card session hovering near either end of a lane's scroll area scrolls it, continuously, until the pointer leaves the band or the drag ends. Implementation: `Kanban/UI/Board/DragAutoScrollMath.swift`, tests in `KanbanTests/DragAutoScrollMathTests.swift`.
The geometry is a pure function of viewport-local coordinates: each end of the visible extent owns a 56pt **activation band**, and a pointer inside one scrolls that way at a speed ramping linearly from 90pt/s at the band's inner edge to 800pt/s at (and beyond) the viewport's own edge. Outside both bands the velocity is exactly zero, so a drag crossing a lane's middle never scrolls it. The floor at the band boundary is deliberate — entering a band should produce visible motion, not an imperceptible crawl.
The pointer may also sit outside the visible area and still drive it: generously above and below (a lane's header and the strip's padding are still "this lane"), but only ~12pt sideways, so a drag over the neighbouring lane never scrolls this one.
Three constraints shape the driver, which is the session's half of the work:
- **The pointer is the physical mouse**, partly for the general reason above, but mostly because drop callbacks only arrive while the mouse *moves*, and holding still against an edge is exactly the gesture that must keep scrolling. A frame clock plus `NSEvent.mouseLocation` needs no events at all.
- **Every scroll step re-resolves the proposal.** The cursor is stationary in the lane's space while the *content* moves under it, so without this the shadow would freeze at whatever slot the last mouse movement proposed and the drop would land there. The lane's drop delegate and the autoscroll driver must go through one shared retarget, so they can never disagree.
- **Termination is structural**, like the rest of the session lifecycle: the driver is a `.task(id:)` keyed on the session, so it is cancelled the moment the session ends — and the watchdog guarantees that flag clears no matter how the drag finished.
The clock is the display's. Frames arrive from a `CADisplayLink` on whichever screen the lane is drawn on (`NSView.displayLink(target:selector:)`, bridged to an `AsyncStream` the `.task` above consumes), and each step integrates the gap between two `targetTimestamp`s, clamped at 50ms so a link resuming from an occluded window steps once rather than teleporting the lane. The 16ms `Task.sleep` loop this replaced had two defects that reach the user as judder: `Task.sleep` guarantees only a *lower* bound on the wake-up, and a fixed ~60Hz cadence beats against a 120Hz panel instead of landing on it. Both arrive at the shadow as well as at the content, because every scroll step drags a retarget behind it.
The board strip itself has nothing to autoscroll: every lane is always visible (the window width divides across the lanes' width units) and the strip fills the window height, so there is no board-level scroller in either axis. The geometry above is axis-agnostic and would serve one unchanged if that ever changes.
## Adjacent interaction: the lane resize drag (not a drag session)
Dragging a lane's trailing edge resizes it between whole unit counts — see `LaneResizeSession.swift` and `LaneLayoutMath`. It deliberately lives OUTSIDE the drag-session machinery above: the handle is a plain `DragGesture`, carries no drop target, and refuses to start while a card/lane session is in flight. Its layout trick inverts this document's premise: instead of reflowing siblings around a shadow, the session freezes the strip's standard width and resizes the *window* by one standard-plus-gap per snap tick, so every other lane keeps its exact pixels and the release settles the dragged lane into a slot that's already in place. Snapping is asymmetric ("shadow leads"): tick up the instant the live edge clears the inter-lane gap; tick down only after retreating 10pt back into it — the 10pt re-entry band is the only hysteresis, cousin to the dead-region hold above.
It does borrow one thing from the machinery it lives outside: **the release gets its own hold** (`LaneWidthHold`). The release writes the new unit count, and that write takes the one-way flow's round trip, so a session that cleared there would hand the strip back to a standard divided from the already-grown window by the snapshot's still-stale unit total — a visible two-step, once into the wrong arrangement and again when the echo lands. So the frozen standard and the *written* unit count keep governing until the snapshot carries that width, with the same timeout-and-dissolve guarantee as the committed-overlay hold. The condition is the only real difference, and it follows from the subject: that hold stands in for an arrangement and any landing on the board retires it, while this one stands in for a value and only a landing that actually carries it will do.
Golden fixture boards for the storage-contract test suite — real on-disk folder trees, not inline strings, so the same fixtures can later drive XCUITests via the `--open-board` launch hook.
Golden fixture boards for the storage-contract test suite — real on-disk folder trees, not inline strings, so the same fixtures can later drive XCUITests via the `--open-board` launch hook.
Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`.
Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. Tests live in `KanbanTests/FixtureBoardTests.swift`.
Hidden fixture files are named `.hidden-*` rather than `.DS_Store`: the repo's `.gitignore` ignores `.DS_Store` everywhere, so a fixture spelled that way would exist on the authoring machine and vanish from a fresh clone. The loader's rule is `.skipsHiddenFiles` — it is about the leading dot, not the name.
Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never generated at test time), chosen so their lexicographic order matches the expected tie-break order — usually a leading digit (`10000000-...`, `20000000-...`, …) so folder order reads the same as array-index order in the tests. That is a fixture-authoring convention, not the loader's gate: the identity predicate is shape-only (`8-4-4-4-12` hex, **any case, any version** — 01-storage-format.md § Fractal layout ▸ Rules), and the case/version coverage lives in `KanbanTests/BoardLoaderTests.swift` rather than here.
## Valid/ — one board per tolerated/valid case
| Board | Case |
|---|---|
| `rich-board.kanban` | A full-breadth well-formed board: 2 lanes, 3 cards, bodies, styling (background/icon/iconColor/width), unknown + reserved frontmatter keys, `attachments/` and `comments/` with real content. Its `attachments/` also carries all four listing shapes — two ordinary files, a hidden one, and a subfolder with a file — so `Card.attachments`' flat rule (01-storage-format.md § Attachments) is asserted against a real tree. Also the board every `index.md` in the tree is round-tripped against. |
| `interrupted-create.kanban` | The motivating skip-not-error case: a UUID-shaped lane folder and a UUID-shaped card folder, each with no `index.md` yet (folder created, write not yet landed). |
| `non-uuid-strays.kanban` | Non-UUID-shaped folders at both lane and card depth, with and without `index.md` — name shape gates candidacy before the file is ever read. |
| `stray-files.kanban` | Stray (non-directory) files at board, lane, and card level — never level candidates, never warned about. The **card-level** one (`scratch.md`) is also the loose-file carve-out's golden case: tolerated everywhere else, it is reported in `LoadResult.looseCardFiles` for the app to relocate into `attachments/` (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28). Detection is read-only, so the file stays put on disk. |
| `tombstones.kanban` | A tombstoned lane and a tombstoned card, both still on disk and still in the snapshot, flagged (`isDeleted`) rather than removed. Also proves a tombstoned lane doesn't recursively flag its own un-deleted children. |
| `duplicate-order-tie-break.kanban` | Three cards sharing one `order` in one lane, and two lanes sharing one `order` — both broken by folder name, ascending. |
| `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. |
| `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `width: 1.5`, and `background: 12345` — a bare scalar, which `background` no longer has a reading for at all), plus `background: {x: 1}` — a legal mapping naming neither subkey, so no color and no trace — and a `deleted` with an unusable timestamp that still tombstones. |
| `duplicate-top-level-keys.kanban` | A top-level key written twice — at board, lane (the strict `order` field), and card level. Last occurrence wins; **not** a fail-fast case (settled, newer than the original card text). Round-tripped to prove the earlier occurrence survives on disk, invisible only to reads. |
| `board-level-deleted.kanban` | A board-level `deleted:` key — legal per the frontmatter table but meaningless; ignored + warned, rest of the board loads normally. |
| `optional-keys.kanban` | `order` and `schema` optional below the board root (re-ruled 2026-07-31). One lane holds a ranked card plus every order-less shape — no key, an explicit null, `order: banana`, `order: .nan` — which all read as append-at-end in folder-name order; the strip holds a ranked lane, a `schema`-less one, and an order-less one. Also the golden case for the minimum agent card: a card whose whole frontmatter is a title. |
## Malformed/ — one board per fail-fast case
Each board is minimal: one broken thing. The two multi-defect boards at the bottom are the deliberate exceptions — they exist precisely because the loader collects rather than stops (01-storage-format.md § Malformed input, settled 2026-07-31), which is a claim no one-broken-thing board can make.
| Board | Case |
|---|---|
| `unparseable-yaml.kanban` | An unterminated flow sequence in the board's frontmatter. |
| `missing-schema.kanban` | Board root `index.md` has no `schema` key — the **root only**; below it a missing `schema` reads as 1 (`Valid/optional-keys.kanban`). |
| `board-root-missing-index.kanban` | The board root folder itself has no `index.md`. |
| `many-defects.kanban` | **The collect-all board.** A root with no `schema`, a card with `schema: 2`, and a lane whose frontmatter will not parse — three fail-fast classes on one board, reported as one `BoardLoadFailure` in walk order (root, then lanes by folder name with their cards inside them). The broken lane also holds a broken card, which is deliberately **not** in the aggregate: a broken lane takes its subtree with it, and the repair's re-check is what reveals what it was hiding. Two further lanes load fine, which is what proves the walk kept going. |
| `skippable-defects.kanban` | **The skip channel's board.**`many-defects.kanban` minus the root defect — the root is never skippable, so a board whose defects are all skippable must have an intact root. Skip both (the newer-schema card and the unparseable lane) and it opens: without them, without the broken lane's own perfectly valid card (the subtree goes too), and with a `LoadWarning.userSkipped` per skip as the loud mark the opened board's notice is written from. |
The four `order` boards that used to live here — `missing-order-lane`, `missing-order-card`, `explicit-null-order`, `non-numeric-order` — were retired on 2026-07-31, when `order` became optional below the board root. Their shapes all live on in `Valid/optional-keys.kanban` as coercion cases.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.