Compare commits

..
172 Commits
Author SHA1 Message Date
rzen eac1c02a7d The phone joins the format — KanbanMobile MVP: shared storage verbatim over an iCloud container
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
2026-08-07 22:49:36 -04:00
rzen c67c1037f1 The strip grows a Sync tab — a standing placeholder, nothing in it yet
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
2026-08-07 22:00:19 -04:00
rzen 7414fc8400 The popover/sheet split reverses — settings fold into the Git tab, the widget stacks name over branch
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
2026-08-07 21:48:02 -04:00
rzen 99ebb69a1d The Pro surface goes dormant — no section, no signpost, machinery intact
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
2026-08-07 20:21:35 -04:00
rzen 798a8bac73 The tier axis comes out of the git stack — compose unconditional, postures mode-driven
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
2026-08-07 20:21:28 -04:00
rzen da0d7fd2d7 Git leaves the paywall — the pivot ruling lands in the design corpus
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
2026-08-07 20:21:16 -04:00
rzen 93a3423e6b Give the single-branch switch picker its disabled explanatory row
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
2026-08-07 19:12:01 -04:00
rzen 7d7e892617 The Git tab fills in — the parked postures rehome whole, and the strip's membership is the posture
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
2026-08-07 19:11:55 -04:00
rzen 7ac34651a2 The board's symbol takes a tint — a 4×2 colour row under the picker's glyphs, and the glyph itself moves into the titlebar
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
2026-08-07 18:36:49 -04:00
rzen 3d231d6454 The Background tab becomes Theme — solid colors or patterns, presets only, chevron-paged
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 18:03:06 -04:00
rzen fb96e30df0 The Background tab fills in — facets rendered to order, eight hues in a carousel
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 16:08:29 -04:00
rzen 56e37be158 The regression suite selects the way the band does — defaultsSoleMember: false, and the budget's arithmetic told straight 2026-08-07 15:57:23 -04:00
rzen 9c857ae0cc Selected-ness rides down as a compared parameter — a marquee crossing repaints its faces, not the board
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.
2026-08-07 15:10:35 -04:00
rzen 5779da2b6c The Info tab fills in — the board's vital statistics in two honest registers
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
2026-08-07 13:58:15 -04:00
rzen c21c53be9c The board popover goes tabbed — Info, Background, Git, three empty rooms awaiting their own sessions
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
2026-08-07 13:34:23 -04:00
rzen 8aefaf23ce The empty provider was never load-bearing — the dragless layer frees the rubber band
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
2026-08-07 12:55:54 -04:00
rzen b0ffff1aa1 The app learns Appearance — Auto, Light, Dark from the View menu and a toolbar pull-down
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
2026-08-07 12:55:45 -04:00
rzen c686242a14 Worktrees join the ignore — session machinery, not project config
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:24:26 -04:00
rzen caaa0c0776 The repo gains its .claude — the opus48 fallback agent, and local settings stay local
.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
2026-08-07 11:24:17 -04:00
rzen ccf55f1d49 The backgrounds get a swatch gallery — sweep one of the bundled artwork exploration
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
2026-08-07 11:24:10 -04:00
rzen 38ff520ae9 The distribution strategy takes a charter — six workstreams around one inversion
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
2026-08-07 11:24:10 -04:00
rzen 42fb10aa65 The market research corpus lands — the broad pass, mapped
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
2026-08-07 11:24:10 -04:00
rzen 43f87a538b Where a drag actually spends its time — the release, not the hot path
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
2026-08-07 11:24:10 -04:00
rzen 1e21d8cd60 The combo's swatch takes a berth — a bar sitting in the field, not a fill
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
2026-08-07 11:23:54 -04:00
rzen 51cf994cb9 Lane selection sheds its slab — a hairline edge with a soft halo, intensity inverse to area
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
2026-08-07 11:22:36 -04:00
rzen f126614b56 The pickup seeds its own slot — the lift and the shadow become one transaction
"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
2026-08-07 11:22:26 -04:00
rzen eef0a4539f Clicks land the instant they happen — the empty-space gestures move behind the masonry
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
2026-08-07 11:22:16 -04:00
rzen cfee4a4b41 The board learns to zoom — eight rungs on one ruler, and Actual Size is the untouched board
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
2026-08-07 11:22:02 -04:00
rzen d5ad21c3da The board wears a picture — background becomes a mapping, and the window chrome follows it under a thin frost
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
2026-08-07 10:15:03 -04:00
rzen 190a8e36f1 Search takes the center — the titlebar placement grammar ratified
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
2026-08-06 22:13:55 -04:00
rzen 9766e1f61c The Colors panel joins the palette — the combo ratified, and each anchor composes the halves it needs
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
2026-08-06 22:05:48 -04:00
rzen 73698cd77b A reusable symbol picker — the board's glyph joins its name in the info popover
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.
2026-08-06 21:18:35 -04:00
rzen b1ea97c03e Locality means the board the drag was picked up from — a rename is not a departure
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
2026-08-06 21:02:47 -04:00
rzen 1980570b27 The popover's two post-split postures named — the explanatory row and the mode-none door
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
2026-08-06 21:02:47 -04:00
rzen b1aa0c4483 The parse memo's blind spot blessed on file — reachable, pinned, and still outside the app's care
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
2026-08-06 21:02:47 -04:00
rzen 13388776e1 The squeezed search field's two answers blessed — expand in place, and the platform's own row
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
2026-08-06 21:00:23 -04:00
rzen 2e0e8ae871 Three surface blessings said in sentences — depth in rounds, no root skips, the path carrier
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
2026-08-06 20:59:12 -04:00
rzen 639d9839bf The subtitle hides with the title, blessed — context survives everywhere but pixels
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
2026-08-06 20:57:47 -04:00
rzen e147e9cd96 The covering await baselines on bracket close — the racer window blessed academic
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
2026-08-06 20:56:50 -04:00
rzen d23ead4b27 Pro surfaces audit through a fixture-gated tier override — a toy sandbox, never a bypass
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
2026-08-06 20:54:18 -04:00
rzen d9b6232dea The lane plate's strays return to their anchors — the replica's wash leaves the height arithmetic it broke
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
2026-08-06 20:54:08 -04:00
rzen e1ffe71e5b A refused trash hover withdraws the trash's own promise — the fall-through holds only keepable ones
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
2026-08-06 20:48:45 -04:00
rzen 531ca2c595 Do-nothing guards read the pending truth — never-trust-memory is the render path's scope
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
2026-08-06 20:46:39 -04:00
rzen 3fc3dc629a A drop under an active filter counts in the rendered space — the visible anchor rule
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
2026-08-06 20:43:19 -04:00
rzen d9797acf57 The form rule and the banner rule compose — inline while the asking surface stands
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
2026-08-06 20:39:10 -04:00
rzen 3385ec61ee The two-counter split blessed — walks landed beside snapshots applied
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
2026-08-06 20:37:42 -04:00
rzen 3c64e5ae68 The skip consent rides the session — reloads never relitigate a standing decision
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
2026-08-06 20:36:15 -04:00
rzen bb936068db The environmental carve-out — no walk to collect from, the welcome row answers
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
2026-08-06 20:26:24 -04:00
rzen 0d8ecdb78b An unopenable repository fails loudly — the standing row, the paused surface, the honest heal
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 18:44:12 -04:00
rzen 52df210284 The fifth fail-fast class gets its row — a garbled schema is evidence, never a mint target
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 18:11:59 -04:00
rzen 26239200ea Denial is not absence — detection learns the unverifiable answer 06 ruled for it
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 17:53:23 -04:00
rzen 503ec4872c Window-of-origin banner hosting reaffirmed — the board strip was an interim, naming survives it
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 17:28:51 -04:00
rzen ea15d1ac74 Every comment-trash purge kneels to the ownership gate — the container-whole retirement retires
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 17:10:09 -04:00
rzen a60d97689e The identity writer appends as 06 promised — clearing is the one sanctioned edit
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 16:57:20 -04:00
rzen c60553f17f An alignment tick when the drop proposal lands somewhere new
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
2026-08-03 13:41:06 -04:00
rzen 43e6ad229e The drag replicas cast the shadow the pickup lift already promised
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
2026-08-03 13:33:52 -04:00
rzen 1ffb64913b A stationary modifier flip answers at the keystroke — the third client of the one shared retarget
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
2026-08-03 13:24:53 -04:00
rzen 1fc8aaa249 Autoscroll ticks on the display's own clock — the 16ms sleeping loop retires
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
2026-08-01 20:22:42 -04:00
rzen 0218ae4c21 Drag instrumentation — render counters, hot-path signposts, and the invariants that prove the gates
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
2026-08-01 20:04:01 -04:00
rzen 409f430813 The lane-resize release gets its own hold — the strip stops two-stepping through the stale snapshot
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
2026-08-01 19:33:33 -04:00
rzen f6105d4389 The marquee registry stops being observable — reflow writes cost a dictionary store and nothing else
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
2026-08-01 19:14:11 -04:00
rzen a51ad750ad The resting layout is built once per snapshot — retargets stop rebuilding it per mouse sample
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
2026-08-01 18:56:08 -04:00
rzen 84f909a720 Proposal changes stop rebuilding the whole board — lanes and card faces gate on their values
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
2026-08-01 18:28:33 -04:00
rzen ef423bb9d2 Every board root keys once at its store — the hover path stops walking the filesystem
`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
2026-08-01 17:55:48 -04:00
rzen 8a8ec4dfd1 The two-homes width rule becomes focused-width parity
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
2026-08-01 16:54:37 -04:00
rzen 8345378972 The board settings sheet — setup leaves the popover for a home a stray click can't dismiss
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
2026-08-01 12:07:12 -04:00
rzen 988a7245a3 Memoize the reload parse and short-circuit value-equal snapshots
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
2026-08-01 11:38:25 -04:00
rzen 5e6417e749 The board search item becomes the stock NSSearchToolbarItem — the field grows on focus
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
2026-08-01 11:16:58 -04:00
rzen 31fee00c73 The decision surface — a refused open becomes a live repair, in place
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
2026-08-01 10:52:02 -04:00
rzen 0933ac1b01 Every open passes through a real loading window — the walk moves off the main actor
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
2026-08-01 09:53:26 -04:00
rzen ba1726fa77 The loader collects every fail-fast defect and honors per-open skips
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
2026-08-01 09:12:49 -04:00
rzen 94e60cd444 The whole board-name area opens the popover — the chevron alone was the trigger
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
2026-08-01 08:28:53 -04:00
rzen 6f2e0d15da Card windows drop the title from the title bar — the body already says it
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
2026-08-01 08:10:31 -04:00
rzen 8206a78ecc Redraw a copy's originals at the source — the resting layout follows the operation
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
2026-08-01 08:07:54 -04:00
rzen adf4fc5973 README: optional below-root keys, the gitignore noise gate, and the true fixture count
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:45:02 -04:00
rzen 153d12558c Changelog: optional frontmatter keys and the seeded .gitignore join the 2.0 block
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:44:35 -04:00
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
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
2026-08-01 07:43:45 -04:00
rzen 16ef3779e8 Rule the session-commit await, switch-clears-stacks, and skip-purge decoupling
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 21:46:23 -04:00
rzen bc27a0cbd2 Bless the two-stack shape, the raw-apply omission, and name the coarse step
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 21:41:05 -04:00
rzen 01f1194a56 Gate the comment-trash sweep on live-step ownership
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 21:38:42 -04:00
rzen d076427ee0 Realign undo with the evening rulings — repo-nested and identity anchors
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
2026-07-31 21:19:42 -04:00
rzen 54951e92ef Changelog: window-session undo joins the 2.0 block
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 20:24:41 -04:00
rzen a381fac742 Make the card window the commit unit on Pro boards
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
2026-07-31 20:23:45 -04:00
rzen 9119aa1e9a Anchor session undo steps by card identity, not path
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 20:10:33 -04:00
rzen 25d2513ccc Repo-nested boards bind native undo in every tier
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 19:44:51 -04:00
rzen 71664dab02 Give card windows their own undo stacks and coarsen the close
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
2026-07-31 19:39:50 -04:00
rzen c0c741fe62 Bless the six 2026-07-31 minor findings
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 19:34:14 -04:00
rzen fecedab60d Split git setup out of the popover into a board settings sheet
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 19:22:45 -04:00
rzen 986347a95a Bless the reload parse memo and value-equal snapshot skip
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 19:15:51 -04:00
rzen cb87ea79c1 Gate relocation noise on a universally seeded .gitignore
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 19:06:14 -04:00
rzen 11981a2a99 Make order and schema optional below the board root
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 19:01:20 -04:00
rzen c1d4d7be7b Wishlist board export and reporting
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 18:55:31 -04:00
rzen 9a35b52261 Wishlist the board/lane/card nature idea
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 18:55:07 -04:00
rzen c6b5f6b691 Wishlist the AI-aided design template set
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 18:53:53 -04:00
rzen 2e4dde5655 Bind the undo provider to the board, not the tier
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
2026-07-31 18:53:38 -04:00
rzen 3bd6187b94 Settle the fail-fast decision surface design
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 18:44:41 -04:00
rzen bec75e4282 Realign code with the 2026-07-31 rulings
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
2026-07-31 18:35:07 -04:00
rzen 542ab169a3 Record the SHA-256 repository posture
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
2026-07-31 18:32:30 -04:00
rzen 889f7ff5ce Bless pack accumulation as housekeeping's accepted cost
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
2026-07-31 18:27:16 -04:00
rzen 34ee34aef6 Bless the housekeeping scheduling constants
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
2026-07-31 18:24:54 -04:00
rzen b8e4f83028 Bless the config writer/reader asymmetry
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
2026-07-31 18:21:07 -04:00
rzen 56fad6b3b0 Bless the identity fields' visibility-scoped config poll
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
2026-07-31 18:14:30 -04:00
rzen 92f4d386f4 Bless the failure-surface split for git operations
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
2026-07-31 18:13:14 -04:00
rzen 40746a0cbc Bless the failed abort keeping the operation stamp
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
2026-07-31 18:11:26 -04:00
rzen a5dbc88a3a Bless the 30-second bound on the git-lock waiting state
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
2026-07-31 18:10:17 -04:00
rzen 2dc008e39f Bless create-and-switch running the full switch sequence
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
2026-07-31 18:08:50 -04:00
rzen 5d946a1076 Bless the synchronous flush-before-overwrite commit
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
2026-07-31 18:06:42 -04:00
rzen 167d3de68a Git-operation failures join the one-shot failure banner tier
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
2026-07-31 17:57:34 -04:00
rzen 4105d1ae7d Restore subjects compose the inverse instead of nesting
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
2026-07-31 17:51:11 -04:00
rzen 0a1b5f657a The root commit is the undo stack's floor, not a step
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
2026-07-31 17:49:30 -04:00
rzen ade7d34cd8 Land the 2026-07-31 design-ruling sweep
Uncommitted ruling prose from the pro-m1 sessions, committed as found:
trash sorts newest-first by modified stamp (no rank minting); kind-blind
trash selection; native undo in every tier with the provider following
the board; session-coarsening for card-window stacks; column-major
masonry; changed-path channel (02); window-scoped comment-thread heals;
commit-message vocabulary growth; integrity commit author; signature
passed per-commit instead of repo config; repo-state validation
tightening; comments pane defaults on; draft close-failure guard;
deferred comment-trash purge; 2.0 ships only with pro-m1 (RELEASE.md).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 17:49:18 -04:00
rzen b8667699ae Changelog: nine git-history entries join the 2.0 block
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 17:08:12 -04:00
rzen a7f35a0e5a Implement repository hygiene
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
2026-07-31 17:06:57 -04:00
rzen 1f7d84bf64 Build branch switching and the popover git surface
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
2026-07-31 16:41:14 -04:00
rzen 142c6e75fe Implement undo and redo as forward commits
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
2026-07-31 15:54:22 -04:00
rzen 563999655f Build the semantic commit-message engine
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
2026-07-31 14:54:17 -04:00
rzen 3c07c26fda Build the auto-commit engine
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
2026-07-31 14:10:55 -04:00
rzen 189af238a1 Build HistoryStore — opt-in git and mode detection
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
2026-07-31 13:18:07 -04:00
rzen 9f8eebe23b Extract the pathfinder's git-operations doc
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
2026-07-31 11:59:01 -04:00
rzen f174a524af Masonry goes column-major — cards read top-down, then across
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
2026-07-31 07:34:30 -04:00
rzen 95133860e1 Drag pickup: evaluate a lane's slots once per body, not once per card
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
2026-07-31 07:17:50 -04:00
rzen 015e539b51 Changelog: five comments entries join the 2.0 block
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 21:31:47 -04:00
rzen 9588f7b1f0 Comments, phase 3 — search, the thread find, announcements, and a11y
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
2026-07-30 21:30:22 -04:00
rzen fe3ffac48e Comments, phase 2 — the pane, the composer, and the inline session
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
2026-07-30 20:19:52 -04:00
rzen f68ac3668e Comments, phase 1 — storage, writer primitives, and the undo inventory
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
2026-07-30 19:36:21 -04:00
rzen e6dd4c0aa6 Changelog: the Pro subscription line joins the 2.0 block
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 18:16:46 -04:00
rzen 3b19883593 Lanework Pro is a subscription — the tier seam, StoreKit 2, and Settings
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
2026-07-30 18:14:58 -04:00
rzen 2c6b8fe63a Remove the App Group wholesale — one sandbox, one bookmark, one flag
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
2026-07-30 17:46:32 -04:00
rzen 092300c7d2 Collapse the edition split — one target, twins merged, verify-editions retired
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
2026-07-30 17:25:50 -04:00
rzen f7c8088783 Lanes delete into the trash — rendering, grammar, drag, clipboard, a11y
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
2026-07-30 17:04:30 -04:00
rzen 8014bde7c6 Lanes delete into the trash — storage, loader, writer, and undo
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
2026-07-30 16:16:57 -04:00
rzen 785ef5fe14 Realign code with the 2026-07-30 findings-resolution rulings
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 15:13:31 -04:00
rzen 9ca8ed84de 08's guide description goes present-tense — per-version changelog retired
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 15:12:22 -04:00
rzen 0bec9a6be5 Collapse the edition split — one app, Pro as a subscription
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 15:04:09 -04:00
rzen c741b02016 The unified log is the coerce stream's consumer — promotion is manual
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 14:43:54 -04:00
rzen 2ec2c95a63 Ratify the registry freshness stamp — two editions, one file, stat-checked
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 07:20:43 -04:00
rzen ae1dd96af6 Delete Immediately is removed — permanence is only reachable inside the trash
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
2026-07-30 07:17:11 -04:00
rzen 69084fdff7 Realign code with the 2026-07-29 findings-resolution rulings
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
2026-07-30 06:49:11 -04:00
rzen 5ae48de0ea Reorders don't stamp; container changes do — the moves-don't-stamp rule refined
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
2026-07-30 02:15:38 -04:00
rzen 90954a47d6 Disown AppKit window restoration — launches present welcome again instead of a windowless shell
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
2026-07-29 22:48:04 -04:00
rzen 546ed94412 The pasteboard UTType constants become edition twins — Pro stops claiming ownership it doesn't have
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
2026-07-29 21:46:27 -04:00
rzen 27158a06cd Resolve the second-pass review findings — comments undo via .trash recursion, search reach, refuse-don't-degrade paste
Thirteen findings cleared with the user (Redesign board: 3 Contradictions + 3 Gaps + 7 Minors → Resolved):

- User template store re-homes to the shared App Group container (09/02) — templates cross editions; App Group implementation card annotated mid-flight.
- Comment delete = move into comments/.trash/ (01/05/13) — the materialized-trash pattern one level down; no byte capture returns on any edition; purge at window close, copies strip it; 13's absolutes stay true.
- Find & search: card-window ⌘F covers the focused comments pane; board search re-ruled to all meaningful card content — comment bodies via a search-owned transient index when comments ship, attributes as they activate (04/01/05); scope options = WISHLIST #10.
- Comments pane visibility = one persisted app-wide bit, à la Show Trash — content-derived auto-show retired (05/11).
- Comment attachments author in-window: composer/inline-edit drop carve-outs + paperclip (05).
- Item-level copies sever remote/remote-state at every level; whole-board forks exempt (01).
- Paste refuses loudly, never degrades — degraded embedded-index.md fallback and its loss row retired; Finder's whole-or-nothing invariant adopted (04/12/02).
- Escape: composer moves focus out (never discards a draft); inline comment edit = Cancel (05/11).
- Minors: 05 three-pane changes bullet, 01 comments-UI parenthetical, 07 TOFU store naming, WISHLIST item 3 trash wording, README deferred-list now points at the findings board.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-29 21:46:01 -04:00
rzen bebbc877db The held window chrome survives detach — toolbars and the board-info widget render again
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
2026-07-29 21:32:20 -04:00
rzen 566deab506 Home app-side state in the shared App Group container
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
2026-07-29 20:18:15 -04:00
rzen a99e1a52f0 Comments card-window UI designed — three componentized panes and the .draft composer
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
2026-07-29 18:48:08 -04:00
rzen 0463540aea Dedupe duplicate ids and heal them silently
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
2026-07-29 18:19:52 -04:00
rzen 68fa503250 Comments storage schema specified — fractal folders, chronological, window-scoped
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
2026-07-29 18:17:51 -04:00
rzen f153e79156 The dedupe universe includes .trash/ — and the container boundary is the first tie-break
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
2026-07-29 18:00:58 -04:00
rzen 61c31c0c5d Restore the trash marquee full-height clause the rewrites dropped
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
2026-07-29 17:59:58 -04:00
rzen 05331b4a87 Retire Tombstone from 11's ⌫ row; give Select All its trash clause
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
2026-07-29 17:59:23 -04:00
rzen 115409b553 Fail-fast is healer-gated — refusal is the last resort and no longer a dead end
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
2026-07-29 17:58:46 -04:00
rzen b1db43137a Delete Immediately joins card and lane context menus as Delete's ⌥-alternate
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
2026-07-29 17:57:28 -04:00
rzen 683c3bfb76 03's toolbar Undo/Redo disable is edition-aware — no undo provider in the running edition
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
2026-07-29 17:56:31 -04:00
rzen feae6d07d6 Copies are transactions; coerce-tier fallbacks log — and the leniency doctrine is stated
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
2026-07-29 17:55:42 -04:00
rzen f80f838620 Both editions installed is a supported steady state — handler, races, clipboard ruled
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
2026-07-29 17:43:27 -04:00
rzen 480336bc69 Heal commits are transparent to undo — the bounce is bounded to one, post-relaunch only
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
2026-07-29 17:35:03 -04:00
rzen 4c9528f158 Every open passes through a pre-snapshot loading state — the window appears immediately
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
2026-07-29 17:32:24 -04:00
rzen abadf11929 Displacement is level-uniform — a file squatting attachments heals like one squatting .trash
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
2026-07-29 17:18:16 -04:00
rzen c42e2e5447 App-side state crosses editions through a shared App Group — homed there from day one
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
2026-07-29 17:16:52 -04:00
rzen 176c8520fc Reliably healable repairs run silently — the user-gated Repair banner retires
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
2026-07-29 17:10:29 -04:00
rzen 1d1796a7f1 Moves don't stamp — order-only rewrites leave modified and modified-by alone
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
2026-07-29 16:45:45 -04:00
rzen 0e4fc525e0 Purge the lanes-in-trash residue — 11's Delete rows, 06/07's card-only phrasing, 02's counts
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
2026-07-29 16:37:29 -04:00
rzen 3a9db2e78b Build the integrity service - IntegrityRules and the HealScheduler
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
2026-07-29 15:45:48 -04:00
rzen 0d846c634e Agent guide v6 - warn against lane-glob moves
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
2026-07-29 14:32:31 -04:00
rzen 62c47a2209 Claimed-name squatters heal by displacement — Lanework owns the board
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
2026-07-29 14:24:07 -04:00
rzen bf559bbbb8 Healing mutations commit separately — the heal class of the two-commit split
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
2026-07-29 14:09:28 -04:00
rzen a2bde31290 Promote kind to the common schema — written at creation of every object
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
2026-07-29 14:05:16 -04:00
rzen 1b02f6e063 Formalize the integrity taxonomy and service — the validator design pass
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
2026-07-29 13:59:46 -04:00
rzen aad8857ea0 Set up distribution and release
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
2026-07-29 13:43:26 -04:00
rzen f34707e17e Build the end-to-end UI test pass
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
2026-07-29 13:40:18 -04:00
rzen 71b112d04c Card drag preview renders at the source face's laid-out width
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
2026-07-29 13:02:57 -04:00
rzen f83385e79f Lane drag preview attaches at the grabbed title bar, not its middle
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
2026-07-29 13:02:57 -04:00
395 changed files with 77622 additions and 5489 deletions
+7
View File
@@ -0,0 +1,7 @@
---
name: opus48
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.
+16
View File
@@ -0,0 +1,16 @@
# Copy to .env.release (gitignored) in the app's repo root and fill in.
# Used by Scripts/release.sh and the asc-*.swift scripts.
#
# These are account-level — the same Apple developer account and API key
# serve every indie project, so copy this file (as .env.release) from an
# existing sibling project rather than filling it out from scratch.
#
# Apple Developer team that signs the build (same as DEVELOPMENT_TEAM).
APPLE_TEAM_ID=C32Z8JNLG6
# App Store Connect API key (App Store Connect > Users and Access > Integrations > App Store Connect API).
# Create a key with "App Manager" access, download the .p8 ONCE, and store it somewhere safe.
ASC_KEY_ID=XXXXXXXXXX
ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Absolute path to the downloaded AuthKey_XXXXXXXXXX.p8 file.
ASC_KEY_PATH=/Users/rzen/.appstoreconnect/private_keys/AuthKey_XXXXXXXXXX.p8
+7
View File
@@ -9,3 +9,10 @@ xcuserdata/
# macOS # macOS
.DS_Store .DS_Store
# App Store credentials (appstore-publish skill)
.env.release
# Claude Code — personal permission grants stay local
.claude/settings.local.json
.claude/worktrees/
+68
View File
@@ -1,3 +1,23 @@
**August 2026**
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 three tabs — Info with the board's vital statistics, Theme for backgrounds, and Git.
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**
Version 2.0: Lanework's first release — a kanban app whose boards are ordinary folders of Markdown files on your Mac. Version 2.0: Lanework's first release — a kanban app whose boards are ordinary folders of Markdown files on your Mac.
@@ -16,6 +36,8 @@ Deleted cards land in a trash lane you can show beside your lanes; drag a card o
Undo and redo cover every board action, including bringing back a deleted lane with all its cards. 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. 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. Search filters the board as you type, and new cards you create clear the filter so they never vanish under it.
@@ -27,3 +49,49 @@ The whole app works from the keyboard — arrow-key navigation, drag-free card m
VoiceOver reads boards as lanes of cards, announces outside changes in one polite digest, and names a focused card that was deleted externally. 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. 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.
Every board can now gain git-backed history — commits, undo and redo as history entries, and branches — with remote sync still to come.
The board popover can now add git version history to any board, with no git installation needed.
A board that's already a git repository opens with its history live automatically — cloning a shared board just works.
Every settled change on a git board becomes a history entry with a plain-language description, like *Move card 'Fix login' to Doing*.
Edits from agents and other apps are recorded under their own authorship, so a git board's history always says who changed what.
Undo and redo on git boards restore earlier states as new history entries — nothing is ever erased — and the undo trail survives relaunching the app.
Every card window on a git board gains a History section listing the changes that touched that card, newest first.
On git boards, everything done in a card window becomes a single history entry when the window closes.
Switch or create branches from the board popover, with an explicit save-or-discard step protecting unsaved card edits.
The name and email on a board's history entries are editable in the board popover and stored in the board's own repository.
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.
+63
View File
@@ -0,0 +1,63 @@
{
"identifier" : "A9F1C4E2-7B30-4D6A-9E51-1C7D2B8F0A34",
"nonRenewingSubscriptions" : [
],
"products" : [
],
"settings" : {
"_askToBuyEnabled" : false,
"_billingIssuesEnabled" : false,
"_disableDialogs" : false,
"_failTransactionsEnabled" : false,
"_locale" : "en_US",
"_storefront" : "USA",
"_storeKitErrors" : [
]
},
"subscriptionGroups" : [
{
"id" : "20250730",
"localizations" : [
],
"name" : "Lanework Pro",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "2.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "2025073001",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Git-backed board history and sync.",
"displayName" : "Lanework Pro",
"locale" : "en_US"
}
],
"productID" : "dev.rzen.indie.kanban.pro.monthly",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Lanework Pro Monthly",
"subscriptionGroupID" : "20250730",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
}
],
"version" : {
"major" : 4,
"minor" : 0
}
}
+2 -2
View File
@@ -20,9 +20,9 @@ The defining consequence: **anything that can read and write files is a first-cl
4. **Native to the bone.** SwiftUI, macOS conventions (Finder-style rename, ⌥-drag copy, package documents, real windows), no web tech, no JS runtime. 4. **Native to the bone.** SwiftUI, macOS conventions (Finder-style rename, ⌥-drag copy, package documents, real windows), no web tech, no JS runtime.
5. **Agents are users, not integrations.** The schema, the agent guide, and the tolerance rules are designed for programmatic writers from day one. 5. **Agents are users, not integrations.** The schema, the agent guide, and the tolerance rules are designed for programmatic writers from day one.
## Editions ## Tiers
Lanework ships as three editions from one codebase and one format (12-editions.md): **Lanework** (no git; macOS-native undo — 13-native-undo.md), **Lanework Pro** (git-backed history, branches, remote sync — 06/07), and **Lanework Teams** (Pro plus tracker integration over the reserved enhanced schema; deferred). Separate Mac App Store apps sharing the `.kanban` UTI — any board opens in any edition. The deeper reason for the split: history and sync live behind a provider seam, so Teams' sync can be backend-agnostic (git *and* trackers) instead of git being load-bearing everywhere. 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
File diff suppressed because one or more lines are too long
+17 -14
View File
@@ -20,7 +20,7 @@ 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 — **for rendering** (settled scope): the snapshot is only ever built from disk, never from memory of what the app meant to write — this is what makes external editors and agents first-class. Provenance is a separate, downstream concern: the **EchoLedger** (Components below) remembers what the app wrote so commit attribution (06-history-undo.md) and VoiceOver announcements (10-accessibility.md) can tell the app's own echo from a foreign change — without the render path ever trusting memory over disk. One-way flow: **files → watcher → loader → store → views**. User actions go through a Writer that mutates files; the change comes back around through the watcher like any external edit. The app trusts its own writes no more than anyone else's — **for rendering** (settled scope): the snapshot is only ever built from disk, never from memory of what the app meant to write — this is what makes external editors and agents first-class. Provenance is a separate, downstream concern: the **EchoLedger** (Components below) remembers what the app wrote so commit attribution (06-history-undo.md) and VoiceOver announcements (10-accessibility.md) can tell the app's own echo from a foreign change — without the render path ever trusting memory over disk. **The 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. **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. 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.
@@ -30,19 +30,21 @@ The **one named exception** is transient UI state rendering things that don't ex
- **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, 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.) - **BoardWriter** — every mutation (create, move, reorder, delete, style) as an explicit filesystem operation. No hidden state; a write is done when the file is on disk. (The EchoLedger's receipts are not this bullet's "hidden state": a receipt describes a *completed* write, and the ledger lives beside the Writer, not in it — no write is ever pending in memory.)
- **EchoLedger** — the write-provenance ledger (the "Writer/echo machinery" that 06-history-undo.md ▸ Interaction with external writers — its Commit attribution rule — and 10-accessibility.md ▸ Live board announcements consume; settled). Every BoardWriter operation drops a receipt of its expected on-disk outcome before returning: path → content hash for writes (attachment imports hash during the copy — the bytes stream through the app anyway), an absence marker for deletes, an old→new pair for folder moves; a newer app write to the same path supersedes the receipt. Classification runs per observed changed file in a debounce window: current on-disk content matches the receipt → **app-mediated**, receipt consumed; no receipt, or mismatch → **foreign**. Final content deciding is what settles the races: an agent writing byte-identical bytes over a fresh app write matches and classifies app-mediated — with identical bytes the misattribution is unobservable in the tree, accepted; a foreign edit landing on an app-written path inside the same window misses the hash and the file classifies foreign — last writer wins the file, the app's subsumed intermediate never separately recorded (the diff compares snapshots, not a journal — 06-history-undo.md). Consumers: the auto-committer's author field and two-commit split (06), and the announcement filter (10) — on no-git boards the ledger runs identically with the announcer as its only consumer. **In-memory, per-store, dies with the session** — losing it costs attribution and nothing else, so the launch catch-up commit (06) classifies everything foreign: the app never vouches for changes it didn't witness. Bracketed operations don't consult it (they commit themselves and announce once at completion), and the reload-granularity origin tag (Live-reload resilience below) is orthogonal: it classifies *reloads*, the ledger classifies *files*. Feeds attribution and announcements only — never the render path (Layering above). - **EchoLedger** — the write-provenance ledger (the "Writer/echo machinery" that 06-history-undo.md ▸ Interaction with external writers — its Commit attribution rule — and 10-accessibility.md ▸ Live board announcements consume; settled). Every BoardWriter operation drops a receipt of its expected on-disk outcome before returning: path → content hash for writes (attachment imports hash during the copy — the bytes stream through the app anyway), an absence marker for deletes, an old→new pair for folder moves; a newer app write to the same path supersedes the receipt. Classification runs per observed changed file in a debounce window: current on-disk content matches the receipt → **app-mediated**, receipt consumed; no receipt, or mismatch → **foreign**. Final content deciding is what settles the races: an agent writing byte-identical bytes over a fresh app write matches and classifies app-mediated — with identical bytes the misattribution is unobservable in the tree, accepted; a foreign edit landing on an app-written path inside the same window misses the hash and the file classifies foreign — last writer wins the file, the app's subsumed intermediate never separately recorded (the diff compares snapshots, not a journal — 06-history-undo.md). Consumers: the auto-committer's author field and two-commit split (06), and the announcement filter (10) — on no-git boards the ledger runs identically with the announcer as its only consumer. **In-memory, per-store, dies with the session** — losing it costs attribution and nothing else, so the launch catch-up commit (06) classifies everything foreign: the app never vouches for changes it didn't witness. Bracketed operations don't consult it (they commit themselves and announce once at completion), and the reload-granularity origin tag (Live-reload resilience below) is orthogonal: it classifies *reloads*, the ledger classifies *files*. Feeds attribution and announcements only — never the render path (Layering above).
- **BoardStore** — per-board `@Observable` object holding the current snapshot plus transient UI state that must be shared across that board's windows (TransientBoardState — see Changes from Kanban). Coalesces watcher reloads — at most one tree walk in flight, signals landing mid-walk fold into one follow-up; the debounce itself lives in FolderWatcher (below). - **BoardStore** — per-board `@Observable` object holding the current snapshot plus transient UI state that must be shared across that board's windows (TransientBoardState — see Changes from Kanban). Coalesces watcher reloads — at most one tree walk in flight, signals landing mid-walk fold into one follow-up; the debounce itself lives in FolderWatcher (below). **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: **200 ms trailing**, the timer restarting per event so a burst yields one reload after quiet, over 50 ms FSEvents latency — settled numbers), attached best-effort to whatever path the board lives at. **Events under any `.git` path component are filtered out** (settled): the board's own root-level repo (a worktree-link `.git` file included) is the app's auto-commit churn, and a repo nested deeper — a card folder containing a clone, a submodule — is a stray (01-storage-format.md) whose internals never render; neither can alter the rendered tree, so neither drives reloads. (A nested repo's *working files* still fire events like any stray's — those reloads are value-equal and quiet.) There is only this one watching path: no NSMetadataQuery for iCloud Drive, no polling fallback for network volumes — on those warned-against locations (07-sync-collab.md) FSEvents delivery is unreliable and live reload silently degrades, accepted per 07's no-accommodations stance. - **FolderWatcher** — FSEvents (debounced: **200 ms trailing**, the timer restarting per event so a burst yields one reload after quiet, over 50 ms FSEvents latency — settled numbers), attached best-effort to whatever path the board lives at. **Events under any `.git` path component are filtered out** (settled): the board's own root-level repo (a worktree-link `.git` file included) is the app's auto-commit churn, and a repo nested deeper — a card folder containing a clone, a submodule — is a stray (01-storage-format.md) whose internals never render; neither can alter the rendered tree, so neither drives reloads. (A nested repo's *working files* still fire events like any stray's — those reloads are value-equal and quiet.) There is only this one watching path: no NSMetadataQuery for iCloud Drive, no polling fallback for network volumes — on those warned-against locations (07-sync-collab.md) FSEvents delivery is unreliable and live reload silently degrades, accepted per 07's no-accommodations stance.
- **Ranks** — gapped fractional ordering math + compaction. Pure. - **Ranks** — gapped fractional ordering math + compaction. Pure.
- **DropSlot** — drop-geometry math: hit zones and insertion-position targeting for drags (lane/position within the masonry, cross-board, Finder file drops). Pure, like Ranks. - **DropSlot** — drop-geometry math: hit zones and insertion-position targeting for drags (lane/position within the masonry, cross-board, Finder file drops). Pure, like Ranks.
- **AgentGuide** — writes/upgrades the board-root `CLAUDE.md` (see 08-agent-integration.md). - **AgentGuide** — writes/upgrades the board-root `CLAUDE.md` (see 08-agent-integration.md). Its refresh is a scheduled heal riding the HealScheduler (below).
- **HistoryStore** — the history provider behind the edition seam (12-editions.md): the board session binds one `HistoryProviding` implementation at composition — base Lanework's native undo stack (13-native-undo.md, inverse `WriteOperation`s over NSUndoManager) or Pro's git plumbing (06-history-undo.md). Pro-only source; the base target never links git machinery. - **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 surfaces as a repairable condition** (settled): the loader's board-wide dedupe (01-storage-format.md ▸ Fractal layout rules) withholds losing occurrences from every snapshot; a warning-tone condition banner names them and offers **Repair** — a Writer remint under user intent (the loader itself never writes). The condition heals when the duplicates are gone, however they go — Repair here, a repair elsewhere, a hand-deleted copy. - **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, so quiet reconciliations cost nothing visible), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry). - **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal — 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 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). - **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A container crossing is a vanish for this purpose** (resettled 2026-07-28 — the materialized trash): re-resolution matches UUID *and* container side (board vs `.trash/`), so a foreign move that trashes a selected board card — or restores a selected trash card — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's container-boundary invariant true across reloads. The old effective-liveness ancestor walk is retired with the tombstone model — presence in the snapshot is the whole question. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted or moved to the trash (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map).
- **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The rule arms on every bracket exit, thrown operations included** (settled): an operation that fails or aborts mid-flight is precisely when the tree's state is least known, so the mandatory final reload runs regardless — succeeding, it renders whatever the operation left (often value-equal after a clean failure, whose tree is left as it was — 06-history-undo.md); failing, it locks exactly as above. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and restore, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles (these disable in place as controls — 05-card-window.md's in-content rule) — and the board refuses drops, including drags arriving from another board's window. **The lock is a predicate every mutating entry point consults, not a menu-validation sweep** (settled): mutating paths without a menu item exist beyond the checkboxes — the attachment row's ⌫/Remove (grammar key + context menu only, 11-command-nexus.md ▸ Context menus), plain-⌫ delete, Return-creation — and each disables with its surface or follows its command twin's validation; menu validation is the lock's most visible face, never its whole mechanism. **File ▸ Duplicate and File ▸ Save as Template join the disabled set** (settled): both copy the on-disk tree, which the lock marks as gone (vanished root), unknown (this failed-reload state), or unwritable — and both are specified to run the close flush first (03-board-ui.md, 09-templates.md), which the lock's suspended saves make impossible to honor. **One carve-out: under the unwritable-location lock alone (below), Save as Template stays live** — it reads the board and writes into Application Support, the copy-out-is-a-read principle applied (archiving the read-only DMG board being inspected is a legitimate errand). **The carve-out gates on the hazard itself, open sessions, not on lock provenance** (settled): the item disables while any open Edit or raw-source session holds unsaved content — content the lock's suspended saves cannot flush, which the template would silently miss (09-templates.md's never-misses-keystrokes guarantee outranks availability) — and re-enables when those sessions settle or the lock clears. A lock standing since open never meets this state: it disables the flip into Edit mode, so no session can start beneath it and the gate is vacuously open; unsaved sessions under this lock exist only when the symmetric probe (▸ Write-failure surfacing) raised it mid-session. Duplicate stays disabled even there — its destination is the same unwritable parent (a writable-parent/unwritable-board permission split was weighed and set aside as too rare to earn the inconsistency). Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it). - **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).
@@ -52,7 +54,7 @@ The **one named exception** is transient UI state rendering things that don't ex
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"). **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 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 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 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.) - **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. - **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.
@@ -63,9 +65,9 @@ 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: **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. **Loss rows are the warning-tone class for non-failure losses** (settled 2026-07-28): content that didn't arrive though nothing failed — a degraded paste, folders skipped from a Finder drop, their future kin. 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. - **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. **Its lifecycle is the one-shot's — dismissable, untimed** (settled): 07's "transient" means non-modal and non-blocking, never auto-expiring; the strip has exactly two lifecycles (one-shots dismiss, conditions heal) and the signpost doesn't add a third. - **Tones, not components.** The banner has kinds — error, warning, info — sharing layout and the accessibility announcement path (10-accessibility.md). The card window's remote-change signpost (07-sync-collab.md) is this same component in the info tone: visually calm, no error color. **Its lifecycle is the one-shot's — dismissable, untimed** (settled): 07's "transient" means non-modal and non-blocking, never auto-expiring; the strip has exactly two lifecycles (one-shots dismiss, conditions heal) and the signpost doesn't add a third.
- **In-progress operations are info rows** (settled): bracketed git operations ("Pulling…", "Switching to 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment imports, 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. - **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, 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. - **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.
@@ -78,10 +80,11 @@ The non-modal banner named throughout the read- and write-side rules above is on
### Launch and window lifecycle (settled) ### Launch and window lifecycle (settled)
- **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. - **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. - **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. - **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.
@@ -89,14 +92,14 @@ The non-modal banner named throughout the read- and write-side rules above is on
## 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 open-now restoration flag (Launch and window lifecycle above), the push-on-commit setting and the once-per-board iCloud warning flag (07-sync-collab.md), and whatever accumulates later — lives in a **board registry in Application Support**: one record per known board, anchored by the **security-scoped bookmark** the sandboxed app keeps anyway for reopening boards. State that belongs to the app, not the user's files — the recents list, per-board window frames, the open-now restoration flag (Launch and window lifecycle above), the push-on-commit setting and the once-per-board iCloud warning flag (07-sync-collab.md), and whatever accumulates later — lives in a **board registry in 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. **One bookmark per open board** (settled): the open flow mints it once and threads it through — the persisted record and the live store's mid-session re-resolution share the same bookmark, never two independent mints. - **Keyed by file identity, never by path.** Bookmarks track renames and moves on the same volume; an opened URL is matched to its record by bookmark resolution / file identity, so a moved board keeps its settings. The recents list *is* this registry sorted by last-opened. **One bookmark per open board** (settled): the open flow mints it once and threads it through — the persisted record and the live store's mid-session re-resolution share the same bookmark, never two independent mints.
- **Recents counts are registry-cached.** The lane/card counts in the welcome window come from the record, stamped at last close — no directory scan at welcome time (which would be slow or hang on big/unavailable boards). Staleness until the next open is accepted. **The counts are working items only** (settled, re-grounded 2026-07-28): cards in `.trash/` don't count; the row advertises the board's working size, and the trash is an errand, not inventory. Records that can't be counted show without counts: unavailable boards per Graceful orphaning below; a board that fails to load just fails on open, fail-fast — the welcome row doesn't pre-detect it. **A first open that fails fail-fast still records** (settled): the registry record is created before loading, so the failed board lands in recents carrying fail-fast's specifics — retry after fixing the file is one click, uniform with the restored-board failure row. **The record's provisional display name is the folder name** (settled): fail-fast means the frontmatter can't be trusted, and the folder name is the Finder document name the user just picked; the first successful load replaces it with the cached title. A never-successfully-opened record is not a special class — it lingers in recents like any other, and Forget is the eraser for a genuinely mistaken open. **Records that collapse onto one file identity merge silently** (settled — a restored registry file, or an orphan whose bookmark re-resolves onto a recreated board): on detection, the record with the newest `lastOpened` wins wholesale and the others retire — recents never shows one board twice, and per-board settings are conveniences that don't earn a merge UI. - **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. - **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 their peers live beside the registry in Application Support (or `UserDefaults` where a scalar fits) — no per-board record involved. Secrets are the named exception: Keychain only, never here (07). - **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
+42 -12
View File
@@ -6,13 +6,14 @@ The board window: layout, lanes, cards, and styling. Interaction mechanics (sele
- **Every lane is always on screen.** The window width divides across the lanes' width units — no horizontal scroll, no enforced minimum lane width. Resizing the window is the width control. (Settled emphatically in the old app: horizontal scroll strays from what kanban is for.) The degenerate case is accepted, not floored: enough lanes/units in a small window compress every lane, titles and cards truncate gracefully, and the remedy is the user's (fewer units, bigger window). A minimum-width setting that reintroduces scroll was considered in the pathfinder and deliberately rejected. **Lane-count changes re-divide, never resize**: new lane (⇧⌘N), lane paste or cross-board lane drop, lane 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). - **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.
- **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). - 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
@@ -32,17 +33,18 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
### 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. **There is no in-app control for the image** — the raw file is the escape hatch (the stance custom hex held until the 2026-08-06 combo reversal; for images 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 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. - The pathfinder's palettes (12 icon tints, 12 backgrounds) carry over as the starting point. **The 12+12 split is a picker split, not a name split** (settled): color resolution searches foregrounds then backgrounds, so a hand-written `background: carnation` (an icon-tint name) resolves and paints — the split governs what the grids offer, never what a name means.
### Controls (settled) ### Controls (settled)
One **style editor** component — a background palette grid and a curated symbol grid — presented from three anchors: **embedded** in the card window sidebar's Style section (05-card-window.md) and in the board popover's styling area, and as a **popover** opened by Style… from a card/lane context menu or the menu bar (Board ▸ Style…, ⌥⌘S — 11-command-nexus.md; selection-aware: it styles the selected cards or lane, and with nothing selected, the board). One component, one behavior, three anchors — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't. One **style editor** component — a background palette grid and a curated symbol grid — presented from two anchors: **embedded** in the card window sidebar's Style section (05-card-window.md), 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 — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't. **The anchors compose the halves they need** (2026-08-06): the card sidebar shows the symbol grid with the **color combo** (below) standing in for the background half — the narrow context the combo was built for; the Style… popover 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.)
- **Palette-only in-app**: the background grid offers the 12 palette colors — 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 12 backgrounds, so palette drift can never silently break it) — plus a leading **None** well that removes the `background` key. Custom hex is not pickable in-app but stays fully honored from disk (runtime contrast, 10-accessibility.md): curated in-app, unlimited on disk. - **Curated-first, panel-backed** (re-ratified 2026-08-06, reversing 2026-07-29's palette-only ruling): the background grid offers the 12 palette colors — 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 12 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 — named symbols the running OS knows, that is: inventories grow per macOS release, so a newer-OS name renders the level default on an older Mac, value preserved on disk — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch. - **Curated symbol grid**: a hand-picked set (roughly five dozen kanban-relevant SF Symbols); its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — named symbols the running OS knows, that is: inventories grow per macOS release, so a newer-OS name renders the level default on an older Mac, value preserved on disk — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch. (Symbols keep this stance deliberately — the 2026-08-06 color-panel reversal above is colors only: the system offers a Colors panel worth deferring to, and no symbol browser of equal standing.)
- **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.
- **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. - **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.
@@ -51,22 +53,50 @@ One **style editor** component — a background palette grid and a curated symbo
## 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.)*
**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.
### 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). - **Board rename** (settled: this function stays in-app, unlike the pathfinder which dropped it with the inspector). Rename edits the board's frontmatter `title` only — the folder is never renamed by the app; the Finder document name is Finder's to change (01-storage-format.md's board-naming rule). A foreign rename landing while the popover is open resyncs the field from the snapshot only while the field is unfocused — a focused field keeps the user's keystrokes, the dirty-buffer courtesy applied here (settled).
- **Board styling** — the embedded style editor (Styling ▸ Controls above). - **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.
- **Git integration** — Pro edition surface, mode-aware (06-history-undo.md, 07-sync-collab.md; in base Lanework this section is absent on ordinary boards and reduces to the contextual one-line Pro pointer on boards carrying an inert `.git` — 12-editions.md): on a mode-none board, the **add-git** action (opt-in init; on repo-nested boards replaced by the honest this-board-lives-inside-a-repository explanation — 06); on git boards, branch/source display, branch switching and creation, the commit-identity name/email fields (06), and **add/change remote** (a remote can be added or changed at any point — 07); for remote-backed boards additionally remote tracking (ahead/behind) with Pull/Push controls and the push-on-every-commit option. **Remote authentication surfaces inline here** (07 ▸ Remote authentication): credential fields on add/verify, the SSH key surface — the machine key with Copy + Verify, key import by paste or drag, the per-host key picker with removal of unreferenced imports, and confirm-gated machine-key regeneration — and the Authentication-needed badge state.
## 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
**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. **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.
- **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. Lane rows and cards interleave in the one trash column purely by trash rank. - **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).
- **Entry is always at the top** (settled): every arrival, card or lane — ⌫/⌘⌫ delete and drag-to-trash alike — lands at the trash's topmost position, minting an `order` rank above the current top. Newest-first ordering falls out of ordinary ranks by construction: **there is no `deleted:` key and no timestamp sort** — the trash sorts by `order` like any lane. The move **stamps `modified`** (the one exception to moves-don't-stamp — deletion is an edit to the card's story), which is what a future age-based auto-purge will read (deferred, 01-storage-format.md). - **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).
- **Rendering**: trailing (rightmost) position when shown, visually distinct — dimmed/hatched header, trash SF Symbol, count badge; no new-card button; not draggable, not resizable, excluded from lane reordering. Fixed one width unit, consumed only while shown; Show/Hide Trash is a re-divide trigger (Layout above). **Visibility**: hidden by default; View ▸ Show Trash toggles (no default chord — ⇧⌘T belongs to the system's Show Tab Bar, 11-command-nexus.md); per-open transient state, resets to hidden, never persisted. Hidden, the trash is invisible to every gesture and to search; shown, its cards participate in the filter **exactly like any other card** — the point of the pivot. - **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.
- **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). - **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).
- **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). - **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).
- **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** skips the trash from anywhere. Both **confirm exactly where the loss is real** (carried over): on boards without app-managed git history the alert stands between one keystroke and unrecoverable deletion; on git boards they act immediately (delete-never-forgets). 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. - **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.
- 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). - 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).
- **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. - **Naming constraint** (carried over): attachment Remove moves files to the *system* Trash (05-card-window.md); board deletion says "Delete" and this container is "Trash" / "Show Trash" — Finder's "Move to Trash" phrasing stays reserved for the system Trash.
- **Materialized reserved lanes are a pattern, not a one-off**: `.trash` is the first; an **archive lane** (`<root>/.archive`, intake criteria to be designed) is the anticipated second (WISHLIST) — same mechanics, ordinary cards in a reserved dot-named container, different entry semantics. Nothing beyond `.trash` is committed yet. - **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.
+13 -13
View File
@@ -5,9 +5,9 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
## Selection ## Selection
- Cards: click selects; ⌘-click toggles; ⇧-click range-extends; click-drag rubber-bands across lanes. Lanes: ⌘/⇧-click multi-select. - Cards: click selects; ⌘-click toggles; ⇧-click range-extends; click-drag rubber-bands across lanes. Lanes: ⌘/⇧-click multi-select.
- **The range anchor** (settled — standard macOS list semantics): the anchor is the last plain- or ⌘-clicked item, per board window, transient — never persisted. ⇧-click ranges from anchor to target in the flatten order (cards), lane order (lanes), or the trash's own order — where rows of the other kind are skipped, since lanes rejoined the trash (2026-07-29; 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. - **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.
- Selection is **homogeneous**: cards XOR lanes. - 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). - **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. - 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).
@@ -18,14 +18,14 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
- **Multi-drag**: dragging any member of a multi-selection drags the whole selection; N contiguous shadows; drop inserts contiguously in preserved relative order — defined, for any multi-selection, as lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom). - **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 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). - **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 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 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 *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. - **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. - **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). **Staging lifecycle** (settled): snapshots are staged **eagerly at ⌘C/⌘X time** — copy captures the source as it is at the gesture, immune to later deletion or unmount — and the store holds at most the *current* copy: a new Lanework copy replaces the previous snapshot, and a sweep at launch and on each copy purges entries the pasteboard no longer references (another app taking the pasteboard orphans the snapshot; the next sweep collects it). The snapshot survives relaunch exactly as long as the pasteboard still points at it — a copy made before quitting pastes whole after restart. **A paste is an import boundary, so normalization applies** (settled 2026-07-28 — 01-storage-format.md's loose-file rule): loose files the staged snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`, Finder-renamed on collision — nothing the snapshot preserved is dropped on arrival. **A degraded paste is loud, never silent** (the banner vocabulary — 02-architecture.md): if the staged snapshot is missing or unreadable at paste time, paste falls back to the embedded `index.md` — content intact, files absent — and a **loss row** (warning tone, user-dismissed) names what was lost with true totals, attachments and loose files alike, folding plurals ("Pasted 'Fix login' without its 3 attachments"; "Pasted 2 items without their 5 files" — BannerCenter owns the phrasing, the manifest records each entry's staged file count so the totals stay honest); the user never discovers an empty `attachments/` later. **The pasteboard is re-read lazily, and the brief lie is accepted** (settled): changeCount is checked on activation, on menu validation, and before paste — no timers; a background app taking the pasteboard while Lanework stays frontmost can leave Edit ▸ Paste enabled until the next check, and the paste itself re-validates and no-ops — nothing stale ever lands, which is the guarantee that matters. - ⌘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; a background app taking the pasteboard while Lanework stays frontmost can leave Edit ▸ Paste enabled until the next check, and the paste itself re-validates and no-ops — nothing stale ever lands, which is the guarantee that matters.
- **Cut is Finder-style deferred**: cut items dim in place until paste moves them; voided if another app takes the pasteboard or the source board closes; second paste materializes copies. **Deletion voids per item**: a cut item that is 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). - **Cut is Finder-style deferred**: cut items dim in place until paste moves them; voided if another app takes the pasteboard or the source board closes; second paste materializes copies. **Deletion voids per item**: a cut item that is deleted (moved to the trash or destroyed) or vanishes externally before paste drops out of the pending cut — 02-architecture.md's UUID-set rule; transient state never resurrects what's gone — so paste moves only the survivors, and a cut voided down to nothing is simply void (paste disabled, no error).
- Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at its last member in flatten order — the ⌘N target rule's shared anchor (The map below). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`. **A trash selection never anchors paste** (settled — the ⌘N target rule's own wording, returned to the precedent it cites): ⌘V stays enabled and behaves exactly as with nothing selected — a card payload appends to the last-active lane, a lane payload lands at the board's right end; the trash is never the destination (▸ The trash), and a trashed card's live disk-lane never leaks in as "the selected card's lane". - Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at its last member in flatten order — the ⌘N target rule's shared anchor (The map below). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`. **A trash selection never anchors paste** (settled — the ⌘N target rule's own wording, returned to the precedent it cites): ⌘V stays enabled and behaves exactly as with nothing selected — a card payload appends to the last-active lane, a lane payload lands at the board's right end; the trash is never the destination (▸ The trash), and a trashed card's live disk-lane never leaks in as "the selected card's lane".
- **Lane paste** lands after the anchor lane — the selected lane, or the selected card's lane (several selected: the last, per the shared anchor rule); nothing selected = the board's right end. Semantics mirror the drag pair above exactly: a pasted *copy* takes fresh GUIDs throughout; a cut-paste is the ⌘-drag move — the folder moves whole (nothing lane-nested to strip or carry — the trash is board-level, resettled 2026-07-28). **Pasting into the source board is supported and is the within-board lane duplicate** (settled): fresh GUIDs apply as anywhere else, no menu-validation special case — the drag path deliberately lacks this operation (⌥ ignored on lane drags, above), the clipboard is its one home. - **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.
@@ -52,9 +52,9 @@ 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: *card* moves live on the ⌥⌘ chord, joining ⌥⌘←/⌥⌘→ lane width in a "⌥⌘ modifies" family; plain ⌥-arrows stay jumps; plain ⌘↑/⌘↓ are unassigned): the selected card(s) move one position within the lane — logical `order`, across interior masonry columns (10-accessibility.md's logical-order rule). A non-contiguous multi-selection **gathers on the first press**: the cards collect into a contiguous block anchored at the first selected card (first = lowest logical order; the rest follow in preserved relative order), and subsequent presses move the block one position. **Cards never change lanes by ⌘-arrow** (settled): inter-lane movement is drag or Cut/Paste (the clipboard rules above), so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes and ⌘←/⌘→ are inert on card selections. With a **lane** selected, ⌘←/⌘→ move the lane one slot — closing 10-accessibility.md's lane-move defect — and ⌥⌘↑/⌥⌘↓ are inert. - **⌥⌘↑/⌥⌘↓ sort within the lane** (the move-vs-jump question, resettled: *card* moves live on the ⌥⌘ chord, joining ⌥⌘←/⌥⌘→ lane width in a "⌥⌘ modifies" family; plain ⌥-arrows stay jumps; plain ⌘↑/⌘↓ are unassigned): the selected card(s) move one position within the lane — logical `order`, across interior masonry columns (10-accessibility.md's logical-order rule). A non-contiguous multi-selection **gathers on the first press**: the cards collect into a contiguous block anchored at the first selected card (first = lowest logical order; the rest follow in preserved relative order), and subsequent presses move the block one position. **Cards never change lanes by ⌘-arrow** (settled): inter-lane movement is drag or Cut/Paste (the clipboard rules above), so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes and ⌘←/⌘→ are inert on card selections. With a **lane** selected, ⌘←/⌘→ move the lane one slot — closing 10-accessibility.md's lane-move defect — and ⌥⌘↑/⌥⌘↓ are inert.
- **⌫/⌘⌫ delete** (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). Selection moves to the deleted item's successor sibling, Finder-style (next card in the lane, next lane on the board; the last sibling's predecessor otherwise; empty container = nothing selected) — repeated ⌫ walks down a lane. Deliberate deletes pick a successor; *external* vanishing never does (02-architecture.md's reload-survival rule: the selection just shrinks). **Put Back is retired with the tombstone model** (resettled 2026-07-28): File ▸ Delete is the chord's only owner — no twin menu items, no shared-equivalent routing; restore is drag-out or ⌘X/⌘V (The trash below). Plain ⌫ performs the same delete as fixed grammar (see Grammar above) — there is no Edit ▸ Delete item, so the two Delete-titled homes never collide for title-matched remapping. - **⌫/⌘⌫ 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). **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). - **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); **a multi-selection anchors at its last member in flatten order** (settled — lane `order`, then card `order`, the multi-drag order; the same anchor serves paste): creation follows the last selected card, or appends to the last selected lane; the lane header's new-card button **overrides this rule** — the click names its target lane, selection notwithstanding (11-command-nexus.md ▸ Pointer grammar); with nothing selected — or a **trash** selection, which never anchors creation — the **last-active lane** — the lane that most recently held selection or a creation in this window session — falling back to the first lane. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first. - **⌘N target rule** (settled): with a card selected, the new card is created in that card's lane, immediately after it (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency); **a multi-selection anchors at its last member in flatten order** (settled — lane `order`, then card `order`, the multi-drag order; the same anchor serves paste): creation follows the last selected card, or appends to the last selected lane; the lane header's new-card button **overrides this rule** — the click names its target lane, selection notwithstanding (11-command-nexus.md ▸ Pointer grammar); with nothing selected — or a **trash** selection, which never anchors creation — the **last-active lane** — the lane that most recently held selection or a creation in this window session — falling back to the first lane. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first.
### The trash, keyboard-first (resettled 2026-07-28 — the materialized trash) ### The trash, keyboard-first (resettled 2026-07-28 — the materialized trash)
@@ -62,12 +62,12 @@ Every command is a menu item. The full inventory — every command and action, i
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: 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; 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. - **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.
- **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 mints a rank above the current top (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. - **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 keeps one container boundary — and the board's kind rule**: a selection never mixes trash items with board items, and (as everywhere) never mixes cards with lanes — a trash selection is either cards or lane rows, kind-homogeneous like the live board's own grammar. The rubber band stays on the side it started on and selects cards only (as the board marquee does); lane rows join by click grammar, and ⇧-click ranges skip rows of the other kind (resurrecting the 2026-07-28 skip-by-kind ruling, mooted when lanes left the trash and back with them). ⇧-arrow extension stops at the container boundary *and* at a kind boundary (plain arrows walk across — navigation moves, extension stops). Select All with a non-empty trash selection selects all visible trash **cards**, otherwise all visible live cards — Select All is card-scoped everywhere, never lane rows. Menu validation stays binary by container: Delete = move to trash on board selections, Delete = permanent on trash selections (03 ▸ Trash). An external move observed by reload re-resolves the selection by presence, as everywhere (02-architecture.md). - **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 rubber band 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).
- **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. - **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: 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; the payload kinds never mix because the selection never does). **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. - **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 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. - **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 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. - **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)
@@ -84,8 +84,8 @@ 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/tags and their kin are reserved, inert keys this version — nothing to search until a future version gives them life). 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 — 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, the header button, 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. - 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, the header button, 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. **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). - **Dispatch while the search field is focused** (settled): the field is a *control*, not a content editor — the focused-editor lockdown (Grammar above) does not apply. Text-domain keys route to the field: ⌘A/⌘X/⌘C/⌘V act on the query, plain ⌫ edits the query and never reaches the board, horizontal arrows move the caret. **Every key with the field focused acts on the field — stock NSSearchField behavior, no pass-throughs** (settled): vertical arrows are caret movement, ⇧-arrows select query text, and **Return is a swallowed no-op** (the filter is live, there is nothing to submit — it never reaches the board's rename/create grammar). **Tab is the keep-filter path**: plain key-view traversal moves focus to the board with the query intact, and the whole board grammar (arrows, ⌥↑ escalation, Return, ⌘↩) then applies over the *filtered* board; ⌘F returns to the field. Board menu commands stay enabled and act on the board selection exactly as when the field is unfocused — ⌘N included (creating a card clears the search, above) — **except the caret-chord commands**: Move Left/Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable while the field is focused (Grammar above, caret-chords rule), so ⌘←/⌘→ stay line-start/end in the query even with a lane selected — and the Delete pair stays unambiguous by construction: plain ⌫ is query editing, ⌘⌫ is File ▸ Delete on the selection, and ⌘Z/⇧⌘Z are the field's own text undo, never git undo (06-history-undo.md ▸ Undo routing's control-class rule).
## Changes from Kanban ## Changes from Kanban
+21 -7
View File
@@ -2,14 +2,15 @@
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. **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). - **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.
- **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.)
@@ -30,7 +31,7 @@ Settled the hard way in the pathfinder (WYSIWYG built, then reversed): the body
- **Remote images are never fetched** — Preview does no networking (sandbox-quiet, files-first). An `![](https://…)` 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 `![](https://…)` renders as a quiet placeholder chip carrying the alt text (or the URL); the file-relative form above is the supported image story.
- **Task-list checkboxes are live**: clicking a `- [ ]` / `- [x]` checkbox flips exactly that marker in the source — a single-character textual edit; every other byte of the body is untouched. This is the deliberate exception to "Preview only reads": checklists are kanban's working currency, and a mode flip to tick a box is ceremony. A toggle is an ordinary user edit — the standard atomic write, auto-committed and undoable on git boards. **The pointer-free path is the system focus model** (settled): checkboxes — like Preview's links — are real controls in the keyboard-focus and accessibility tree, so Full Keyboard Access Tab-reaches them and Space toggles, and VoiceOver toggles with VO-Space (10-accessibility.md's real-accessible-checkboxes promise, honored natively). Without FKA they are not in the key loop — standard macOS content behavior, so ordinary Tab users never wade through a long checklist. In-content controls are *content*, not commands: no menu item, no chord — 04's every-function-has-a-menu-item contract covers commands, and 11-command-nexus.md scopes them accordingly. Under the read-only lock (02-architecture.md) the controls disable in place — 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). - **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
@@ -56,13 +57,13 @@ Stacked sections under small-caps headers, in this order; quiet rows, read-optim
### 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 / 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.
- **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 **curated symbol grid** (03-board-ui.md ▸ Styling ▸ Controls, its 2026-08-06 anchor-ownership rule) — the sidebar is exactly the narrow context the combo was built for, so it stands in for the well grid's background half here, panel escape hatch included; the full grid remains the surface at the other anchors. Card styling is discoverable here without a context menu; the component family is shared with the board popover and Style….
### Details — unknown frontmatter keys ### Details — unknown frontmatter keys
@@ -82,9 +83,22 @@ The card-level styling home: the **embedded style editor** — background palett
- **Delete** — moves the card to the trash (destructive styling; the window then dismisses itself per Deletion & lifecycle below; recoverable from the board's trash lane — 03-board-ui.md). - **Delete** — moves the card to the trash (destructive styling; the window then dismisses itself per Deletion & lifecycle below; recoverable from the board's trash lane — 03-board-ui.md).
- **Reveal in Finder** — the card's folder. - **Reveal in Finder** — the card's folder.
## 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.
@@ -95,7 +109,7 @@ The card-level styling home: the **embedded style editor** — background palett
## 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.
+28 -25
View File
@@ -1,75 +1,78 @@
# History & Undo # History & Undo
**Edition scope: Lanework Pro** (12-editions.md). This doc is the git HistoryProvider; base Lanework ships mode:none only, with macOS-native undo (13-native-undo.md) and the inert-`.git` posture (12). The Undo routing section below is edition-independent — both substrates dispatch through it. **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; see 07-sync-collab.md's mode progression). A board without git has **no undo/redo** (board history, that is — text editors keep their standard typing undo everywhere; see Undo routing below) — consistent with the settled no-undo stance for repo-nested boards; card deletes are the exception, recoverable on every board via the materialized trash (03-board-ui.md; lane deletes lean on native undo in-session — 13-native-undo.md). On git-enabled boards, every settled change auto-commits; those mechanics are carried over from the pathfinder with their hard rules intact. 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 — for *discovery***: a `git init` under an open mode-none board takes effect at the next open — the running session keeps its mode, and the watcher does not scan for `.git` appearing (no mid-session mode flips from watching; stated here so it isn't rediscovered as a bug). The one deliberate mid-session transition is the app's own **add-git** (Opt-in init above): clicking it flips the open board into git mode immediately — the popover flows straight into the git controls, the first auto-commit follows — the rule forbids *discovered* flips, never commanded ones. - **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. **The root commit has its own subject** (settled): whenever the app creates a repo's first commit — immediately on the app's own add-git (init doesn't wait for the debounce; the board is protected from the moment git exists), or at the first settled change on an adopted unborn repo — it commits the whole tree as **"Initial board state"**, never a folded diff-from-empty: there is no last-committed snapshot to diff against, and forty Adds would bury the event. A **detached HEAD**, or an **in-progress merge/rebase/cherry-pick** left by outside-the-app git (`MERGE_HEAD`, `rebase-merge`/`rebase-apply`, `CHERRY_PICK_HEAD` — pause states that load fine on a clean tree and are otherwise invisible), instead **pauses the git surface honestly — the *whole* surface, remote half included** (settled): auto-commit holds (the auth-pause posture, 07-sync-collab.md — pause, badge, explain, never hammer), Undo/Redo and the branch controls disable, **and Pull, Push, and push-on-commit hold with them** — with auto-commit held, 07's clean-tree-by-pull-time invariant is false, and a pull's rebase (or a rejected push's fetch→rebase→push) would run against a dirty tree carrying uncommitted edits, precisely what flush-before-overwrite exists to prevent; the ahead/behind badge keeps counting (a fetch is a read), and the popover's git section names the state plainly ("HEAD is detached — commits would belong to no branch"; "a merge is in progress") and says resolving it belongs to the tool that created it. Edits keep landing on disk — files are the board — and commit as one settled batch when the state clears. The app **never mutates repo state it didn't create** (no auto branch-at-HEAD, no `merge --abort`); the check runs at open and again before every flush, so finishing the operation in a terminal resumes the pipeline without ceremony. **The one exemption is the app's own leftovers** (settled): every bracketed operation stamps its intent app-side (per-board registry) before touching the repo, so an interrupted app-run rebase or checkout is recognizable as Lanework's — finding a pause state with a matching stamp, the app **aborts its own unfinished operation** to restore the pre-operation state and says so via banner ("a branch switch was interrupted — the previous state is restored"), then clears the stamp. Abort discards nothing: fetched commits stay fetched, local commits are restored — the rebase's own no-loss accounting. Without a matching stamp the leftover is outside git's, and the pause-and-defer stance above holds unchanged. - **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.
- **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). - **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 — **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 ## Undo routing
**Routing is by focus** — the platform's first-responder rule, its own section because two undo systems coexist and four docs cite the rule. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is the git trail. Text undo works on **every** board — no-git and repo-nested included; "no undo/redo" above means board history, not typing. **Control-class text fields route the same way** (settled): the search field (04-interactions.md ▸ Search) and the popover's text fields (board rename, commit identity, credentials) own ⌘Z/⇧⌘Z as field-local text undo while focused — "board menu commands stay enabled" never hands Edit ▸ Undo to git while a text-bearing control has focus; a reflexive undo over a typo must never become a tree checkout. With focus outside every text-bearing surface — editor or control — Edit ▸ Undo/Redo are git undo (and are disabled on boards without it). **No fall-through**: exhausting a focused editor's stack beeps; it never reaches board history. **Routing is by focus** — the platform's first-responder rule, its own section because two undo systems coexist and four docs cite the rule. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is the git trail. Text undo works on **every** board — and since the 2026-07-31 repo-nested re-ruling, so does board-level undo: every board binds a provider (native or git), so "no undo" is no longer 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 own configuration fields (commit identity, the New Branch… name, and 07's credentials and remote URL when they land) own ⌘Z/⇧⌘Z as field-local text undo while focused — "board menu commands stay enabled" never hands Edit ▸ Undo to git while a text-bearing control has focus; a reflexive undo over a typo must never become a tree checkout. With focus outside every text-bearing surface — editor or control — Edit ▸ Undo/Redo are, **in a card window, that window's own session stack** (13-native-undo.md's two-level model, re-ruled 2026-07-31 — fine-grained window gestures, both tiers; the coarse close unit is the tier-split: one native board step, or one commit), and on board surfaces board history — the board's bound provider, git or native (every board binds one since 2026-07-31; 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.
## 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'"), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's banner-gated fix; app-mediated, so the composer names it directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: a move into `.trash/` is Delete, a move out of it is Restore ("Restore card 'X'" — drag-to-restore, cut+paste), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — Delete Immediately, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows fall back to "Update board" — always with a bulleted body naming every event, so the oneline log stays scannable and the full message stays complete. - **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, 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.
- **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. - **Non-snapshot files commit too** (settled — the repo tracks more than the model: the agent guide, `CLAUDE.user.md`, the seeded `.gitignore`, and strays at every level): the committer **stages the whole board root** — whatever `git status` shows, `.gitignore` respected, **open Edit sessions still staged around** (settled): the session card's folder stays excluded exactly as in Rules ▸ Auto-commit, whole-root staging widening *what* commits, never overriding the exclusion — and its commit condition is the *tree*, not the snapshot diff, so a stray-only window commits rather than leaving the tree dirty (firing mid-session, it commits the strays and leaves the session folder untouched) (a permanently dirty stray would break branch switch's cannot-fail-dirty guarantee and void flush-before-overwrite for every file the model can't see). The composer's input extends accordingly: beside the snapshot diff it receives the changed-path list, and non-snapshot paths compose **path-shaped events**`CLAUDE.md` composes "Update agent guide (vN)", the version read from the guide's marker first line (a pure function of file content, *not* write-site tagging — the no-interception rule stands); any other non-snapshot path composes "Update '⟨path⟩'", folding plural ("Update 3 files"). Model events keep the subject when present; non-snapshot changes then ride as body bullets — recorded, never silently absorbed under an unrelated subject. Attribution needs no new rule: the EchoLedger (02-architecture.md) is path-keyed, so the app's guide write classifies app-mediated by its receipt (the mechanism behind the guide-attribution exception below) and a stray edit classifies foreign, the two-commit split applying per file as everywhere else.
- **Titles truncate in subjects only** (~40 chars, keeping `git log --oneline` sane); body lines carry full titles. An untitled item reads "(untitled)" — never a bare `""` (a pathfinder edge fixed, not carried). Undo/redo restores commit as "Undo: ⟨subject⟩" / "Redo: ⟨subject⟩"; the undo-menu labels are the *crossed* commit's subject, so labels never nest. - **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. **Open raw-source buffers are settled by the same step** (settled — an unsettled raw buffer is the worse hazard: its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new branch's card): Save All *applies* each raw buffer — and since Apply validates, a buffer that fails validation cancels the whole switch with focus on the offending window, nothing half-switched; Discard exits raw source without writing; Cancel keeps everything. External checkouts the app can't gate are the accepted last-writer-wins case, same as the Edit buffer (05-card-window.md's dirty-buffer rule; on git boards the overwritten version is a commit, one revert away). Silently flushing the commit alone would be wrong twice over: the tree can be clean precisely because a save hasn't landed, and a later debounced save would write old-branch text onto the new branch's card. With sessions settled, the pending auto-commit flushes (flush-before-overwrite above) and checkout runs on a truly settled tree: it cannot fail dirty, and no in-flight work is lost or dragged across branches. (Inline title editors need no step of their own: reaching the popover's branch controls commits them — click-away commits, and board-scoped commands disable while one is focused — 04-interactions.md ▸ Grammar.) - **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 (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 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 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. - **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
@@ -77,7 +80,7 @@ Agent and hand edits arrive through the watcher like any change and get auto-com
- **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.
## Out of scope ## Out of scope
+10 -10
View File
@@ -1,6 +1,6 @@
# Sync & Collaboration # Sync & Collaboration
**Edition scope: Lanework Pro** (12-editions.md). Base Lanework ships mode:none only — the state machine below never leaves its first state there, `.git` encountered on disk is inert (12), and the Mode: none section's no-undo caveat is superseded in base by native undo (13-native-undo.md). Teams adds tracker-backed sync behind the same seam (deferred). **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:
@@ -14,7 +14,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 materialized trash (03-board-ui.md) makes card 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
@@ -22,7 +22,7 @@ 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 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). - **Optional push-on-commit**: a per-board setting (stored app-side in the board registry — 02-architecture.md's per-board app state); when enabled, **every commit the app makes** is pushed immediately — auto-commits and undo/redo restore commits alike (06-history-undo.md's restores are commits like any other; a shared board never shows a phantom lag after an undo).
- **Push failures never nag.** A push rejected as non-fast-forward (another machine pushed first) triggers an automatic **fetch → rebase → push**, with bounded retries — the same rebase machinery as Pull, so it cannot block and cannot conflict. Manual Push behaves identically. Stated plainly: enabling push-on-commit implicitly accepts that remote commits may land in the live board whenever pushes race — consistent with the board's live-reload nature, but it should be learned from the design, not discovered. All other push failures stay quiet: pushes queue, the ahead/behind indicator in the board popover carries the pending count and the last error, and pushing resumes automatically on the next commit or manual Push. No modals, no per-commit errors. The one refinement: **authentication failures pause rather than retry** — see Remote authentication below. - **Push failures never nag.** A push rejected as non-fast-forward (another machine pushed first) triggers an automatic **fetch → rebase → push**, with bounded retries — the same rebase machinery as Pull, so it cannot block and cannot conflict. Manual Push behaves identically. Stated plainly: enabling push-on-commit implicitly accepts that remote commits may land in the live board whenever pushes race — consistent with the board's live-reload nature, but it should be learned from the design, not discovered. All other push failures stay quiet: pushes queue, the ahead/behind indicator in the board popover carries the pending count and the last error, and pushing resumes automatically on the next commit or manual Push. No modals, no per-commit errors. The one refinement: **authentication failures pause rather than retry** — see Remote authentication below.
- **There can be no conflicts — and no data loss.** Every edit becomes a commit before anything can overwrite it (auto-commit settles local changes; the tree is clean by the time a pull runs). The clean-tree premise is why **abnormal repo states pause the remote half too**: a detached HEAD or in-progress merge/rebase holds Pull, Push, and push-on-commit alongside auto-commit (06-history-undo.md ▸ 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.) - **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.)
@@ -32,13 +32,13 @@ The board is a git repository (the 06-history-undo.md substrate — undo/redo, a
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)
+8 -5
View File
@@ -17,11 +17,14 @@ The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-f
- Creating cards (mkdir UUID, write `index.md`, ordering rules) — plain UTF-8, no BOM, preserving each file's existing line endings (01-storage-format.md's encoding contract). - 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).
- Deletes — move the card **or lane** folder into `<root>/.trash/` (top position; never delete a folder outright unless permanence is meant; lanes rejoined 2026-07-29 → guide v6); 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.
- v6 additionally teaches **`kind: lane`**: lane frontmatter always carries it (include it when creating a lane; stamp it when trashing a lane that lacks it) — it is what tells a trashed lane from a card inside the flat `.trash/` (01-storage-format.md ▸ Deletion). - **`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: **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. - **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: **`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. - **`modified-by` self-stamping** — stamp files you write; re-stamp every write *and every move* (the app clears it on its own writes; a bare folder move leaves the card unstamped); self-commit instead when you need exact authorship.
- New in the rewrite: a pointer to the optional **`CLAUDE.user.md`** (see below), instructing agents to read it when present. - **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`).
- 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.
+4 -4
View File
@@ -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 `.trash/`** (Save as Template already strips it, but hand-dropped user templates can carry one; a new board isn't born with trash) — **mint fresh GUIDs** for every lane/card folder, stamp `created`/`modified` fresh (the stated exception to 01-storage-format.md's copies-keep-`created` rule — a new board is born today, not forked from the template), seed the save panel's suggested name from the template title, and **set the new board's `title` to the user-chosen document name** (per 01-storage-format.md's board-naming rule, so display name and folder name start out matching). The `template:` key is kept — inert on an ordinary board. **`.git` is never copied** — a template is content, not history, and a hand-dropped user template that carries one must not produce boards that are silently in git mode (06-history-undo.md's no-silent-auto-init — the principle is *never give the user a repo they didn't ask for*, and it scopes to instantiation: File ▸ Duplicate deliberately carries `.git`, because a duplicate of a git board is a fork of its history — 03-board-ui.md): an instantiated board is never in *git* mode — its actual mode follows 06's nearest-`.git`-wins detection at the destination the save panel chose: mode `none` in a plain folder, repo-nested when saved inside an existing repository (no undo, no add-git — the popover explains, 06-history-undo.md). Beyond the `template:` residue, the result is indistinguishable from a hand-built board. 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 `.trash/`** (Save
## Save as Template ## Save as Template
A "Save as Template" function copies the current board into the user templates store; the chooser lists user templates after the bundled ones. **The copy is preceded by the close flush** (02-architecture.md ▸ Windows: editor saves, then the pending auto-commit — with the pull-style mechanical exception committing an open Edit session's on-disk saves as-is, sessions staying open), so the template never misses the last keystrokes; the same rule covers File ▸ Duplicate (03-board-ui.md), where the flush also keeps the copied `.git`'s history from lagging its tree. Under the read-only lock, Save as Template disables with one exception — the unwritable-location state, where it stays live unless an open Edit or raw-source session holds unsaved content the suspended saves can't flush (reads the board, writes Application Support; 02-architecture.md ▸ Live-reload resilience has the settled scoping). Because a template *is* a board, the copy is nearly literal: **`.git` is not copied** (a template is content, not history — copying it would embed the board's full repo, every attachment version included, in the template store; see 06-history-undo.md's repo-growth note), `.trash/` is dropped, a `template:` key is added — or, when the board already carries one (e.g. it was itself instantiated from a template), its stale `order` is overwritten — with an order appended after existing user templates; GUIDs are left as-is (instantiation mints fresh ones anyway), and timestamps are kept per 01-storage-format.md's copies-keep-`created` rule (equally inert — instantiation restamps them). 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`, `.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). - **Strays copy through.** The copy is literal apart from the stated exclusions (`.git`, `.trash/`) — `CLAUDE.user.md`, a seeded `.gitignore`, and other non-schema files carry through Save as Template *and* instantiation alike. Deliberate: a template is the folder, and `CLAUDE.user.md` carrying a board's custom agent instructions into boards born from it is a feature. The app-owned `CLAUDE.md` copies inertly and self-heals to the current guide version when the new board is opened (08-agent-integration.md).
**Storage (settled): Application Support** (`…/Lanework/Templates/`, inside the app container) as the canonical store — friction-free sandbox writes, no location ceremony — kept honest by a **Reveal in Finder** affordance in the template chooser: revealed, it's plain board folders, hand-editable and agent-writable, and a board folder dropped in becomes a template — **no `template:` key required**. Chooser order: bundled templates by `template.order`, then keyed user templates by `template.order`, then keyless user boards last, sorted by display name (`title` ?? folder name — 01-storage-format.md's board naming). An unloadable user template sorts with the keyless tier, by folder name — the failed load can supply neither `template.order` nor `title`, so the folder name is the only identity it has (and the one its unloadable row already shows). The app never stamps a key into store files it didn't write itself — a hand-dropped board is never touched, and adding a key by hand is how its user picks a position; the one writer of keyed files is Save as Template, whose own copies arrive keyed (above). A user-visible or user-configurable location was considered and set aside as ceremony disproportionate to a secondary feature; revisit if template sharing becomes a real workflow. **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).
+8 -5
View File
@@ -11,9 +11,9 @@ 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 flattened element carries the attachment count in its value, and the accessible attachment surface is the card window's keyboard-native section (below); the face itself has no media presentation (03-board-ui.md's no-carousel resettlement). - **Tree shape**: window → lanes (accessibility containers, in lane `order`) → cards (leaf elements, in card `order`). A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like the visible badge (04-interactions.md). The lane header's new-card button is a labeled child ("New card in ⟨lane⟩"). A card is **one flattened element**: label = title (or the untitled placeholder), value carries the attachment count when present, selected state via trait. Face icon and chips are decorative — folded into the element, never separately focusable: the flattened element carries the attachment count in its value, and the accessible attachment surface is the card window's keyboard-native section (below); the face itself has no media presentation (03-board-ui.md's no-carousel resettlement).
- **Logical order, not masonry position** (decided): within a wide lane, VoiceOver reads cards by `order` — the interior grid columns are presentation only. This deliberately diverges from on-screen geometry; the spatial arrow-key model (04-interactions.md) remains available alongside, since board keyboard navigation keeps working with VoiceOver running. - **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 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"). - **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, 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. **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. - **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. **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. - **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 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. - **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.
@@ -24,10 +24,11 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
- **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 (⌥⌘→/⌥⌘←, 11-command-nexus.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 (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". - **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 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. - **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. **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. - **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.
@@ -37,19 +38,21 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
- **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"). - **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. 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. - **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 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. - **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. **"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. - **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 → restore from the trash (⌘X/⌘V) → 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
+23 -9
View File
@@ -10,7 +10,7 @@ The single source of truth for **every command and action the app can perform**
| **M** | Menu command, effectively fixed | Undo/Redo only: NSUndoManager rewrites their titles dynamically, which defeats title-matched remapping (04). | | **M** | Menu command, effectively fixed | Undo/Redo only: NSUndoManager rewrites their titles dynamically, which defeats title-matched remapping (04). |
| **G** | Fixed grammar key | Platform grammar, deliberately not remappable — Finder's own Return/arrows aren't either (04 ▸ Grammar). | | **G** | Fixed grammar key | Platform grammar, deliberately not remappable — Finder's own Return/arrows aren't either (04 ▸ Grammar). |
| **P** | Pointer grammar | Clicks, drags, modifiers — not customizable. | | **P** | Pointer grammar | Clicks, drags, modifiers — not customizable. |
| **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).
@@ -29,15 +29,15 @@ The single source of truth for **every command and action the app can perform**
| File | Save as Template | — (no default) | Board window; 09-templates.md | | File | Save as Template | — (no default) | Board window; 09-templates.md |
| File | Reveal in Finder | — (no default) | Board window: the selection's folder(s), or the board root with nothing selected; card window: the card's folder — the selected attachment's file instead when the attachments section is focused; welcome: the selected recent's folder (disabled on unavailable rows) — the context-menu entry's required twin | | File | Reveal in Finder | — (no default) | Board window: the selection's folder(s), or the board root with nothing selected; card window: the card's folder — the selected attachment's file instead when the attachments section is focused; welcome: the selected recent's folder (disabled on unavailable rows) — the context-menu entry's required twin |
| File | Add Attachment… | ⇧⌘A | Card window | | File | Add Attachment… | ⇧⌘A | Card window |
| File | Delete | ⌘⌫ | Board window, any card or lane selection — staged by place (resettled 2026-07-28): board cards move to `.trash/`, trash cards delete permanently (03's recoverability confirm), lanes delete physically. Deliberately **not** extended to the card window: an enabled ⌘⌫ key equivalent would steal delete-to-line-start from the window's text surfaces, so there the card's delete is the sidebar Actions button (05) | | File | 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 Immediately | ⌘⌫ | Board window, card selection — skips the trash from anywhere; confirmed on boards without git history (mode none / repo-nested), immediate on git boards — 03 ▸ Trash | | File | 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 | Empty Trash… (confirmed) | ⇧⌘⌫ | Board window, trash shown and non-empty (whole-trash scope, search-independent — 03 ▸ Trash) | | File | Empty Trash… (confirmed) | ⇧⌘⌫ | Board window, trash shown and non-empty (whole-trash scope, search-independent — 03 ▸ Trash) |
| File | Close | ⌘W | Any window; flushes per 02 ▸ Windows | | File | Close | ⌘W | Any window; flushes per 02 ▸ Windows |
| Edit | Undo / Redo (M) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards, during 06's abnormal-state pause (detached HEAD, in-progress merge/rebase), and under the read-only lock (02) | | Edit | Undo / Redo (M) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards, during 06's abnormal-state pause (detached HEAD, in-progress merge/rebase), and under the read-only lock (02) |
| Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copies out and ⌘X/⌘V is the keyboard restore path (resettled 2026-07-28 — 04 ▸ The trash); paste never targets the trash; text editors: standard text clipboard | | Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copies out and ⌘X/⌘V is the keyboard restore path (resettled 2026-07-28 — 04 ▸ The trash); paste never targets the trash; text editors: standard text clipboard |
| Edit | Select All | ⌘A | Board: all visible live cards (filter-respecting); text editors: the text | | Edit | 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 | ⌘F | Board window: board search (04 ▸ Search); card window: find-in-text (05) | | Edit | Find | ⌘F | Board window: board search (04 ▸ Search); card window: find-in-text (05) |
| Edit | Find Next / Find Previous | ⌘G / ⇧⌘G | Card window: the find bar's stepping; disabled in the board window — board search is a live filter, not a cursor. **Use Selection for Find (⌘E) is deliberately absent**: the chord belongs to View ▸ Edit Body, which outranks the text view's binding; a user who wants it back remaps Edit Body system-natively | | 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 | 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 |
@@ -46,9 +46,17 @@ The single source of truth for **every command and action the app can perform**
| 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 | Increase Lane Width / Decrease Lane Width (the stepper's re-divide semantics, never the window's size — 03 ▸ Lane) | ⌥⌘→ / ⌥⌘← | Selected lane(s) — batches over a multi-lane selection like style (03 ▸ Lane); disabled while any text control is focused (04 ▸ Grammar, caret-chords rule) |
| Board | Pull / Push | — (no default) | Remote-backed boards only (07); disabled during 06's abnormal-state pause (the whole git surface holds) and on an unresolvable remote (07's one-time remote picker case); popover twins exist | | Board | Pull / Push | — (no default) | Remote-backed boards only (07); disabled during 06's abnormal-state pause (the whole git surface holds) and on an unresolvable remote (07's one-time remote picker case); popover twins exist |
| View | Show Trash (checkmark toggle) | — (no default) | Board window — ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled (settled; see Standard macOS furniture), so the chord is the system's; assign one via the remapping mechanism if wanted (04 ▸ Configurable bindings) | | View | Show Trash (checkmark toggle) | — (no default) | Board window — ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled (settled; see Standard macOS furniture), so the chord is the system's; assign one via the remapping mechanism if wanted (04 ▸ Configurable bindings) |
| View | 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 | 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) | | 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)
@@ -62,10 +70,13 @@ 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, Preview | Enter Edit | | Return | Card window, Preview | Enter Edit |
| Escape | Card window, Edit | Return to Preview | | Escape | Card window, Edit | Return to Preview |
| 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, 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) |
@@ -86,18 +97,21 @@ Context menus are the per-item action inventory VoiceOver reads (10 ▸ The boar
| Surface | Entries | | Surface | Entries |
|---|---| |---|---|
| Card | Open, Rename, Style…, quick-style recents row (03), Delete | | Card | Open, Rename, Style…, quick-style recents row (03), Delete (the ⌥-alternate Delete Immediately row retired with the command, 2026-07-30) |
| Lane | One menu, invoked on the header or lane empty space (settled — a full lane still has its header): Rename, Style…, quick-style recents row (03), Width control (stepper — menu twins Increase/Decrease Lane Width), Delete | | Lane | One menu, invoked on the header or lane empty space (settled — a full lane still has its header): Rename, Style…, quick-style recents row (03), Width control (stepper — menu twins Increase/Decrease Lane Width), Delete |
| Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder (inspection before a purge; twin of File ▸ Reveal in Finder, not edit-shaped, enabled on trash selections — 04 ▸ The trash) | | 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 |
| Comment | Edit (inline session — 05 ▸ The comments column), Delete (immediate, undoable — 01), Reveal in Finder |
| Welcome recent | Open, Reveal in Finder, Forget (C — registry management, welcome-scoped) | | Welcome recent | Open, Reveal in Finder, Forget (C — registry management, welcome-scoped) |
## Configuration controls (C) ## Configuration controls (C)
- **Board popover** (Board Info ⌘I — 03 ▸ Board popover): board rename; embedded style editor; add-git (mode none) / repo-nested explanation (06); branch display, switch, create; commit-identity name/email (06); add/change remote, credential fields, the SSH key surface — machine key Copy + Verify, key import by paste or drag, the per-host key picker, removal of an unreferenced import, confirm-gated machine-key regeneration — Authentication-needed state (07); ahead/behind with Pull/Push buttons and the push-on-commit toggle. - **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
+43 -25
View File
@@ -1,45 +1,59 @@
# Editions # Tiers
Lanework ships as three editions built from one codebase and one on-disk format. This doc owns the edition axis: what each edition is, how the split is engineered (targets, the provider seam), and which features land where. Individual docs stay edition-agnostic where they can — they conditionalize on **board mode** (none / git / git+remote — 07-sync-collab.md), and this doc defines which modes each edition ships. 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.
## The three editions **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.
- **Lanework** (base) — no git integration. Boards are plain folders (mode `none` everywhere); undo/redo is macOS-native (13-native-undo.md). The full board experience: lanes, cards, styling, trash, attachments, card window, templates, agents, accessibility. **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.
- **Lanework Pro** — git integration as designed in 06-history-undo.md and 07-sync-collab.md: opt-in init, adoption, git-backed undo/history, branches, remotes, pull/push, auth. Plus Pro-only differentiators (matrix below).
- **Lanework Teams** — Pro plus tracker integration over the reserved enhanced schema (`remote`/`remote-state`, `comments/` — 01-storage-format.md). **Deferred** — months out, no design pass yet; this doc only keeps the seam honest.
Why a real split and not feature flags: the editions differ at the binary level — base ships without libgit2 and without the network-client entitlement; Pro links git machinery and talks to remotes. And the strategic reason (settled): Teams' card sync must be **backend-agnostic** — it has to work over git and over a range of trackers — so history and sync must sit behind a genuine provider seam. Base's native undo is the first proof the seam is real: two working history providers before a third arrives. ## The tiers
## Distribution (settled) - **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.
Separate Mac App Store apps — real Xcode targets, not an IAP unlock. Both editions declare the same `.kanban` package UTI (`dev.rzen.indie.kanban-board`) verbatim, so any board opens in either app; the format is one format, owned by 01-storage-format.md, with no edition-specific keys. Upgrade path is buying the other app; boards need no migration whatsoever (files are the interchange). The 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 ## The provider seam
History (and later sync) is a provider behind one protocol boundary, chosen per edition at the composition root: 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. Base binds the native undo stack (13-native-undo.md: NSUndoManager over inverse `WriteOperation`s). Pro binds the git provider (06-history-undo.md: undo as forward restore commits over HEAD's first-parent ancestry). Teams inherits Pro's. - **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. - **Sync/tracker providers** — deferred with Teams; the reserved schema keys and the one-way file flow (02-architecture.md) are the format-level seam already in place.
What is shared across providers (settled): **06's Undo routing is edition-independent** — focus decides text-undo vs board-undo; only the substrate behind board-undo differs. The command surface is identical (⌘Z/⇧⌘Z, dynamically retitled menu items — both providers use NSUndoManager's title rewriting); menu titles draw on the same semantic vocabulary (06 ▸ Commit messages). A user moving between editions relearns nothing. 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.
## Base and `.git` — the inert posture (settled) ## The free tier and `.git` — the inert posture (settled; now also the lapsed posture)
Base Lanework generalizes the repo-nested stance to every `.git` it meets: **any `.git` is inert**. Opening a board that has one (a Pro user's board, a repo-nested board) works normally — files read and write as on any board, native undo runs, the trash works — but the app never reads history, never commits, never touches `.git` in any way. To base, `.git` at the board root is a stray like any other, preserved verbatim. Pro's external-writer machinery (06 ▸ Interaction with external writers) already reconciles the uncommitted drift a base session leaves behind — a base edit is just a foreign change to Pro. The watcher's `.git` event filtering stays in base (it exists to ignore git churn, which mixed households will produce). 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).
Base's popover git slot (03-board-ui.md ▸ Board popover) does not offer add-git. **Its posture is contextual** (settled — ruled 2026-07-27): on ordinary boards the section is simply absent — the popover is rename + style, complete in itself. Only when the board carries an inert `.git` does a calm info line appear: "This board has a git history. Lanework Pro works with it." — an honest explanation of what the folder is, surfacing exactly where the question arises, never a standing ad. The card window's absent History section follows the same pattern: absent, no placeholder. 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.
## Edition matrix ## Tier matrix
The feature sort. Everything not listed rides with "board experience" and is identical everywhere. The feature sort. Everything not listed rides with "board experience" and is identical everywhere.
| Feature | Lanework | Pro | Teams | | Feature | Lanework (free) | Pro | Teams |
|---|---|---|---| |---|---|---|---|
| Board experience: lanes, cards, drag & drop, keyboard map, clipboard, search, styling, trash, attachments, card window, templates, welcome screen | ✓ | ✓ | ✓ | | Board experience: lanes, cards, drag & drop, keyboard map, clipboard, search, styling, trash, attachments, card window, templates, welcome screen | ✓ | ✓ | ✓ |
| Agent integration: agent guide, `modified-by` attribution, tolerance rules | ✓ | ✓ | ✓ | | Agent integration: agent guide, `modified-by` attribution, tolerance rules | ✓ | ✓ | ✓ |
| Accessibility (10-accessibility.md, all of it) | ✓ | ✓ | ✓ | | Accessibility (10-accessibility.md, all of it) | ✓ | ✓ | ✓ |
| Comments (post-2.0 design pass; per the standing decision, **all editions**) | ✓ (future) | ✓ (future) | ✓ (future) | | Comments (designed 2026-07-29 — 01 ▸ Enhanced schema + 05 ▸ The comments column; ships post-2.0) | ✓ | ✓ | ✓ + tracker-synced threads |
| Undo/redo | native (13) | git (06) | git (06) | | Undo/redo | native (13) | git on git boards, native otherwise — repo-nested included (06/13, re-ruled 2026-07-31) | inherits Pro |
| Undo of foreign/agent edits | — (honest gap, 13) | ✓ (stack absorbs foreign commits) | ✓ | | Undo of foreign/agent edits | — (honest gap, 13) | ✓ (stack absorbs foreign commits) | ✓ |
| Overwrite protection (flush-before-overwrite, both-versions-as-commits) | — (07's accepted caveat is permanent here; trash + native undo are the safety story) | ✓ | ✓ | | Overwrite protection (flush-before-overwrite, both-versions-as-commits) | — (07's accepted caveat is permanent here; trash + native undo are the safety story) | ✓ | ✓ |
| History surfaces: card History sidebar (05), View ▸ History (11) | — | ✓ | ✓ | | History surfaces: card History sidebar (05), View ▸ History (11) | — | ✓ | ✓ |
@@ -48,16 +62,20 @@ The feature sort. Everything not listed rides with "board experience" and is ide
| "While you were away" digest (WISHLIST item 1, requires git) | — | ✓ (future) | ✓ (future) | | "While you were away" digest (WISHLIST item 1, requires git) | — | ✓ (future) | ✓ (future) |
| Tracker integration (`remote`/`remote-state` sync, tracker-backed boards) | — | — | ✓ (future) | | Tracker integration (`remote`/`remote-state` sync, tracker-backed boards) | — | — | ✓ (future) |
Docs 06 and 07 are **Pro-edition docs**; every other doc applies to all editions, with mode-conditioned passages (undo availability, popover git surface, Delete Immediately's confirmation branch) resolving per the modes the edition ships. Base ships exactly one mode: `none` (with the inert-`.git` posture above); Pro ships the full state machine. 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.
## Targets (settled shape; details at implementation) ## The target (re-ruled 2026-07-30)
Three app targets sharing sources: `Lanework` (base — excludes Pro-only source dirs, no libgit2, current minimal entitlements), `LaneworkPro` (adds git sources, libgit2 dependency, network-client entitlement, Keychain access), Teams deferred. **Bundle ids** (settled — ruled 2026-07-27): base keeps `dev.rzen.indie.Kanban` — it is the current app and ships first, so nothing re-wires; Pro mints `dev.rzen.indie.KanbanPro` with matching test-target ids; Teams reserves nothing yet. Shared UTI declaration in both Info.plists (base remains the exporter); distinct icon assets; parallel test targets and schemes. Milestones: the current plan's m7-git-undo and m8-remote-sync become Pro milestones, built as the git HistoryProvider behind the seam after base ships; a new native-undo milestone takes their place for base. **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.
## Edition naming in base (settled — ruled 2026-07-27) ## App-side state (re-ruled 2026-07-30)
**Quiet signposts.** Base presents as a complete app, not a demo: Pro is named in exactly two places — one line in the About box, and the contextual popover line on `.git` boards (above). Nothing on the welcome screen, nothing in banners. The iCloud/network-volume warning (07-sync-collab.md) is rewritten for base without the git recommendation — it warns on its own merits (eviction, silent forks) and recommends a local folder; there is no git to recommend and no Pro pitch in a warning (a warning that sells reads as manufactured). 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 ## Open questions
None currently — bundle ids, the popover slot posture, and edition naming were ruled 2026-07-27 and are settled above. 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.
+9 -7
View File
@@ -1,21 +1,23 @@
# Native Undo (base edition) # Native Undo
The undo/redo substrate for base Lanework (12-editions.md), filling the one gap mode:none admits (06-history-undo.md, 07-sync-collab.md): boards without git had no undo. Pro's substrate remains git (06); this doc never applies there. The design problem is not NSUndoManager itself — it is native undo over **files-are-truth**: the disk can change underneath the stack, because the app is not the only writer. The undo/redo substrate for **every board without app-managed git** (re-ruled 2026-07-31 — the provider follows the board, not the tier alone; formerly free-tier-only, which made a Pro upgrade *remove* undo from mode-none boards). Since the 2026-08-07 pivot (12-editions.md — git left the paywall) the tier axis is gone entirely, and one thing the pivot does **not** change: git remains **opt-in per board** — creating a local repository is the user's deliberate choice (add-git, 06), never something the app initializes for them — so a board whose user never opted in keeps this conventional stack for good. The substrate is the board's mode alone: mode-none **and repo-nested** boards bind it (the repo-nested no-undo case retired 2026-07-31: leave-strictly-alone concerns *git*, and this stack never touches git — memory-only, journal-free, session-scoped — so what repo-nested denies is app-managed history, never ⌘Z; the upgrade story is thereby exceptionless), switching to the git provider (06-history-undo.md) where the board's own git exists. **Add-git swaps the substrate mid-session** — the commanded flip discards the in-session native stack and seeds the git trail from the root commit, the branch-switch discard-and-reseed precedent applied; a subscription lapse still never interrupts (12). 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 ## Rules
- **One stack per board, owned by the board session.** Not per-window: every window over a board (board window, its card windows) shares the store and shares the stack. `window.undoManager` for board surfaces returns the session's manager; 06 ▸ Undo routing applies unchanged — text-editing surfaces get their session-scoped text undo, everywhere else ⌘Z/⇧⌘Z hit the board stack. Undo is board-local, exactly as git undo was. - **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 `window.undoManager` answers with it (standard per-window AppKit scoping). 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. - **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** (Delete Immediately, Empty Trash) — `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule (03-board-ui.md) already fires on all base boards, since none have git history: the confirm *is* the safety. **Repair** (01-storage-format.md's duplicate-id remint) — undoing a remint would recreate the duplicate id the operation exists to remove. Both match their existing "destructive, confirmed, final" posture. - **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 — on git boards the write commits like any disk change (05-card-window.md's carve-outs are the same statement from the stamping side).
- **Coalescing follows commit granularity** (settled): one gesture, one undo step — a multi-card move is one step with a plural title; an Edit session is one step, registered at the Edit→Preview flip (the effective Save — 05-card-window.md); a styling batch is one step (03's one-gesture-one-commit rule, substrate swapped). The 06 vocabulary supplies menu titles ("Undo Move 3 Cards"), via NSUndoManager's dynamic retitling — the same naming machinery both editions use. - **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. Git undo's survive-relaunch property is a Pro difference, stated honestly (12's matrix). - **Session-only persistence** (settled): the stack lives with the board session and dies at close/quit — standard macOS behavior. Git undo's survive-relaunch property is a Pro difference, stated honestly (12's matrix).
- **Foreign writes never join the stack** (settled): NSUndoManager can only undo what the app mediated. An agent's or hand edit is not a step — the honest capability gap vs Pro (12's matrix). Foreign changes also do not clear the stack wholesale; collisions are handled lazily, per step, by validation: - **Foreign writes never join the stack** (settled): NSUndoManager can only undo what the app mediated. An agent's or hand edit is not a step — the honest capability gap vs Pro (12's matrix). Foreign changes also do not clear the stack wholesale; collisions are handled lazily, per step, by validation:
- **Staleness validation before every apply** (settled): an inverse operation re-checks its target against the 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. **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. - **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. - **Locks disable the stack** (settled): every read-only lock (vanished root, failed reload after wholesale ops, unwritable location — 02-architecture.md) disables Undo/Redo with the other mutating commands; the stack itself survives the lock and resumes when it clears. Steps landed before a lock validate like any other at apply time.
## Interaction with the trash ## 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 the trash, so nothing needs byte capture. A **permanent delete registers no step**Delete Immediately and Empty Trash are not undoable (Rules above), lanes and their freight included; the confirm is the safety. ⌫'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). On Pro the substrate is history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge rides the close flush there as before — purge timing follows the undo substrate's need.
## Out of scope ## Out of scope
+37
View File
@@ -0,0 +1,37 @@
# Git Operations — Extracted Conclusions
**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.
+8 -5
View File
@@ -18,18 +18,21 @@ Each document covers one aspect of the design. Within each:
| [03-board-ui.md](03-board-ui.md) | Board window: layout, lanes, cards, styling, templates | | [03-board-ui.md](03-board-ui.md) | Board window: layout, lanes, cards, styling, templates |
| [04-interactions.md](04-interactions.md) | Selection, drag & drop, keyboard, clipboard, search | | [04-interactions.md](04-interactions.md) | Selection, drag & drop, keyboard, clipboard, search |
| [05-card-window.md](05-card-window.md) | The card window: Markdown preview/edit, attachments | | [05-card-window.md](05-card-window.md) | The card window: Markdown preview/edit, attachments |
| [06-history-undo.md](06-history-undo.md) | Git-backed undo/redo and history — **Pro edition** | | [06-history-undo.md](06-history-undo.md) | Git-backed undo/redo and history — **Pro tier** |
| [07-sync-collab.md](07-sync-collab.md) | Board modes: local-only, git; iCloud Drive warned against — **Pro edition** | | [07-sync-collab.md](07-sync-collab.md) | Board modes: local-only, git; iCloud Drive warned against — **Pro tier** |
| [08-agent-integration.md](08-agent-integration.md) | AI agents as first-class users of the board | | [08-agent-integration.md](08-agent-integration.md) | AI agents as first-class users of the board |
| [09-templates.md](09-templates.md) | Board templates: inventory and definition format | | [09-templates.md](09-templates.md) | Board templates: inventory and definition format |
| [10-accessibility.md](10-accessibility.md) | VoiceOver, text scaling, visual accommodations | | [10-accessibility.md](10-accessibility.md) | VoiceOver, text scaling, visual accommodations |
| [11-command-nexus.md](11-command-nexus.md) | The command Nexus — every command and action: bindings, contexts, customizability | | [11-command-nexus.md](11-command-nexus.md) | The command Nexus — every command and action: bindings, contexts, customizability |
| [12-editions.md](12-editions.md) | The three editions (Lanework / Pro / Teams): distribution, provider seam, feature matrix | | [12-editions.md](12-editions.md) | The tiers (free / Pro subscription / Teams deferred): one-app distribution, entitlement, provider seam, feature matrix |
| [13-native-undo.md](13-native-undo.md) | macOS-native undo/redo — the base edition's history substrate | | [13-native-undo.md](13-native-undo.md) | macOS-native undo/redo — the free tier's history substrate |
| [14-git-operations.md](14-git-operations.md) | Extracted pathfinder git-operations conclusions: the forward-restore model — **Pro tier** |
## Deferred design iterations ## Deferred design iterations
The card window (05-card-window.md), toolbar (03-board-ui.md ▸ Toolbar), and styling controls (03-board-ui.md ▸ Styling ▸ Controls) each had their focused pass and are settled. The 2026-07-27 edition split (12-editions.md) added two deferred passes: the attachment-undo staging design and the EchoLedger foreign-undo bridge (both flagged in 13-native-undo.md), plus Teams' tracker integration (no design yet). 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).
## Wishlist ## Wishlist
@@ -0,0 +1,542 @@
<title>Board Backgrounds — Facets</title>
<style>
:root{
--paper:#FAFAF7;
--ink:#1D1C1A;
--ink-2:#6E6A63;
--line:#E4E1DA;
--chip:#F0EEE8;
--accent:#5B6E8C;
--paper-a85: rgba(250,250,247,.85);
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--paper:#181715;
--ink:#ECEAE5;
--ink-2:#98938A;
--line:#2E2C28;
--chip:#242220;
--accent:#8FA3C0;
--paper-a85: rgba(24,23,21,.85);
}
}
:root[data-theme="dark"]{
--paper:#181715;
--ink:#ECEAE5;
--ink-2:#98938A;
--line:#2E2C28;
--chip:#242220;
--accent:#8FA3C0;
--paper-a85: rgba(24,23,21,.85);
}
*, *::before, *::after{ box-sizing:border-box; }
body{
margin:0;
background:var(--paper);
color:var(--ink);
font-family:-apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
-webkit-font-smoothing:antialiased;
}
.wrap{ max-width:1240px; margin:0 auto; padding:0 32px 64px; }
.page-head{ padding:32px 0 16px; }
.page-head h1{ font-size:22px; font-weight:600; margin:0 0 6px; text-wrap:balance; }
.page-head .subtitle{ font-size:13px; color:var(--ink-2); margin:0; max-width:760px; }
.controlbar{
position:sticky; top:0; z-index:10;
display:flex; align-items:center; gap:20px; row-gap:10px; flex-wrap:wrap;
padding:12px 0;
background:var(--paper-a85);
-webkit-backdrop-filter:blur(8px);
backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);
margin-bottom:28px;
}
.chipgroup{ display:flex; gap:6px; flex-wrap:wrap; }
.chip{
font:inherit; font-size:12px; padding:5px 11px; border-radius:999px;
border:1px solid var(--line); background:var(--chip); color:var(--ink);
cursor:pointer; line-height:1.3;
}
.chip:hover{ border-color:var(--accent); }
.chip.active{ background:var(--accent); border-color:var(--accent); color:var(--paper); }
.chip:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
.toggle{ display:flex; align-items:center; gap:6px; font-size:12px; color:var(--ink); cursor:pointer; user-select:none; }
.toggle input{ width:14px; height:14px; accent-color:var(--accent); }
.toggle input:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
.gen-note{ font-size:12px; color:var(--ink-2); font-family:ui-monospace,"SF Mono",Menlo,monospace; }
.recipe-box{ margin-bottom:40px; }
.section-label{ font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-2); margin:0 0 10px; }
.recipe-list{ display:grid; grid-template-columns:1fr 1fr; gap:7px 32px; margin:0; }
.recipe-item{ display:flex; gap:6px; font-size:13px; line-height:1.5; }
.recipe-item dt{ margin:0; font-weight:600; color:var(--ink-2); flex:0 0 auto; }
.recipe-item dd{ margin:0; color:var(--ink-2); }
@media (max-width:720px){ .recipe-list{ grid-template-columns:1fr; } }
.family{ margin-bottom:44px; }
.family h2{ font-size:15px; font-weight:600; margin:0 0 4px; }
.family .recipe{ font-size:13px; color:var(--ink-2); margin:0 0 14px; max-width:820px; }
.colheads{
display:grid; grid-template-columns:repeat(3, 1fr); gap:14px;
margin-bottom:8px;
position:sticky; top:56px; z-index:5;
background:var(--paper-a85);
-webkit-backdrop-filter:blur(8px);
backdrop-filter:blur(8px);
padding:4px 0;
}
.colheads span{
font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase;
color:var(--ink-2);
}
.grid{ display:grid; grid-template-columns:repeat(3, 1fr); gap:14px; }
@media (max-width:720px){
.colheads{ display:none; }
.grid{ grid-template-columns:1fr; }
}
.card{ cursor:pointer; }
.frame{
aspect-ratio:16/10; border-radius:8px; border:1px solid var(--line);
overflow:hidden; background:var(--chip);
}
.frame svg, .lightbox-frame svg{ display:block; width:100%; height:100%; }
.caption{ display:flex; justify-content:space-between; align-items:baseline; margin-top:6px; font-size:11px; gap:8px; }
.caption .swatch-id{ font-family:ui-monospace,"SF Mono",Menlo,monospace; color:var(--ink); }
.caption .note{ color:var(--ink-2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.board-overlay{ display:none; }
body.show-overlay .board-overlay{ display:inline; }
.lightbox{
position:fixed; inset:0; z-index:100;
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px;
background:rgba(0,0,0,.55);
opacity:0; pointer-events:none;
transition:opacity .12s ease;
}
.lightbox.open{ opacity:1; pointer-events:auto; }
.lightbox-frame{
width:min(92vw, 1100px); aspect-ratio:16/10; border-radius:10px; overflow:hidden;
box-shadow:0 24px 60px rgba(0,0,0,.45);
background:var(--chip);
}
.lightbox-id{ font-family:ui-monospace,"SF Mono",Menlo,monospace; font-size:12px; color:#fff; }
@media (prefers-reduced-motion: reduce){
.lightbox{ transition:none; }
}
</style>
<div class="wrap">
<header class="page-head">
<h1>Board Backgrounds — Facets, round 2</h1>
<p class="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>
</header>
<div class="controlbar">
<div class="chipgroup" id="strategy-chips" role="group" aria-label="Color-count filter">
<button type="button" class="chip active" data-strategy="all" aria-pressed="true">All</button>
<button type="button" class="chip" data-strategy="mono" aria-pressed="false">Mono</button>
<button type="button" class="chip" data-strategy="duo" aria-pressed="false">Duo</button>
<button type="button" class="chip" data-strategy="trio" aria-pressed="false">Trio</button>
</div>
<div class="chipgroup" id="tone-chips" role="group" aria-label="Tone filter">
<button type="button" class="chip active" data-tone="all" aria-pressed="true">All</button>
<button type="button" class="chip" data-tone="light" aria-pressed="false">Light</button>
<button type="button" class="chip" data-tone="dark" aria-pressed="false">Dark</button>
</div>
<label class="toggle">
<input type="checkbox" id="overlay-toggle">
<span>Board overlay</span>
</label>
<button type="button" class="chip" id="reroll">↻ Reroll geometry</button>
<span class="gen-note" id="gen-note">gen 1</span>
</div>
<section class="recipe-box">
<p class="section-label">Recipe parameters</p>
<dl class="recipe-list">
<div class="recipe-item"><dt>Vertices</dt><dd>coarse 5×3 cells (~20 triangles) · medium 9×6 (~97) · fine 14×9 (~230), grid-jittered then Delaunay-triangulated</dd></div>
<div class="recipe-item"><dt>Colors</dt><dd>mono · duo = complement H+180° weighted 65/35 · trio = triad H±120° weighted 50/30/20, picked per triangle</dd></div>
<div class="recipe-item"><dt>Saturation</dt><dd>soft · mid · rich row bands, jittered ±15% per triangle</dd></div>
<div class="recipe-item"><dt>Brightness</dt><dd>narrow band — tone base (light ≈8590%, dark ≈1721%) ±4.5 per triangle; all hues in a swatch share it</dd></div>
<div class="recipe-item"><dt>Hue</dt><dd>amber 38° · forest 140° · sky 215° · rose 335° (±3° per triangle)</dd></div>
<div class="recipe-item"><dt>Randomness</dt><dd>geometry and per-triangle color assignment reseed on reroll; the swatch ID names the recipe</dd></div>
</dl>
</section>
<section class="family" data-strategy="mono" data-tone="light">
<h2>Mono — Light</h2>
<p class="recipe">One hue; only the brightness jitter draws the mesh. Rows: each hue at soft, mid, rich saturation.</p>
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
<div class="grid" id="grid-mono-light"></div>
</section>
<section class="family" data-strategy="duo" data-tone="light">
<h2>Duo — Light</h2>
<p class="recipe">Base hue plus its complement, weighted 65/35 — a dominant field with contrasting inclusions.</p>
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
<div class="grid" id="grid-duo-light"></div>
</section>
<section class="family" data-strategy="trio" data-tone="light">
<h2>Trio — Light</h2>
<p class="recipe">A contrasting triad (H, H+120°, H120°) weighted 50/30/20 — closest in spirit to the low-poly reference.</p>
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
<div class="grid" id="grid-trio-light"></div>
</section>
<section class="family" data-strategy="mono" data-tone="dark">
<h2>Mono — Dark</h2>
<p class="recipe">The mono mesh on the dark base — facets catching light like slate.</p>
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
<div class="grid" id="grid-mono-dark"></div>
</section>
<section class="family" data-strategy="duo" data-tone="dark">
<h2>Duo — Dark</h2>
<p class="recipe">Complementary pair on the dark base; at low brightness the hue contrast turns ember-like.</p>
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
<div class="grid" id="grid-duo-dark"></div>
</section>
<section class="family" data-strategy="trio" data-tone="dark">
<h2>Trio — Dark</h2>
<p class="recipe">The triad on the dark base — stained glass at dusk.</p>
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
<div class="grid" id="grid-trio-dark"></div>
</section>
</div>
<div class="lightbox" id="lightbox">
<div class="lightbox-frame" id="lightbox-frame"></div>
<div class="lightbox-id" id="lightbox-id"></div>
</div>
<script>
(function(){
"use strict";
// ---------- seeded PRNG (same as sweep-1 gallery) ----------
function xmur3(str){
let h = 1779033703 ^ str.length;
for(let i=0;i<str.length;i++){
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return function(){
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
h ^= h >>> 16;
return h >>> 0;
};
}
function mulberry32(a){
return function(){
let t = (a += 0x6D2B79F5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededRng(id){
return mulberry32(xmur3(id)());
}
// ---------- helpers ----------
function mod360(h){ return ((h % 360) + 360) % 360; }
function clamp(v,min,max){ return v < min ? min : (v > max ? max : v); }
function hsl(h,s,l){ return "hsl(" + f1(mod360(h)) + "," + f1(clamp(s,0,100)) + "%," + f1(clamp(l,0,100)) + "%)"; }
function rnd(rng,min,max){ return min + rng() * (max - min); }
function rndInt(rng,min,max){ return Math.floor(rnd(rng,min,max+1)); }
function f1(n){ return n.toFixed(1); }
// ---------- Delaunay (BowyerWatson) ----------
function circumcircle(a,b,c){
const ax=a[0],ay=a[1],bx=b[0],by=b[1],cx=c[0],cy=c[1];
const d = 2*(ax*(by-cy)+bx*(cy-ay)+cx*(ay-by));
if(Math.abs(d) < 1e-9) return null;
const a2 = ax*ax+ay*ay, b2 = bx*bx+by*by, c2 = cx*cx+cy*cy;
const ux = (a2*(by-cy)+b2*(cy-ay)+c2*(ay-by))/d;
const uy = (a2*(cx-bx)+b2*(ax-cx)+c2*(bx-ax))/d;
const dx = ax-ux, dy = ay-uy;
return { x:ux, y:uy, r2:dx*dx+dy*dy };
}
function triangulate(points){
const pts = points.slice();
const st = pts.length;
pts.push([-3000,-3000],[3500,-3000],[240,3600]);
let tris = [{ i:[st,st+1,st+2], cc:circumcircle(pts[st],pts[st+1],pts[st+2]) }];
for(let pi=0; pi<st; pi++){
const p = pts[pi];
const bad = [];
for(let t=0;t<tris.length;t++){
const cc = tris[t].cc;
if(cc){
const dx = p[0]-cc.x, dy = p[1]-cc.y;
if(dx*dx+dy*dy < cc.r2) bad.push(tris[t]);
}
}
const edgeCount = new Map();
for(let b=0;b<bad.length;b++){
const idx = bad[b].i;
for(let e=0;e<3;e++){
const u = idx[e], v = idx[(e+1)%3];
const key = u < v ? u+"_"+v : v+"_"+u;
edgeCount.set(key,(edgeCount.get(key)||0)+1);
}
}
const badSet = new Set(bad);
tris = tris.filter(function(t){ return !badSet.has(t); });
for(let b=0;b<bad.length;b++){
const idx = bad[b].i;
for(let e=0;e<3;e++){
const u = idx[e], v = idx[(e+1)%3];
const key = u < v ? u+"_"+v : v+"_"+u;
if(edgeCount.get(key) === 1){
const cc = circumcircle(pts[u],pts[v],p);
if(cc) tris.push({ i:[u,v,pi], cc:cc });
}
}
}
}
const out = [];
for(let t=0;t<tris.length;t++){
const idx = tris[t].i;
if(idx[0] < st && idx[1] < st && idx[2] < st) out.push(idx);
}
return { pts: pts, tris: out };
}
// ---------- color ----------
// Weighted per-triangle hue pick; weights sum to 1.
function pickHue(rng, hueList){
let x = rng();
for(let i=0;i<hueList.length;i++){
if(x < hueList[i].w) return hueList[i].h;
x -= hueList[i].w;
}
return hueList[hueList.length-1].h;
}
function triangleColor(rng, hueList, spec){
const h = pickHue(rng, hueList) + rnd(rng,-3,3);
const s = spec.sat * rnd(rng,0.85,1.15);
const l = spec.lBase + rnd(rng,-spec.lJit,spec.lJit);
return hsl(h,s,l);
}
// ---------- facets generator ----------
// Points scatter beyond the 480x300 viewBox so the mesh covers the frame edge-to-edge.
function genFacets(rng, hueList, spec, density){
const pts = [];
const x0 = -36, y0 = -36, x1 = 516, y1 = 336;
const cols = density.cols, rows = density.rows;
const cw = (x1-x0)/cols, ch = (y1-y0)/rows;
for(let c=0;c<cols;c++){
for(let r=0;r<rows;r++){
pts.push([ x0 + (c + rnd(rng,0.08,0.92))*cw, y0 + (r + rnd(rng,0.08,0.92))*ch ]);
}
}
const mesh = triangulate(pts);
let s = '<rect width="480" height="300" fill="' + hsl(hueList[0].h, spec.sat, spec.lBase) + '"/>';
for(let t=0;t<mesh.tris.length;t++){
const idx = mesh.tris[t];
const a = mesh.pts[idx[0]], b = mesh.pts[idx[1]], c = mesh.pts[idx[2]];
const color = triangleColor(rng, hueList, spec);
const ptsAttr = f1(a[0])+","+f1(a[1])+" "+f1(b[0])+","+f1(b[1])+" "+f1(c[0])+","+f1(c[1]);
s += '<polygon points="' + ptsAttr + '" fill="' + color + '" stroke="' + color + '" stroke-width="0.7"/>';
}
return s;
}
// ---------- board overlay (same as sweep-1 gallery) ----------
function buildOverlay(rng, tone){
const laneFill = tone === "dark" ? "rgba(20,20,24,0.40)" : "rgba(255,255,255,0.38)";
const cardFill = tone === "dark" ? "#26262B" : "#FFFFFF";
const cardStroke = tone === "dark" ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.08)";
const titleFill = tone === "dark" ? "rgba(22,22,26,0.55)" : "rgba(255,255,255,0.55)";
let g = '<g class="board-overlay">';
g += '<rect x="0" y="0" width="480" height="34" fill="' + titleFill + '"/>';
const laneX = [16,168,320];
for(let li=0; li<laneX.length; li++){
const lx = laneX[li];
g += '<rect x="' + lx + '" y="40" width="144" height="250" rx="8" fill="' + laneFill + '"/>';
let y = 48;
const count = rndInt(rng,2,4);
for(let c=0;c<count;c++){
const h = rnd(rng,26,44);
if(y + h > 282) break;
g += '<rect x="' + (lx+8) + '" y="' + f1(y) + '" width="128" height="' + f1(h) + '" rx="5" fill="' + cardFill + '" stroke="' + cardStroke + '"/>';
y += h + 8;
}
}
g += "</g>";
return g;
}
// ---------- registry ----------
const HUES = [
{ code:"am", name:"amber", h:38 },
{ code:"fo", name:"forest", h:140 },
{ code:"sk", name:"sky", h:215 },
{ code:"ro", name:"rose", h:335 }
];
const STRATEGIES = [
{ id:"mono", hues:function(H){ return [{h:H, w:1}]; } },
{ id:"duo", hues:function(H){ return [{h:H, w:0.65},{h:H+180, w:0.35}]; } },
{ id:"trio", hues:function(H){ return [{h:H, w:0.5},{h:H+120, w:0.3},{h:H-120, w:0.2}]; } }
];
const DENSITIES = [
{ id:"c", label:"coarse", cols:5, rows:3 },
{ id:"m", label:"medium", cols:9, rows:6 },
{ id:"f", label:"fine", cols:14, rows:9 }
];
// Saturation rows and brightness bases per tone. Rich rows get a touch of
// brightness headroom so the saturation actually shows.
const TONES = {
light: { lJit:4.5, levels:[ {label:"soft", sat:20, lBase:90}, {label:"mid", sat:42, lBase:88}, {label:"rich", sat:68, lBase:85} ] },
dark: { lJit:4.5, levels:[ {label:"soft", sat:16, lBase:17}, {label:"mid", sat:34, lBase:19}, {label:"rich", sat:52, lBase:21} ] }
};
// ---------- lightbox ----------
let openState = null;
const lightbox = document.getElementById("lightbox");
const lightboxFrame = document.getElementById("lightbox-frame");
const lightboxId = document.getElementById("lightbox-id");
function openLightbox(svgEl, id){
openState = { svgEl:svgEl, parent:svgEl.parentNode, next:svgEl.nextSibling };
lightboxFrame.appendChild(svgEl);
lightboxId.textContent = id;
lightbox.classList.add("open");
}
function closeLightbox(){
if(!openState) return;
if(openState.next){
openState.parent.insertBefore(openState.svgEl, openState.next);
} else {
openState.parent.appendChild(openState.svgEl);
}
openState = null;
lightbox.classList.remove("open");
}
lightbox.addEventListener("click", function(e){
if(e.target === lightbox) closeLightbox();
});
document.addEventListener("keydown", function(e){
if(e.key === "Escape") closeLightbox();
});
// ---------- render ----------
let gen = 1;
function renderAll(){
closeLightbox();
const toneKeys = ["light","dark"];
for(let si=0; si<STRATEGIES.length; si++){
const strategy = STRATEGIES[si];
for(let ti=0; ti<toneKeys.length; ti++){
const tone = toneKeys[ti];
const toneSpec = TONES[tone];
const grid = document.getElementById("grid-" + strategy.id + "-" + tone);
grid.innerHTML = "";
// rows: hue × saturation; columns: density
for(let hi=0; hi<HUES.length; hi++){
for(let li=0; li<toneSpec.levels.length; li++){
const level = toneSpec.levels[li];
for(let di=0; di<DENSITIES.length; di++){
const density = DENSITIES[di];
const id = "facets-" + HUES[hi].code + "-" + strategy.id + "-" + density.id + "-" + tone.charAt(0) + (li+1);
const rng = seededRng(id + "/g" + gen);
const spec = { sat:level.sat, lBase:level.lBase, lJit:toneSpec.lJit };
const hueList = strategy.hues(HUES[hi].h);
const inner = genFacets(rng, hueList, spec, density);
const overlay = buildOverlay(rng, tone);
const svgMarkup = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 300" data-id="' + id + '">' + inner + overlay + "</svg>";
const card = document.createElement("div");
card.className = "card";
const frame = document.createElement("div");
frame.className = "frame";
frame.innerHTML = svgMarkup;
const svgEl = frame.firstElementChild;
const caption = document.createElement("div");
caption.className = "caption";
const idSpan = document.createElement("span");
idSpan.className = "swatch-id";
idSpan.textContent = id;
const noteSpan = document.createElement("span");
noteSpan.className = "note";
noteSpan.textContent = HUES[hi].name + " · " + level.label;
caption.appendChild(idSpan);
caption.appendChild(noteSpan);
card.appendChild(frame);
card.appendChild(caption);
card.addEventListener("click", function(){ openLightbox(svgEl, id); });
grid.appendChild(card);
}
}
}
}
}
document.getElementById("gen-note").textContent = "gen " + gen;
}
// ---------- filters ----------
let activeStrategy = "all", activeTone = "all";
const strategyChips = document.querySelectorAll("#strategy-chips .chip");
const toneChips = document.querySelectorAll("#tone-chips .chip");
function applyFilters(){
const sections = document.querySelectorAll(".family");
for(let i=0;i<sections.length;i++){
const sec = sections[i];
const strat = sec.dataset.strategy, tone = sec.dataset.tone;
const visible = (activeStrategy === "all" || activeStrategy === strat) && (activeTone === "all" || activeTone === tone);
sec.style.display = visible ? "" : "none";
}
}
strategyChips.forEach(function(chip){
chip.addEventListener("click", function(){
strategyChips.forEach(function(c){ c.classList.remove("active"); c.setAttribute("aria-pressed","false"); });
chip.classList.add("active");
chip.setAttribute("aria-pressed","true");
activeStrategy = chip.dataset.strategy;
applyFilters();
});
});
toneChips.forEach(function(chip){
chip.addEventListener("click", function(){
toneChips.forEach(function(c){ c.classList.remove("active"); c.setAttribute("aria-pressed","false"); });
chip.classList.add("active");
chip.setAttribute("aria-pressed","true");
activeTone = chip.dataset.tone;
applyFilters();
});
});
document.getElementById("overlay-toggle").addEventListener("change", function(e){
document.body.classList.toggle("show-overlay", e.target.checked);
});
document.getElementById("reroll").addEventListener("click", function(){
gen++;
renderAll();
});
renderAll();
})();
</script>
@@ -0,0 +1,755 @@
<title>Board Background Swatches</title>
<style>
:root{
--paper:#FAFAF7;
--ink:#1D1C1A;
--ink-2:#6E6A63;
--line:#E4E1DA;
--chip:#F0EEE8;
--accent:#5B6E8C;
--paper-a85: rgba(250,250,247,.85);
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--paper:#181715;
--ink:#ECEAE5;
--ink-2:#98938A;
--line:#2E2C28;
--chip:#242220;
--accent:#8FA3C0;
--paper-a85: rgba(24,23,21,.85);
}
}
:root[data-theme="dark"]{
--paper:#181715;
--ink:#ECEAE5;
--ink-2:#98938A;
--line:#2E2C28;
--chip:#242220;
--accent:#8FA3C0;
--paper-a85: rgba(24,23,21,.85);
}
*, *::before, *::after{ box-sizing:border-box; }
body{
margin:0;
background:var(--paper);
color:var(--ink);
font-family:-apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
-webkit-font-smoothing:antialiased;
}
.wrap{ max-width:1240px; margin:0 auto; padding:0 32px 64px; }
.page-head{ padding:32px 0 16px; }
.page-head h1{ font-size:22px; font-weight:600; margin:0 0 6px; }
.page-head .subtitle{ font-size:13px; color:var(--ink-2); margin:0; }
.controlbar{
position:sticky; top:0; z-index:10;
display:flex; align-items:center; gap:20px; row-gap:10px; flex-wrap:wrap;
padding:12px 0;
background:var(--paper-a85);
-webkit-backdrop-filter:blur(8px);
backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);
margin-bottom:28px;
}
.chipgroup{ display:flex; gap:6px; flex-wrap:wrap; }
.chip{
font:inherit; font-size:12px; padding:5px 11px; border-radius:999px;
border:1px solid var(--line); background:var(--chip); color:var(--ink);
cursor:pointer; line-height:1.3;
}
.chip:hover{ border-color:var(--accent); }
.chip.active{ background:var(--accent); border-color:var(--accent); color:var(--paper); }
.chip:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
.toggle{ display:flex; align-items:center; gap:6px; font-size:12px; color:var(--ink); cursor:pointer; user-select:none; }
.toggle input{ width:14px; height:14px; accent-color:var(--accent); }
.toggle input:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
.section-label{ font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-2); margin:0 0 10px; }
.axes{ margin-bottom:40px; }
.axes-list{ display:grid; grid-template-columns:1fr 1fr; gap:7px 32px; margin:0; }
.axis{ display:flex; gap:6px; font-size:13px; line-height:1.5; }
.axis dt{ margin:0; font-weight:600; color:var(--ink-2); flex:0 0 auto; }
.axis dd{ margin:0; color:var(--ink-2); }
@media (max-width:720px){ .axes-list{ grid-template-columns:1fr; } }
.family{ margin-bottom:40px; }
.family h2{ font-size:15px; font-weight:600; margin:0 0 4px; }
.family .recipe{ font-size:13px; color:var(--ink-2); margin:0 0 14px; max-width:820px; }
.grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(260px,1fr)); gap:14px; }
.card{ cursor:pointer; }
.frame{
aspect-ratio:16/10; border-radius:8px; border:1px solid var(--line);
overflow:hidden; background:var(--chip);
}
.frame svg, .lightbox-frame svg{ display:block; width:100%; height:100%; }
.caption{ display:flex; justify-content:space-between; align-items:baseline; margin-top:6px; font-size:11px; gap:8px; }
.caption .swatch-id{ font-family:ui-monospace,"SF Mono",Menlo,monospace; color:var(--ink); }
.caption .note{ color:var(--ink-2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.board-overlay{ display:none; }
body.show-overlay .board-overlay{ display:inline; }
.lightbox{
position:fixed; inset:0; z-index:100;
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px;
background:rgba(0,0,0,.55);
opacity:0; pointer-events:none;
transition:opacity .12s ease;
}
.lightbox.open{ opacity:1; pointer-events:auto; }
.lightbox-frame{
width:min(92vw, 1100px); aspect-ratio:16/10; border-radius:10px; overflow:hidden;
box-shadow:0 24px 60px rgba(0,0,0,.45);
background:var(--chip);
}
.lightbox-id{ font-family:ui-monospace,"SF Mono",Menlo,monospace; font-size:12px; color:#fff; }
@media (prefers-reduced-motion: reduce){
.lightbox{ transition:none; }
}
</style>
<div class="wrap">
<header class="page-head">
<h1>Board Background Swatches</h1>
<p class="subtitle">96 procedural candidates — base layer × filler layer. Toggle the board overlay to judge them under content.</p>
</header>
<div class="controlbar">
<div class="chipgroup" id="family-chips" role="group" aria-label="Family filter">
<button type="button" class="chip active" data-family="all" aria-pressed="true">All</button>
<button type="button" class="chip" data-family="whisper" aria-pressed="false">Whisper</button>
<button type="button" class="chip" data-family="confetti" aria-pressed="false">Confetti</button>
<button type="button" class="chip" data-family="aurora" aria-pressed="false">Aurora</button>
<button type="button" class="chip" data-family="contours" aria-pressed="false">Contours</button>
<button type="button" class="chip" data-family="drift" aria-pressed="false">Drift</button>
<button type="button" class="chip" data-family="bubbles" aria-pressed="false">Bubbles</button>
<button type="button" class="chip" data-family="terrazzo" aria-pressed="false">Terrazzo</button>
<button type="button" class="chip" data-family="graph" aria-pressed="false">Graph</button>
<button type="button" class="chip" data-family="waves" aria-pressed="false">Waves</button>
<button type="button" class="chip" data-family="dusk" aria-pressed="false">Dusk</button>
<button type="button" class="chip" data-family="starfield" aria-pressed="false">Starfield</button>
<button type="button" class="chip" data-family="slate" aria-pressed="false">Slate</button>
</div>
<div class="chipgroup" id="tone-chips" role="group" aria-label="Tone filter">
<button type="button" class="chip active" data-tone="all" aria-pressed="true">All</button>
<button type="button" class="chip" data-tone="light" aria-pressed="false">Light</button>
<button type="button" class="chip" data-tone="dark" aria-pressed="false">Dark</button>
</div>
<label class="toggle">
<input type="checkbox" id="overlay-toggle">
<span>Board overlay</span>
</label>
</div>
<section class="axes">
<p class="section-label">Axes</p>
<dl class="axes-list">
<div class="axis"><dt>Base</dt><dd>solid · linear 2/3-stop (angle) · radial/corner glow · soft-blob mesh</dd></div>
<div class="axis"><dt>Hue strategy</dt><dd>mono · analogous · complementary accent · multicolor</dd></div>
<div class="axis"><dt>HSB family</dt><dd>pastel · muted · deep/dark</dd></div>
<div class="axis"><dt>Filler shape</dt><dd>dots · rings · blobs/chips · capsules · triangles · plus-signs · contour lines · sine bands · grid</dd></div>
<div class="axis"><dt>Density</dt><dd>sparse → dense</dd></div>
<div class="axis"><dt>Size</dt><dd>uniform vs power-law</dd></div>
<div class="axis"><dt>Placement</dt><dd>uniform scatter · diagonal band · corner-weighted · grid-jitter</dd></div>
<div class="axis"><dt>Filler color</dt><dd>same-hue tint · accent hue · multicolor · alpha-only</dd></div>
<div class="axis"><dt>Opacity</dt><dd>whisper 410% → visible 1525%</dd></div>
<div class="axis"><dt>Depth</dt><dd>crisp vs gradient-soft, layered sizes</dd></div>
</dl>
</section>
<section class="family" data-family="whisper" data-tone="light">
<h2>Whisper</h2>
<p class="recipe">A vertical pastel gradient ground carries roughly sixty faint dots at 610% opacity — the quietest family in the set.</p>
<div class="grid" id="grid-whisper"></div>
</section>
<section class="family" data-family="confetti" data-tone="light">
<h2>Confetti</h2>
<p class="recipe">A near-neutral ground is scattered with about seventy tiny circles, rotated squares and triangles across three related hues.</p>
<div class="grid" id="grid-confetti"></div>
</section>
<section class="family" data-family="aurora" data-tone="light">
<h2>Aurora</h2>
<p class="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>
<div class="grid" id="grid-aurora"></div>
</section>
<section class="family" data-family="contours" data-tone="light">
<h2>Contours</h2>
<p class="recipe">A flat ground is crossed by fourteen thin wavy horizontal lines built from summed sine harmonics, evoking topographic contours.</p>
<div class="grid" id="grid-contours"></div>
</section>
<section class="family" data-family="drift" data-tone="light">
<h2>Drift</h2>
<p class="recipe">A diagonal two-tone gradient ground carries rotated capsule shapes rejection-sampled into a diagonal band across the canvas.</p>
<div class="grid" id="grid-drift"></div>
</section>
<section class="family" data-family="bubbles" data-tone="light">
<h2>Bubbles</h2>
<p class="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>
<div class="grid" id="grid-bubbles"></div>
</section>
<section class="family" data-family="terrazzo" data-tone="light">
<h2>Terrazzo</h2>
<p class="recipe">A near-neutral ground is covered in about fifty-five small irregular polygon chips across four related colors, like stone terrazzo.</p>
<div class="grid" id="grid-terrazzo"></div>
</section>
<section class="family" data-family="graph" data-tone="light">
<h2>Graph</h2>
<p class="recipe">A pale ground is ruled into a 24px grid, with a few plus-mark intersections and small accent dots — graph paper.</p>
<div class="grid" id="grid-graph"></div>
</section>
<section class="family" data-family="waves" data-tone="light">
<h2>Waves</h2>
<p class="recipe">A horizontal gradient ground is layered with six translucent sine-wave bands, phase-shifted and stacked toward the bottom third like dunes.</p>
<div class="grid" id="grid-waves"></div>
</section>
<section class="family" data-family="dusk" data-tone="dark">
<h2>Dusk</h2>
<p class="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>
<div class="grid" id="grid-dusk"></div>
</section>
<section class="family" data-family="starfield" data-tone="dark">
<h2>Starfield</h2>
<p class="recipe">A dark radial ground is scattered with about ninety tiny stars, a handful haloed and brighter, plus two faint nebula blobs.</p>
<div class="grid" id="grid-starfield"></div>
</section>
<section class="family" data-family="slate" data-tone="dark">
<h2>Slate</h2>
<p class="recipe">A flat dark ground carries the same wavy contour lines as Contours, with small accent dots resting on the lines — dark topographic.</p>
<div class="grid" id="grid-slate"></div>
</section>
</div>
<div class="lightbox" id="lightbox">
<div class="lightbox-frame" id="lightbox-frame"></div>
<div class="lightbox-id" id="lightbox-id"></div>
</div>
<script>
(function(){
"use strict";
// ---------- seeded PRNG ----------
function xmur3(str){
let h = 1779033703 ^ str.length;
for(let i=0;i<str.length;i++){
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return function(){
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
h ^= h >>> 16;
return h >>> 0;
};
}
function mulberry32(a){
return function(){
let t = (a += 0x6D2B79F5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededRng(id){
const seed = xmur3(id)();
return mulberry32(seed);
}
// ---------- small helpers ----------
function mod360(h){ return ((h % 360) + 360) % 360; }
function hsl(h,s,l){ return "hsl(" + mod360(h) + "," + s + "%," + l + "%)"; }
function rnd(rng,min,max){ return min + rng() * (max - min); }
function rndInt(rng,min,max){ return Math.floor(rnd(rng,min,max+1)); }
function pick(rng,arr){ return arr[Math.floor(rng()*arr.length)]; }
function pad2(n){ return String(n).padStart(2,"0"); }
function f1(n){ return n.toFixed(1); }
function linGrad(id,x1,y1,x2,y2,stops){
let s = "";
for(let i=0;i<stops.length;i++){
s += '<stop offset="' + stops[i][0] + '" stop-color="' + stops[i][1] + '"/>';
}
return '<linearGradient id="' + id + '" x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '">' + s + '</linearGradient>';
}
function radGrad(id,cx,cy,r,stops){
let s = "";
for(let i=0;i<stops.length;i++){
const st = stops[i];
const op = st.length > 2 ? st[2] : 1;
s += '<stop offset="' + st[0] + '" stop-color="' + st[1] + '" stop-opacity="' + op + '"/>';
}
return '<radialGradient id="' + id + '" cx="' + cx + '" cy="' + cy + '" r="' + r + '">' + s + '</radialGradient>';
}
function triPoints(cx,cy,size,rotDeg){
const r = size * 0.7;
const rot = rotDeg * Math.PI / 180;
const pts = [];
for(let k=0;k<3;k++){
const a = rot + k*(Math.PI*2/3) - Math.PI/2;
pts.push(f1(cx + r*Math.cos(a)) + "," + f1(cy + r*Math.sin(a)));
}
return pts.join(" ");
}
// shared wavy-contour-line builder, used by "contours" and "slate"
function contourLines(rng,count,spacing,startY,stroke,opacity,widthAttr){
let out = "";
const allPoints = [];
for(let i=0;i<count;i++){
const baseY = startY + i*spacing;
const harmonics = rndInt(rng,2,3);
const comps = [];
let wsum = 0;
for(let k=0;k<harmonics;k++){
const c = { freq: rnd(rng,1,3), phase: rnd(rng,0,Math.PI*2), weight: rnd(rng,0.3,1) };
comps.push(c);
wsum += c.weight;
}
const amp = rnd(rng,6,14);
const pts = [];
for(let x=0;x<=480;x+=8){
let s = 0;
for(let k=0;k<comps.length;k++){
const c = comps[k];
s += c.weight * Math.sin(c.freq*(x/480)*Math.PI*2 + c.phase);
}
const y = baseY + amp*(s/wsum);
pts.push([x,y]);
}
allPoints.push(pts);
let d = "";
for(let p=0;p<pts.length;p++){
d += (p===0 ? "M" : "L") + f1(pts[p][0]) + "," + f1(pts[p][1]) + " ";
}
out += '<path d="' + d + '" stroke="' + stroke + '" stroke-width="' + widthAttr + '" fill="none" opacity="' + opacity + '"/>';
}
return { svg: out, lines: allPoints };
}
// ---------- board overlay (shared across all families) ----------
function buildOverlay(rng, tone){
const laneFill = tone === "dark" ? "rgba(20,20,24,0.40)" : "rgba(255,255,255,0.38)";
const cardFill = tone === "dark" ? "#26262B" : "#FFFFFF";
const cardStroke = tone === "dark" ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.08)";
const titleFill = tone === "dark" ? "rgba(22,22,26,0.55)" : "rgba(255,255,255,0.55)";
let g = '<g class="board-overlay">';
g += '<rect x="0" y="0" width="480" height="34" fill="' + titleFill + '"/>';
const laneX = [16,168,320];
for(let li=0; li<laneX.length; li++){
const lx = laneX[li];
g += '<rect x="' + lx + '" y="40" width="144" height="250" rx="8" fill="' + laneFill + '"/>';
let y = 48;
const count = rndInt(rng,2,4);
for(let c=0;c<count;c++){
const h = rnd(rng,26,44);
if(y + h > 282) break;
g += '<rect x="' + (lx+8) + '" y="' + f1(y) + '" width="128" height="' + f1(h) + '" rx="5" fill="' + cardFill + '" stroke="' + cardStroke + '"/>';
y += h + 8;
}
}
g += "</g>";
return g;
}
// ---------- 12 families ----------
function famWhisper(rng,H,gid){
const baseId = gid + "-base";
let s = "<defs>" + linGrad(baseId,"0","0","0","1",[["0%",hsl(H,45,94)],["100%",hsl(H,40,88)]]) + "</defs>";
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
let filler = "";
for(let i=0;i<60;i++){
const cx = rnd(rng,0,480), cy = rnd(rng,0,300), r = rnd(rng,2,5), op = rnd(rng,0.06,0.10);
filler += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,50,55) + '" opacity="' + op.toFixed(2) + '"/>';
}
return s + filler;
}
function famConfetti(rng,H,gid){
let s = '<rect width="480" height="300" fill="' + hsl(H,12,95) + '"/>';
const hues = [H, H+120, H+240];
let filler = "";
for(let i=0;i<70;i++){
const type = pick(rng,["circle","square","triangle"]);
const hue = pick(rng,hues);
const color = hsl(hue,55,55);
const size = rnd(rng,3,8);
const x = rnd(rng,0,480), y = rnd(rng,0,300);
const op = rnd(rng,0.10,0.16).toFixed(2);
if(type === "circle"){
filler += '<circle cx="' + f1(x) + '" cy="' + f1(y) + '" r="' + f1(size/2) + '" fill="' + color + '" opacity="' + op + '"/>';
} else if(type === "square"){
const rot = rnd(rng,0,360).toFixed(1);
filler += '<rect x="' + f1(x-size/2) + '" y="' + f1(y-size/2) + '" width="' + f1(size) + '" height="' + f1(size) + '" fill="' + color + '" opacity="' + op + '" transform="rotate(' + rot + ' ' + f1(x) + ' ' + f1(y) + ')"/>';
} else {
const rot = rnd(rng,0,360);
filler += '<polygon points="' + triPoints(x,y,size,rot) + '" fill="' + color + '" opacity="' + op + '"/>';
}
}
return s + filler;
}
function famAurora(rng,H,gid){
const corners = [[0,0],[480,0],[0,300],[480,300]];
const corner = pick(rng,corners);
const n = rndInt(rng,4,6);
let defs = "", circles = "";
for(let i=0;i<n;i++){
const r = rnd(rng,90,180);
const rx = rnd(rng,0,480), ry = rnd(rng,0,300);
const t = rnd(rng,0.3,0.7);
const cx = rx + (corner[0]-rx)*t;
const cy = ry + (corner[1]-ry)*t;
const offset = pick(rng,[-25,25]);
const gidL = gid + "-aur-" + i;
defs += radGrad(gidL,"50%","50%","50%",[["0%",hsl(H+offset,55,80),0.55],["100%",hsl(H+offset,55,80),0]]);
circles += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="url(#' + gidL + ')"/>';
}
let s = "<defs>" + defs + "</defs>";
s += '<rect width="480" height="300" fill="' + hsl(H,35,93) + '"/>';
return s + circles;
}
function famContours(rng,H,gid){
let s = '<rect width="480" height="300" fill="' + hsl(H,30,92) + '"/>';
const res = contourLines(rng,14,22,6,hsl(H,45,60),0.14,1.2);
return s + res.svg;
}
function famDrift(rng,H,gid){
const baseId = gid + "-base";
let s = "<defs>" + linGrad(baseId,"0","0","1","1",[["0%",hsl(H,40,93)],["100%",hsl(H+30,40,89)]]) + "</defs>";
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
const a = 300, b = -480;
const denom = Math.sqrt(a*a + b*b);
let count = 0, tries = 0, filler = "";
while(count < 40 && tries < 2000){
tries++;
const x = rnd(rng,0,480), y = rnd(rng,0,300);
const dist = Math.abs(a*x + b*y) / denom;
if(dist < 90){
const len = rnd(rng,20,70), thick = rnd(rng,4,7);
const op = rnd(rng,0.08,0.14).toFixed(2);
filler += '<rect x="' + f1(-len/2) + '" y="' + f1(-thick/2) + '" width="' + f1(len) + '" height="' + f1(thick) + '" rx="' + f1(thick/2) + '" fill="' + hsl(H,45,60) + '" opacity="' + op + '" transform="translate(' + f1(x) + ' ' + f1(y) + ') rotate(-32)"/>';
count++;
}
}
return s + filler;
}
function famBubbles(rng,H,gid){
const baseId = gid + "-base";
let s = "<defs>" + radGrad(baseId,"0%","0%","100%",[["0%",hsl(H,45,95)],["100%",hsl(H,40,88)]]) + "</defs>";
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
const groups = [[5,40,80,0.05],[15,12,30,0.08],[40,2,8,0.12]];
let filler = "";
for(let gi=0; gi<groups.length; gi++){
const n = groups[gi][0], rmin = groups[gi][1], rmax = groups[gi][2], op = groups[gi][3];
for(let i=0;i<n;i++){
const cx = rnd(rng,0,480), cy = rnd(rng,0,300), r = rnd(rng,rmin,rmax);
const ring = rng() < 0.4;
if(ring){
filler += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="none" stroke="' + hsl(H,50,55) + '" stroke-width="1.5" opacity="' + op + '"/>';
} else {
filler += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,50,55) + '" opacity="' + op + '"/>';
}
}
}
return s + filler;
}
function famTerrazzo(rng,H,gid){
let s = '<rect width="480" height="300" fill="' + hsl(H,10,94) + '"/>';
const colors = [hsl(H,45,60), hsl(H+40,40,55), hsl(H-40,40,50), hsl(40,8,55)];
let filler = "";
for(let i=0;i<55;i++){
const cx = rnd(rng,0,480), cy = rnd(rng,0,300);
const sides = rndInt(rng,5,7);
const rot = rnd(rng,0,Math.PI*2);
const color = pick(rng,colors);
const pts = [];
for(let k=0;k<sides;k++){
const ang = rot + k*(Math.PI*2/sides);
const r = rnd(rng,4,14);
pts.push(f1(cx + r*Math.cos(ang)) + "," + f1(cy + r*Math.sin(ang)));
}
filler += '<polygon points="' + pts.join(" ") + '" fill="' + color + '" opacity="0.18"/>';
}
return s + filler;
}
function famGraph(rng,H,gid){
let s = '<rect width="480" height="300" fill="' + hsl(H,25,96) + '"/>';
const pitch = 24;
const cols = Math.floor(480/pitch);
const rows = Math.floor(300/pitch);
let grid = "";
for(let c=0;c<=cols;c++){
const x = c*pitch;
grid += '<line x1="' + x + '" y1="0" x2="' + x + '" y2="300" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.10"/>';
}
for(let r=0;r<=rows;r++){
const y = r*pitch;
grid += '<line x1="0" y1="' + y + '" x2="480" y2="' + y + '" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.10"/>';
}
let plus = "";
for(let i=0;i<12;i++){
const gx = rndInt(rng,0,cols)*pitch, gy = rndInt(rng,0,rows)*pitch;
plus += '<line x1="' + (gx-4) + '" y1="' + gy + '" x2="' + (gx+4) + '" y2="' + gy + '" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.25"/>';
plus += '<line x1="' + gx + '" y1="' + (gy-4) + '" x2="' + gx + '" y2="' + (gy+4) + '" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.25"/>';
}
let dots = "";
const nd = rndInt(rng,3,4);
for(let i=0;i<nd;i++){
const gx = rndInt(rng,0,cols)*pitch, gy = rndInt(rng,0,rows)*pitch;
dots += '<circle cx="' + gx + '" cy="' + gy + '" r="3" fill="' + hsl(H,55,55) + '" opacity="0.35"/>';
}
return s + grid + plus + dots;
}
function famWaves(rng,H,gid){
const baseId = gid + "-base";
let s = "<defs>" + linGrad(baseId,"0","0","1","0",[["0%",hsl(H,38,94)],["100%",hsl(H,34,89)]]) + "</defs>";
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
let filler = "";
for(let i=0;i<6;i++){
const baseline = 190 + i*18;
const amp = rnd(rng,10,24);
const freq = rnd(rng,1,2);
const phase = rnd(rng,0,Math.PI*2);
let d = "M0,300 ";
for(let x=0;x<=480;x+=12){
const y = baseline + amp*Math.sin(freq*(x/480)*Math.PI*2 + phase);
d += "L" + x + "," + f1(y) + " ";
}
d += "L480,300 Z";
filler += '<path d="' + d + '" fill="' + hsl(H,40,80) + '" opacity="0.10"/>';
}
return s + filler;
}
function famDusk(rng,H,gid){
const baseId = gid + "-base";
let defs = linGrad(baseId,"0","0","0","1",[["0%",hsl(H,35,16)],["100%",hsl(H,45,9)]]);
const n = rndInt(rng,4,5);
let blobs = "";
for(let i=0;i<n;i++){
const r = rnd(rng,90,180);
const cx = rnd(rng,0,480), cy = rnd(rng,0,220);
const offset = pick(rng,[-30,30]);
const gidL = gid + "-dusk-" + i;
defs += radGrad(gidL,"50%","50%","50%",[["0%",hsl(H+offset,55,35),0.5],["100%",hsl(H+offset,55,35),0]]);
blobs += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="url(#' + gidL + ')"/>';
}
const horizonId = gid + "-horizon";
defs += radGrad(horizonId,"50%","50%","50%",[["0%",hsl(H,50,30),0.45],["100%",hsl(H,50,30),0]]);
const horizon = '<ellipse cx="240" cy="270" rx="320" ry="70" fill="url(#' + horizonId + ')"/>';
return "<defs>" + defs + "</defs>" + '<rect width="480" height="300" fill="url(#' + baseId + ')"/>' + blobs + horizon;
}
function famStarfield(rng,H,gid){
const baseId = gid + "-base";
let defs = radGrad(baseId,"50%","50%","75%",[["0%",hsl(H,40,14)],["100%",hsl(H,45,7)]]);
let nebula = "";
for(let i=0;i<2;i++){
const cx = rnd(rng,0,480), cy = rnd(rng,0,300), r = rnd(rng,120,200);
const nId = gid + "-neb-" + i;
defs += radGrad(nId,"50%","50%","50%",[["0%",hsl(H+40,50,30),0.3],["100%",hsl(H+40,50,30),0]]);
nebula += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="url(#' + nId + ')"/>';
}
const brightIdx = new Set();
while(brightIdx.size < 8){ brightIdx.add(rndInt(rng,0,89)); }
let stars = "";
for(let i=0;i<90;i++){
const cx = rnd(rng,0,480), cy = rnd(rng,0,300);
if(brightIdx.has(i)){
const haloId = gid + "-star-" + i;
defs += radGrad(haloId,"50%","50%","50%",[["0%",hsl(H,30,88),0.9],["100%",hsl(H,30,88),0]]);
const r = rnd(rng,0.6,1.8);
stars += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="6" fill="url(#' + haloId + ')"/>';
stars += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,30,88) + '" opacity="0.9"/>';
} else {
const r = rnd(rng,0.6,1.8);
const op = rnd(rng,0.25,0.5).toFixed(2);
stars += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,30,88) + '" opacity="' + op + '"/>';
}
}
return "<defs>" + defs + "</defs>" + '<rect width="480" height="300" fill="url(#' + baseId + ')"/>' + nebula + stars;
}
function famSlate(rng,H,gid){
let s = '<rect width="480" height="300" fill="' + hsl(H,18,13) + '"/>';
const res = contourLines(rng,14,22,6,hsl(H,35,55),0.12,1.2);
s += res.svg;
let dots = "";
for(let i=0;i<10;i++){
const li = rndInt(rng,0,res.lines.length-1);
const line = res.lines[li];
const pi = rndInt(rng,0,line.length-1);
const pt = line[pi];
const r = rnd(rng,2,3);
dots += '<circle cx="' + f1(pt[0]) + '" cy="' + f1(pt[1]) + '" r="' + f1(r) + '" fill="' + hsl(H,60,60) + '" opacity="0.5"/>';
}
return s + dots;
}
// ---------- registry ----------
const HUES = [
{ name:"clay", h:8 },
{ name:"amber", h:38 },
{ name:"olive", h:80 },
{ name:"forest", h:140 },
{ name:"teal", h:175 },
{ name:"sky", h:215 },
{ name:"iris", h:262 },
{ name:"rose", h:335 }
];
const FAMILIES = [
{ id:"whisper", tone:"light", gen:famWhisper },
{ id:"confetti", tone:"light", gen:famConfetti },
{ id:"aurora", tone:"light", gen:famAurora },
{ id:"contours", tone:"light", gen:famContours },
{ id:"drift", tone:"light", gen:famDrift },
{ id:"bubbles", tone:"light", gen:famBubbles },
{ id:"terrazzo", tone:"light", gen:famTerrazzo },
{ id:"graph", tone:"light", gen:famGraph },
{ id:"waves", tone:"light", gen:famWaves },
{ id:"dusk", tone:"dark", gen:famDusk },
{ id:"starfield", tone:"dark", gen:famStarfield },
{ id:"slate", tone:"dark", gen:famSlate }
];
// ---------- render ----------
let openState = null;
const lightbox = document.getElementById("lightbox");
const lightboxFrame = document.getElementById("lightbox-frame");
const lightboxId = document.getElementById("lightbox-id");
function openLightbox(svgEl, id){
openState = { svgEl:svgEl, parent:svgEl.parentNode, next:svgEl.nextSibling };
lightboxFrame.appendChild(svgEl);
lightboxId.textContent = id;
lightbox.classList.add("open");
}
function closeLightbox(){
if(!openState) return;
if(openState.next){
openState.parent.insertBefore(openState.svgEl, openState.next);
} else {
openState.parent.appendChild(openState.svgEl);
}
openState = null;
lightbox.classList.remove("open");
}
lightbox.addEventListener("click", function(e){
if(e.target === lightbox) closeLightbox();
});
document.addEventListener("keydown", function(e){
if(e.key === "Escape") closeLightbox();
});
let renderedCount = 0;
function renderAll(){
for(let fi=0; fi<FAMILIES.length; fi++){
const fam = FAMILIES[fi];
const grid = document.getElementById("grid-" + fam.id);
for(let hi=0; hi<HUES.length; hi++){
const hue = HUES[hi];
const id = fam.id + "-" + pad2(hi+1);
const rng = seededRng(id);
const inner = fam.gen(rng, hue.h, id);
const overlay = buildOverlay(rng, fam.tone);
const svgMarkup = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 300" data-id="' + id + '">' + inner + overlay + "</svg>";
const card = document.createElement("div");
card.className = "card";
card.dataset.family = fam.id;
card.dataset.tone = fam.tone;
const frame = document.createElement("div");
frame.className = "frame";
frame.innerHTML = svgMarkup;
const svgEl = frame.firstElementChild;
const caption = document.createElement("div");
caption.className = "caption";
const idSpan = document.createElement("span");
idSpan.className = "swatch-id";
idSpan.textContent = id;
const noteSpan = document.createElement("span");
noteSpan.className = "note";
noteSpan.textContent = hue.name;
caption.appendChild(idSpan);
caption.appendChild(noteSpan);
card.appendChild(frame);
card.appendChild(caption);
card.addEventListener("click", function(){ openLightbox(svgEl, id); });
grid.appendChild(card);
renderedCount++;
}
}
}
// ---------- filters ----------
let activeFamily = "all", activeTone = "all";
const familyChips = document.querySelectorAll("#family-chips .chip");
const toneChips = document.querySelectorAll("#tone-chips .chip");
function applyFilters(){
const sections = document.querySelectorAll(".family");
for(let i=0;i<sections.length;i++){
const sec = sections[i];
const fam = sec.dataset.family, tone = sec.dataset.tone;
const visible = (activeFamily === "all" || activeFamily === fam) && (activeTone === "all" || activeTone === tone);
sec.style.display = visible ? "" : "none";
}
}
familyChips.forEach(function(chip){
chip.addEventListener("click", function(){
familyChips.forEach(function(c){ c.classList.remove("active"); c.setAttribute("aria-pressed","false"); });
chip.classList.add("active");
chip.setAttribute("aria-pressed","true");
activeFamily = chip.dataset.family;
applyFilters();
});
});
toneChips.forEach(function(chip){
chip.addEventListener("click", function(){
toneChips.forEach(function(c){ c.classList.remove("active"); c.setAttribute("aria-pressed","false"); });
chip.classList.add("active");
chip.setAttribute("aria-pressed","true");
activeTone = chip.dataset.tone;
applyFilters();
});
});
document.getElementById("overlay-toggle").addEventListener("change", function(e){
document.body.classList.toggle("show-overlay", e.target.checked);
});
renderAll();
})();
</script>
+72
View File
@@ -0,0 +1,72 @@
# Board background image set — exploration notes
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.
## The model
Every background = **base layer × filler layer**.
Axes swept or reserved:
- **Base**: solid · linear gradient (2/3-stop, angle) · radial/corner glow · soft-blob mesh
- **Hue strategy**: monochrome · analogous · complementary accent · multicolor
- **HSB family**: pastel · muted/dusty · deep-dark
- **Filler shapes**: dots · rings · blob chips · capsules · triangles · plus-signs · contour lines · sine bands · grid
- **Density** sparse→dense; **size distribution** uniform vs power-law; **placement** uniform scatter · diagonal band · corner-weighted · grid-jitter
- **Filler color**: same-hue tint · accent hue · multicolor · alpha-only; **opacity** whisper 410% → visible 1525%; **depth** crisp vs gradient-soft, layered sizes
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.
## Sweep 1 — the gallery
- **Artifact (preview)**: https://claude.ai/code/artifact/afa8f1d8-ded0-4d7b-9785-f4b7eb4f8008
- **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.
- **96 swatches** = 12 families × 8 hues. Hue wheel (shared by all families): clay 8° · amber 38° · olive 80° · forest 140° · teal 175° · sky 215° · iris 262° · rose 335°. IDs are `family-NN` (`aurora-05` = aurora × teal).
- **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).
Families (19 light, 1012 dark):
| Family | Recipe |
|---|---|
| whisper | pastel vertical gradient + faint scattered dots — the quietest |
| confetti | neutral ground + small multicolor shapes (H, H+120, H+240) |
| aurora | 46 large soft radial-gradient blobs, mesh look |
| contours | topographic wavy polylines, mono |
| drift | diagonal band of 32°-tilted capsules over diagonal gradient |
| bubbles | power-law circles/rings over corner glow |
| terrazzo | irregular 57-gon stone chips, four colors |
| graph | 24px graph-paper grid + plus-signs + accent dots |
| 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%.
## Faceted gallery — user-designed originals (2026-08-07)
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 → BowyerWatson 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 8590, dark 1721, 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").
2. Sweep 2: deepen winners, add the reserved axes (grain, two-layer depth, clusters, outlines).
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.
+94
View File
@@ -0,0 +1,94 @@
# Drag/Drop Smoothness Analysis
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 (~110ms) + **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 ~1050ms instead of ~250400ms. 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 ~3060ms 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 ~250400ms and reachable by a fast user; after P1 it is ~3060ms 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).
### M3 — Strip-side caches (allocation hygiene, lowest yield)
`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 ~250400ms `echo` to ~1050ms; 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.
+21 -9
View File
@@ -23,12 +23,18 @@ While a drag is in flight, the proposal (an insertion index) is computed geometr
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 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 excludes the dragged run whatever the effective operation is.** A within-board ⌥-drag copies, and the originals really do stay — but ⌥ can be pressed and released mid-drag, and a layout that re-admitted the originals on every modifier flip would flap the whole board under the cursor. The dragged items are lifted out at pickup and stay out until release; the copy's originals reappear when the write lands. This is why the drop index means the same thing for `moveCards` and `copyCards` (see **The drop commits** below). **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: 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. - **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. - **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 lane strip's resting layout is arithmetic
@@ -69,7 +75,7 @@ Edge case: if a dead region is hovered with **no valid prior proposal** — a fr
## The card masonry (2D) ## The card masonry (2D)
A lane lays its cards out with `MasonryLayout`: card `i` goes to interior column `i % columnCount`, and 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. 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. 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.
@@ -78,10 +84,10 @@ The resting grid is **re-run, not measured**: `MasonryPlacement.frames(heights:)
Cursor → proposal, in three steps (`DropSlotMath.cardSlot`): 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. 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 logical indices `c, c + C, c + 2C, …`; 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. 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 `r · C + c`, clamped to the card count. The clamp is the only place the arithmetic bends: every column's tail slot maps at or past the end, so "below the last card of any column" is the end slot — appending — which is the honest reading, since a round-robin masonry has no landing spot below one column that is not simply the end. 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. A consequence worth stating out loud: because assignment to columns is round-robin, inserting at index `k` shifts every later card one position and therefore *across* columns. That reflow is the point — `MasonryLayout` is a `Layout` over a single `ForEach` precisely so those moves animate as positional slides rather than as remove/insert blinks. 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.
Everything else carries over unchanged: resting-layout reconstruction, boundary ties, own-slot no-op, uncapped terminal slots. Everything else carries over unchanged: resting-layout reconstruction, boundary ties, own-slot no-op, uncapped terminal slots.
@@ -106,9 +112,11 @@ Two carve-outs:
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. 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 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 with the dragged run removed** — the resting layout's own convention, so the number the geometry produced is the number the writer consumes, unrewritten. 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 | | Gesture | Store method | Writer |
| --- | --- | --- | | --- | --- | --- |
@@ -123,7 +131,7 @@ The commit is the store's, and it is one `performWrite` bracket per gesture what
Three properties of that table are load-bearing: 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). - **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 a rank chosen in the gap the lifted originals vacated would collide with them. 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. - **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. - **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. 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.
@@ -176,12 +184,16 @@ The pointer may also sit outside the visible area and still drive it: generously
Three constraints shape the driver, which is the session's half of the work: 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 ticking task plus `NSEvent.mouseLocation` needs no events at all. - **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. - **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. - **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. 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) ## 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. 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.
@@ -1,7 +0,0 @@
---
schema: 1
order:
title: Lane With Null Order
---
The hand-editor started the 'order' key and never gave it a value — reads
as missing, not malformed.
@@ -1,4 +0,0 @@
---
schema: 1
title: Board Is Fine
---
@@ -0,0 +1,8 @@
---
schema: 1
kind: card
order: 1024
title: Intact Card
---
Nothing wrong with this one. It is here so the broken sibling below is a *card* defect rather
than the only thing in its lane.
@@ -0,0 +1,8 @@
---
schema: 2
kind: card
order: 2048
title: From A Newer Lanework
---
Defect #2: a schema newer than this app, below the root — the one `schema` rule the optional-key
ruling left alone. Unfixable, so the surface offers only Skip.
@@ -1,5 +1,6 @@
--- ---
schema: 1 schema: 1
kind: lane
order: 1024 order: 1024
title: Lane Is Fine title: Intact Lane
--- ---
@@ -0,0 +1,8 @@
---
schema: 3
kind: card
order: 1024
title: Hidden Behind The Broken Lane
---
Broken too, and deliberately *not* in the aggregate: its lane was skipped before this folder was
ever listed. Fix the lane and the next walk reports this one.
@@ -0,0 +1,10 @@
---
schema: 1
kind: lane
order: 2048
title: A Lane That Will Not Parse
labels: [red, green
---
Defect #3: unparseable YAML — an unterminated flow sequence, the same shape
`unparseable-yaml.kanban` uses one level up. The card below is never enumerated: a broken lane
takes its subtree with it, and repair-then-re-check is what reveals what it was hiding.
@@ -0,0 +1,7 @@
---
schema: 1
kind: card
order: 1024
title: Last Card
---
The walk reaches here, which is the point: a defect in an earlier lane does not end the walk.
@@ -0,0 +1,6 @@
---
schema: 1
kind: lane
order: 3072
title: Also Intact
---
@@ -0,0 +1,8 @@
---
title: Many Defects
created: 2026-07-31T09:00:00Z
---
The root's own `schema` is missing — the this-really-is-a-board gate, and defect #1.
The walk does not stop here: lanes are enumerated by folder shape, so everything below is still
read and reported in the same aggregate.
@@ -1,5 +0,0 @@
---
schema: 1
title: Card Missing Order
---
No 'order' key — required on every card.
@@ -1,4 +0,0 @@
---
schema: 1
title: Board Is Fine
---
@@ -1,5 +0,0 @@
---
schema: 1
title: Lane Missing Order
---
No 'order' key — required on every lane.
@@ -1,4 +0,0 @@
---
schema: 1
title: Board Is Fine
---
@@ -1,7 +0,0 @@
---
schema: 1
order: banana
title: Card With Non-Numeric Order
---
'order' is present but not a number — malformed, not missing; the same
loud rejection as a missing value.
@@ -1,4 +0,0 @@
---
schema: 1
title: Board Is Fine
---
@@ -0,0 +1,7 @@
---
schema: 1
kind: card
order: 1024
title: Intact Card
---
Stays on the board whatever is skipped — a skip omits the item it names, never its siblings.
@@ -0,0 +1,8 @@
---
schema: 2
kind: card
order: 2048
title: From A Newer Lanework
---
Unfixable, so Skip is the only choice the surface offers for it. Skipped, the card leaves the
board and this file stays on disk untouched.
@@ -1,5 +1,6 @@
--- ---
schema: 1 schema: 1
kind: lane
order: 1024 order: 1024
title: Lane Is Fine title: Intact Lane
--- ---
@@ -0,0 +1,8 @@
---
schema: 1
kind: card
order: 1024
title: Perfectly Fine, Behind A Broken Lane
---
Nothing at all is wrong with this card. It is never in the snapshot regardless: its lane is either
a defect or a skip, and both take the subtree.
@@ -0,0 +1,9 @@
---
schema: 1
kind: lane
order: 2048
title: A Lane That Will Not Parse
labels: [red, green
---
Unparseable YAML. Skipping it takes the whole lane out of the board — the intact card below
included, which is the honest cost of the tolerance and why the notice names what left.
@@ -0,0 +1,7 @@
---
schema: 1
kind: card
order: 1024
title: Last Card
---
Proof the walk kept going past the broken lane.
@@ -0,0 +1,6 @@
---
schema: 1
kind: lane
order: 3072
title: Also Intact
---
@@ -0,0 +1,11 @@
---
schema: 1
title: Skippable Defects
created: 2026-07-31T09:00:00Z
---
Every defect on this board is **below the root**, which is what makes it the skip channel's golden
case: the root is never skippable, so a board whose only defects are skippable has to have an
intact root.
Skip both and it opens — without the broken lane, without the newer-schema card, and without the
perfectly good card that lives under the broken lane.
+8 -7
View File
@@ -19,21 +19,22 @@ Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never
| `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. | | `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. | | `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. | | `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. |
| `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `background: 12345`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `background: {x: 1}`, `width: 1.5`), plus a `deleted` with an unusable timestamp that still tombstones. | | `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. | | `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. | | `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 ## Malformed/ — one board per fail-fast case
Each board is minimal: one broken thing. 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 | | Board | Case |
|---|---| |---|---|
| `unparseable-yaml.kanban` | An unterminated flow sequence in the board's frontmatter. | | `unparseable-yaml.kanban` | An unterminated flow sequence in the board's frontmatter. |
| `missing-schema.kanban` | Board root `index.md` has no `schema` key. | | `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`). |
| `schema-newer-than-app.kanban` | Board root `schema: 2`, newer than `BoardLoader.supportedSchema`. | | `schema-newer-than-app.kanban` | Board root `schema: 2`, newer than `BoardLoader.supportedSchema`. |
| `missing-order-lane.kanban` | A UUID-shaped lane `index.md` with no `order` key. |
| `missing-order-card.kanban` | A UUID-shaped card `index.md` with no `order` key. |
| `explicit-null-order.kanban` | A lane's `order:` key present with no value — reads as missing, not malformed. |
| `non-numeric-order.kanban` | A card's `order: banana` — present but not a number. |
| `board-root-missing-index.kanban` | The board root folder itself has no `index.md`. | | `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.
@@ -3,5 +3,6 @@ schema: 1
order: 4096 order: 4096
background: {x: 1} background: {x: 1}
--- ---
A mapping has no sensible string reading — malformed, falls back to no A mapping is a legal `background` — it carries `color` and `image` subkeys
color. — but this one names neither, so there is no color to read and the card
falls back to none.
@@ -3,3 +3,5 @@ schema: 1
order: 5120 order: 5120
background: 12345 background: 12345
--- ---
`background` is a mapping and only a mapping, so a bare scalar has no
reading at all — malformed, no color, bytes preserved.
@@ -0,0 +1,8 @@
---
schema: 1
kind: card
order: 1024
title: Ranked Card
---
The only card in this lane that says where it goes. Every sibling below reads as
append-at-end, in folder-name order.
@@ -0,0 +1,6 @@
---
title: Minimum Agent Card
---
The whole legal minimum: one `mkdir` and one write, no `order` and no `schema`
(08-agent-integration.md — "filing a card must need nothing but the schema").
Reads as schema 1, at the bottom of the lane.
@@ -0,0 +1,8 @@
---
schema: 1
kind: card
order:
title: Null Order Card
---
The hand-editor started the key and never gave it a value — reads as missing,
which below the root is the append-at-end reading.
@@ -0,0 +1,8 @@
---
schema: 1
kind: card
order: banana
title: Non-Numeric Order Card
---
Present but unusable — the same reading a missing key gets, recorded as a
coercion with the text as written.
@@ -0,0 +1,9 @@
---
schema: 1
kind: card
order: .nan
title: Non-Finite Order Card
---
NaN has no place in the total order the tie-break and midpoint math assume, so
it is unusable exactly like `banana` — a coercion since 2026-07-31, not a
rejection.
@@ -0,0 +1,6 @@
---
schema: 1
kind: lane
order: 1024
title: Ranked Lane
---
@@ -0,0 +1,6 @@
---
schema: 1
kind: card
order: 1024
title: Card In The Orderless Lane
---
@@ -0,0 +1,6 @@
---
schema: 1
kind: lane
title: Orderless Lane
---
No rank at all: sorts right of every ranked lane in the strip.
@@ -0,0 +1,7 @@
---
kind: lane
order: 2048
title: Schemaless Lane
---
No `schema` below the root reads as 1 — the walk validating this file against
schema 1 is what makes the reading reliable.
@@ -0,0 +1,8 @@
---
schema: 1
kind: board
title: Optional Keys
---
The board root keeps its `schema` — the this-really-is-a-board gate. Everything
below it may leave `order` and `schema` out entirely (01-storage-format.md
§ Frontmatter and § Ordering, re-ruled 2026-07-31).
@@ -2,7 +2,7 @@
schema: 1 schema: 1
title: Wire up the loader's stray tolerance title: Wire up the loader's stray tolerance
order: 2048 order: 2048
background: coral background: {color: coral}
icon: flag.fill icon: flag.fill
iconColor: orange iconColor: orange
--- ---
@@ -3,7 +3,7 @@ schema: 1
order: 1024 order: 1024
title: Doing title: Doing
width: 2 width: 2
background: '#3478F6' background: {color: '#3478F6'}
icon: hammer.fill icon: hammer.fill
iconColor: blue iconColor: blue
--- ---
@@ -2,6 +2,6 @@
schema: 1 schema: 1
order: 2048 order: 2048
title: Done title: Done
background: green background: {color: green}
--- ---
Completed work lives here until someone clears it out. Completed work lives here until someone clears it out.
+1 -1
View File
@@ -5,7 +5,7 @@ title: "Rich Demo Board"
created: 2026-07-01T09:00:00Z created: 2026-07-01T09:00:00Z
modified: 2026-07-26T16:41:38Z modified: 2026-07-26T16:41:38Z
modified-by: claude modified-by: claude
background: "#1E1E1E" background: {color: "#1E1E1E"}
icon: rectangle.stack.fill icon: rectangle.stack.fill
iconColor: purple iconColor: purple
+25
View File
@@ -0,0 +1,25 @@
import IndieAbout
/// **The About box's configuration.**
///
/// **Dormant since the 2026-08-07 pivot** (12-editions.md PIVOT 2026-08-07 git leaves the
/// paywall): this box used to carry a second copyright line naming Lanework Pro, one of the three
/// places 12's pre-pivot "Tier naming" section named the subscription. Git left the paywall, the
/// base/Pro split is being re-decided, and until it's ruled, no surface in the app names or sells
/// Pro so the line comes out, and the box goes back to a plain, one-line copyright shown to
/// everyone.
enum AboutBox {
/// The About window's content: version/build/date from the stamped Info.plist
/// (`update_build_info.sh` CFBundleVersion, BuildDate, BuildHash), the version line
/// opening the bundled CHANGELOG.md, and the bundled ISC LICENSE.md as the one document
/// link. Both documents are authored to the Apple inline-Markdown subset see the files'
/// own single-line paragraphs.
static var configuration: AppInfoConfiguration {
AppInfoConfiguration(
copyrightText: "© 2026 rzen",
documents: [.license(extension: "md")],
changelogDocument: .changelog()
)
}
}
+2 -2
View File
@@ -291,7 +291,7 @@ struct DuplicateBoardCommand: View {
/// rule covers File Duplicate". So the shape here is `DuplicateBoardCommand`'s, step for step /// rule covers File Duplicate". So the shape here is `DuplicateBoardCommand`'s, step for step
/// flush, then a cancellable copy off the main actor under an in-progress row and the differences /// flush, then a cancellable copy off the main actor under an in-progress row and the differences
/// are all in the engine (`TemplateEngine.saveAsTemplate(boardAt:titled:into:)`): the destination is /// are all in the engine (`TemplateEngine.saveAsTemplate(boardAt:titled:into:)`): the destination is
/// Application Support rather than a sibling, `.git` and `.trash/` are dropped rather than forked, a /// the app-side template store rather than a sibling, `.git` and `.trash/` are dropped rather than forked, a
/// collision auto-renames rather than failing, and a `template:` key lands on the copy. /// collision auto-renames rather than failing, and a `template:` key lands on the copy.
/// ///
/// **No save panel, ever.** The store is the app's own container "friction-free sandbox writes, no /// **No save panel, ever.** The store is the app's own container "friction-free sandbox writes, no
@@ -313,7 +313,7 @@ struct DuplicateBoardCommand: View {
/// ///
/// Board window only, and disabled under the read-only lock with the exception 09 spells out and /// Board window only, and disabled under the read-only lock with the exception 09 spells out and
/// 02-architecture.md Live-reload resilience scopes: **under the unwritable-location lock alone it /// 02-architecture.md Live-reload resilience scopes: **under the unwritable-location lock alone it
/// stays live**, because it "reads the board and writes Application Support" (archiving the /// stays live**, because it "reads the board and writes the app-side store" (archiving the
/// read-only DMG board being inspected is a legitimate errand), *unless* an open Edit or raw-source /// read-only DMG board being inspected is a legitimate errand), *unless* an open Edit or raw-source
/// session holds unsaved content content that lock's suspended saves cannot flush, and which the /// session holds unsaved content content that lock's suspended saves cannot flush, and which the
/// template would therefore silently miss. The other two locks disable it outright: a vanished root /// template would therefore silently miss. The other two locks disable it outright: a vanished root
+21
View File
@@ -16,6 +16,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-delegate") private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-delegate")
/// Starts the Pro entitlement's transaction listener (12-editions.md The entitlement).
///
/// **Here rather than in `AppModel.init`**, and the distinction matters: a unit-test host *is*
/// this app, so `KanbanApp.init()` and therefore `AppModel.init` run on every test launch
/// (`AppStateHome.isUnitTestHost`). Building the entitlement there costs one `UserDefaults` read;
/// acquiring a StoreKit listener there would give every test run a live `Transaction.updates`
/// subscription for no reason. Launch is the honest home for a listener, and this is the app's.
///
/// It starts nothing the board-open path waits on: the listener writes cached facts that a
/// *later* composition may read, and never reaches into a session that is already open
/// (`ProEntitlement`).
///
/// **The appearance override applies here too, for the same reason.** `AppearanceStore.init`
/// only reads; this is the one call that hands its answer to `NSApp` the global side effect
/// `KanbanApp.init` must not carry, since a unit-test host runs that `init` on every launch
/// (`AppearanceStore.applyCurrent`).
func applicationDidFinishLaunching(_ notification: Notification) {
appModel?.entitlement.start()
appModel?.appearance.applyCurrent()
}
/// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar /// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar
/// alive)" a document-shaped app whose windows are boards has no business quitting because the /// alive)" a document-shaped app whose windows are boards has no business quitting because the
/// user tidied one away, and welcome is one Dock click or one menu item back. /// user tidied one away, and welcome is one Dock click or one menu item back.
+993 -39
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
import SwiftUI
// MARK: - View Appearance
/// View Appearance Auto / Light / Dark (11-command-nexus.md View; 03-board-ui.md Toolbar).
///
/// **App-wide and always enabled**, unlike `ZoomCommands` beside it in the View menu: appearance is a
/// preference about how *every* window in the app draws, welcome included, so this row needs no
/// `@FocusedValue` scoping and no board window in front `NewBoardCommand`'s posture (everywhere, no
/// focus required) rather than `ZoomCommands`' (board windows only).
///
/// A `Picker` rather than three independent toggles: SwiftUI renders one placed directly in a
/// menu-bar command group as a submenu "Appearance" as its title, "Auto" / "Light" / "Dark" as its
/// rows, a checkmark on whichever is selected which is the three-way exclusive choice a trio of
/// `Toggle`s cannot express (nothing stops more than one, or none, from reading as checked). `nil` is
/// the Auto tag; `AppearanceStore.setOverride` is the single write path the board-toolbar item shares
/// (`BoardZoomStore.step`'s rule a toolbar item is a menu command with a different face, never a
/// second implementation of it).
struct AppearanceCommands: View {
/// A plain `let` rather than an `@Environment` read, `ZoomCommands`' reason: menu commands live in
/// the menu bar, outside every scene's environment; the row re-renders on a change because
/// `AppModel` is `@Observable` (`NewBoardCommand`'s pattern).
let appModel: AppModel
var body: some View {
Picker("Appearance", selection: Binding(
get: { appModel.appearance.override },
set: { appModel.appearance.setOverride($0) }
)) {
Text("Auto").tag(nil as AppAppearance?)
Text("Light").tag(AppAppearance.light as AppAppearance?)
Text("Dark").tag(AppAppearance.dark as AppAppearance?)
}
}
}
+130
View File
@@ -0,0 +1,130 @@
import AppKit
import Observation
// MARK: - AppAppearance
/// The app-wide appearance override Auto (follow system) / Light / Dark (11-command-nexus.md
/// View Appearance; 03-board-ui.md Toolbar).
///
/// There is no `.auto` case: following the system is the *absence* of an override, which is what makes
/// "no stored key" the one honest spelling of it (`AppearanceStore`, `AppPreferences.appearanceKey`)
/// a third case would need its own reading of what it means to override with "don't override".
public enum AppAppearance: String, CaseIterable, Sendable {
case light
case dark
}
// MARK: - AppearanceStore
/// The app's one appearance override, app-wide and persisted (11-command-nexus.md View Appearance;
/// 03-board-ui.md Toolbar).
///
/// `BoardZoomStore`'s shape exactly, and for its reasons. Two consumers need change notification a
/// property wrapper in a view cannot give them: the board toolbar's picker item, whose checkmarks are
/// read fresh whenever AppKit opens its menu rather than polled (`WindowToolbarController`), and the
/// View-menu picker, which lives outside every scene's environment and reaches `AppModel` as a plain
/// `let` the same way `ZoomCommands` reaches `zoom`. An `@Observable` object over an injectable
/// `UserDefaults` is what serves both without either one mirroring the other's state.
///
/// ### Why not `@AppStorage`, like Show Comments
///
/// Show Comments has exactly one write path and exactly one thing reading it back the checkbox
/// itself. This preference has two independent controls that must never drift the way the toolbar's
/// zoom buttons and the View-menu zoom rows must not (`BoardZoomStore.setLevel`'s rule), and the
/// toolbar's is AppKit underneath an `NSMenuToolbarItem` cannot bind to `@AppStorage` at all.
///
/// ### Why the write path applies live, unlike zoom's
///
/// A zoom level only ever feeds a board's own drawing, so persisting it is enough the board reads it
/// back through the environment. An appearance override is a statement about the whole app's chrome,
/// every open window included, so the setter both persists *and* calls the apply seam in the same
/// beat: there is no reload and no window that has to be told twice.
@MainActor
@Observable
public final class AppearanceStore {
/// The current override. `nil` is Auto the app follows the system appearance.
public private(set) var override: AppAppearance?
@ObservationIgnored
private let defaults: UserDefaults
/// The one seam that touches `NSApp` injected so a test can prove the setter's whole contract
/// (persist, then apply) without a live application object, `BoardZoomStore.defaults`'s reason
/// turned toward AppKit rather than `UserDefaults`.
@ObservationIgnored
private let apply: (NSAppearance.Name?) -> Void
/// - Parameters:
/// - defaults: the domain to persist in injected for `BoardZoomStore`'s reason: a test holds
/// its own rather than touching the developer's real appearance.
/// - apply: what "make it so" means. Defaulted to the real thing; a test hands in a recording
/// closure instead so it never touches `NSApp`.
public init(
defaults: UserDefaults = .standard,
apply: @escaping (NSAppearance.Name?) -> Void = { name in
NSApp.appearance = name.map { NSAppearance(named: $0) } ?? nil
}
) {
self.defaults = defaults
self.apply = apply
// A string, not an enum-backed scalar: the key is absent for Auto (the remove-at-default
// idiom a default lane width and an empty rename already use), and any value that survives to
// here but is neither "light" nor "dark" a hand edit, a future build's spelling read by an
// older one degrades to Auto rather than refusing to resolve. `AppAppearance.init(rawValue:)`
// already answers `nil` for anything it does not recognise, so the lenient read costs nothing
// beyond the `flatMap`.
override = defaults.string(forKey: AppPreferences.appearanceKey).flatMap(AppAppearance.init(rawValue:))
}
// MARK: - The pure resolver
/// What an override means to AppKit no `NSApp`, no live application, provable with nothing but
/// the enum (`BoardZoom.normalize`'s reason: the rule is a function, and the object around it is
/// only that function's persistence and observability).
///
/// `nonisolated`, unlike everything else here: it touches no actor-isolated state, and marking it
/// so is what lets a plain (non-`@MainActor`) test call it directly, the same freedom
/// `BoardZoom.normalize` has by living outside `BoardZoomStore` entirely.
public nonisolated static func appearanceName(for override: AppAppearance?) -> NSAppearance.Name? {
switch override {
case .light: .aqua
case .dark: .darkAqua
case nil: nil
}
}
// MARK: - Writing
/// Sets the override, persists it, and applies it the single write path the View-menu picker and
/// the board-toolbar item share (`BoardZoomStore.setLevel`'s rule: the two faces of one command
/// must never become two implementations of it).
///
/// **Auto removes the key** rather than writing a third spelling of it: the preference is meant to
/// read as "no override on file" to anyone who inspects it, the same bargain a default lane width
/// and an empty rename already keep.
///
/// **An unchanged value writes and applies nothing**, `BoardZoomStore.setLevel`'s own guard and for
/// the same load-bearing reason: `@Observable` notifies on every assignment, equal or not, so an
/// ungated write would invalidate every observer of `override` the picker's checkmarks, the
/// toolbar controller's tracked validation on a no-op, and hand the apply seam a repeat call for
/// nothing every one of its callers would have to tolerate.
public func setOverride(_ newValue: AppAppearance?) {
guard newValue != override else { return }
override = newValue
if let newValue {
defaults.set(newValue.rawValue, forKey: AppPreferences.appearanceKey)
} else {
defaults.removeObject(forKey: AppPreferences.appearanceKey)
}
apply(Self.appearanceName(for: newValue))
}
/// Re-applies the stored override launch's whole job
/// (`AppDelegate.applicationDidFinishLaunching`). `init` above already read the value; this is the
/// method that hands it to AppKit, kept separate from `init` so building a store including in a
/// test, including as `AppModel`'s own construction is never itself a global side effect.
public func applyCurrent() {
apply(Self.appearanceName(for: override))
}
}
+15
View File
@@ -106,8 +106,23 @@ enum BoardTreeCopy {
throw .failed(url: source, error: error) throw .failed(url: source, error: error)
} }
// The one exclusion this walk makes without being asked (01-storage-format.md § Enhanced
// schema: `comments/.trash/` is "**stripped at every copy boundary** (clipboard staging,
// Duplicate, Save as Template) a copy must not carry ghosts no window session will ever
// purge"). Unconditional because all three of this walk's callers *are* copy boundaries, and
// an opt-in flag would be three call sites agreeing to one rule instead of one rule.
//
// It is the one depth-sensitive exclusion, hence the parent check rather than a name in
// `excluded`: `.trash` at a board root is the board's own and carries or not per each flow's
// stated exclusions, while `.trash` inside a `comments/` is undo's backing store and never
// travels.
let isCommentThread = source.lastPathComponent.lowercased() == IntegrityRules.commentsFolderName
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
guard !excluded.contains(entry.lastPathComponent.lowercased()) else { continue } guard !excluded.contains(entry.lastPathComponent.lowercased()) else { continue }
guard !isCommentThread
|| entry.lastPathComponent.lowercased() != IntegrityRules.commentTrashFolderName
else { continue }
// Between items, never mid-item: this is the whole of "checks cancellation between // Between items, never mid-item: this is the whole of "checks cancellation between
// items", and the reason the copy is a walk at all. // items", and the reason the copy is a walk at all.
+554 -48
View File
@@ -15,9 +15,23 @@ import os
/// it is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out /// it is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out
/// of the way. /// of the way.
/// ///
/// ### Every open passes through a loading state
///
/// The window appears **immediately** welcome click, File Open, Finder double-click,
/// restoration alike at its saved frame, its title carrying the registry record's cached name,
/// and its content area holding `BoardLoadingView` until the first snapshot lands
/// (02-architecture.md § Launch and window lifecycle, ruled 2026-07-29). The walk that produces
/// that snapshot runs **off the main actor** (`BoardStoreRegistry.acquireOffMain`), so a board of
/// any size opens as a live window rather than as a beachball, and every restored window walks
/// independently of every other.
///
/// The window is therefore real, and closeable, before it has a store: W during the walk cancels
/// it and closes the window. That is why `configureWindow` is in two halves see
/// `configureLoadingWindow(recordID:)`.
///
/// ### Failure opens welcome, on a row that already exists /// ### Failure opens welcome, on a row that already exists
/// ///
/// A board that will not load has nothing to show, so its window never appears. But its registry /// A board that will not load has nothing to show, so its window retires. But its registry
/// record is created **before** the load runs (02-architecture.md § Per-board app state, "a first /// record is created **before** the load runs (02-architecture.md § Per-board app state, "a first
/// open that fails fail-fast still records"), so the failure that joins `AppModel.launchFailures` /// open that fails fail-fast still records"), so the failure that joins `AppModel.launchFailures`
/// always has a recents row waiting for it `WelcomeRow.derive` matches the two by path, uniform /// always has a recents row waiting for it `WelcomeRow.derive` matches the two by path, uniform
@@ -44,8 +58,8 @@ struct BoardWindowHost: View {
@State private var boardInfo = BoardInfoPresentation() @State private var boardInfo = BoardInfoPresentation()
/// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s /// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s
/// reason and reaching the menu bar the same way: Delete Immediately and Empty Trash are /// reason and reaching the menu bar the same way: File Delete (landing on a trash selection)
/// menu-bar items, and a menu item cannot present anything of its own. /// and Empty Trash are menu-bar items, and a menu item cannot present anything of its own.
@State private var trashConfirmations = TrashConfirmations() @State private var trashConfirmations = TrashConfirmations()
/// How Board Open Card reaches this window's card windows. `@State` for `boardInfo`'s reason, /// How Board Open Card reaches this window's card windows. `@State` for `boardInfo`'s reason,
@@ -59,10 +73,53 @@ struct BoardWindowHost: View {
/// window's field. /// window's field.
@State private var boardSearch = BoardSearchPresentation() @State private var boardSearch = BoardSearchPresentation()
/// The pre-snapshot surface's grace clock (02 § Launch and window lifecycle). `@State` for
/// `boardInfo`'s reason one per window, living exactly as long as the window.
@State private var loading = BoardLoadingIndicator()
/// The open walk, so W can cancel it by name rather than by waiting for SwiftUI's teardown to
/// get around to it.
@State private var openWalk = BoardOpenWalk()
/// This board's registry record, from the moment `recordOpen` mints it which is what the
/// loading window's title reads (`Self.loadingTitle`). `nil` only for the one body evaluation
/// that precedes `start()`.
@State private var recordID: UUID?
/// This open's security-scoped access, claimed in `start()` and held until the session takes it
/// over or the open ends.
///
/// `@State` rather than a local in `start()` because the decision surface outlives that call: a
/// repair *writes into the board*, and a scope released when `start()` returned would be released
/// exactly before the one write that needs it. Every exit balances it the session adopts it,
/// or Cancel and the failure path stop it.
@State private var access: ScopedAccess?
/// Whether a person asked for this board (`OpenOrigin`) claimed beside the access, and read by
/// exactly one branch: what a failed walk does.
@State private var origin: OpenOrigin = .attended
/// The board's URL as this open resolved it the scoped one where there is one. Held for the
/// surface's sake, which re-walks and repairs against it long after `start()` has returned.
@State private var boardURL: URL?
/// **The repair bracket's ledger**, held between Repair and Open's writes and the store that the
/// following walk builds (`BoardRepairRun`, `EchoLedger.adopt`).
///
/// It cannot live anywhere else: the repairs run before a store exists and the receipts have to
/// reach that store's ledger before `beginSession` composes a git board's committer, or the app's
/// own repair commits as `Lanework External`. (Pro's committer, until 12-editions.md PIVOT
/// 2026-08-07 made it every git board's.) Cleared once adopted.
@State private var repairLedger: EchoLedger?
@State private var phase: Phase = .opening @State private var phase: Phase = .opening
private enum Phase { private enum Phase {
case opening case opening
/// **The walk refused and a person is looking at it** the decision surface, in the loading
/// window's own content area (01-storage-format.md § Malformed input, settled 2026-07-31:
/// "the loading content transforms in place, never a sheet over a spinner").
case deciding(BoardDecisionSurfaceModel)
case open(BoardStore) case open(BoardStore)
/// The load failed; this window is on its way out and must not try again. /// The load failed; this window is on its way out and must not try again.
case failed case failed
@@ -76,22 +133,41 @@ struct BoardWindowHost: View {
// 10-accessibility.md's full-relative-scaling rule): at a large system text size a // 10-accessibility.md's full-relative-scaling rule): at a large system text size a
// 640×400 floor would be narrower than two lane headers, and "every lane is always on // 640×400 floor would be narrower than two lane headers, and "every lane is always on
// screen" would degrade into a strip of truncation. // screen" would degrade into a strip of truncation.
//
// **The *system* size, not the zoomed one** (03-board-ui.md Layout zoom: "Zoom never
// moves the window"). Zooming to 200% must not push a floor up under a window the user
// already sized: moving the window belongs to the right-edge lane drag alone, and a
// minimum that grew with the level would resize every open board from a menu item.
.frame( .frame(
minWidth: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).width, minWidth: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).width,
minHeight: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).height minHeight: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).height
) )
.background(WindowAccessor(controller: windowController)) .background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle) .navigationTitle(windowTitle)
.task { await start() } .task { await beginOpening() }
.onDisappear { endSessionIfStillOpen() } .onDisappear { endSessionIfStillOpen() }
} }
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
switch phase { switch phase {
case .opening, .failed: case .opening:
// Nothing to render and nothing worth animating: this window either becomes a board in a // **The pre-snapshot loading state** (02 § Launch and window lifecycle): empty for the
// moment or dismisses itself. // grace, a centered spinner after it, never a skeleton. The board replaces it in place
// when `phase` becomes `.open` a snap, which is what assigning outside `withAnimation`
// means here.
BoardLoadingView(indicator: loading)
case let .deciding(model):
// **In place.** The same content area the spinner was in, with no transition of its own:
// 02's snap, read for the surface that arrives instead of a snapshot.
BoardDecisionSurface(
model: model,
onRepairAndOpen: { repairAndOpen(model) },
onRecheck: { recheck(model) },
onCancel: { cancelDecision(model, closingWindow: false) }
)
case .failed:
// Nothing to render and nothing worth animating: this window is dismissing itself.
Color.clear Color.clear
case let .open(store): case let .open(store):
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -117,6 +193,11 @@ struct BoardWindowHost: View {
openCard: openCard, openCard: openCard,
search: boardSearch search: boardSearch
) )
// **The zoom level enters here and nowhere else** (03-board-ui.md Layout zoom).
// On `BoardView` rather than on the `VStack`, deliberately: the level is the *board's*
// ruler, so the banner strip and the transient search bar above it chrome, not the
// board stay at the system's size, as do the sheets and popovers this window hosts.
.environment(\.boardZoom, appModel.zoom.context)
} }
// The transient strip's two dismissal inputs (`BoardSearchPresentation // The transient strip's two dismissal inputs (`BoardSearchPresentation
// .transientPersists`): it stays while a query is filtering the board or while the field // .transientPersists`): it stays while a query is filtering the board or while the field
@@ -127,6 +208,26 @@ struct BoardWindowHost: View {
.onChange(of: boardSearch.isFocused) { _, _ in .onChange(of: boardSearch.isFocused) { _, _ in
boardSearch.dismissTransientIfCleared(query: store.searchQuery) boardSearch.dismissTransientIfCleared(query: store.searchQuery)
} }
// **The window chrome follows the board's background** (03-board-ui.md § Styling
// Capabilities): a board that paints one runs its content the full height of the frame
// under a transparent title bar, with `BoardView.boardBackground`'s frosted strip
// keeping the widget and the toolbar legible over it; a board that paints none keeps
// the standard chrome untouched.
//
// Here rather than in `configureWindow` because it is not a wiring fact but a *live*
// one: `background` is hand-editable, the watcher reloads on a change to `index.md`, and
// the chrome has to follow the reading in both directions. `initial: true` because the
// first render is already a level, not a change this is the board's first statement
// about its chrome, and the loading half deliberately made none
// (`HostedWindowController.extendsUnderTitlebar`).
.onChange(of: BoardBackdrop.isCustom(store.snapshot, root: store.rootURL), initial: true) { _, custom in
windowController.setExtendsContentUnderTitlebar(custom)
}
// (**The board settings sheet was presented here** between 2026-07-31 and 2026-08-07,
// when the popover/sheet split was reversed: the sheet retired, its contents rehomed
// into the popover's Git tab, and this window has no modal configuration surface at all
// now 03-board-ui.md Board settings sheet, marked retired.)
//
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and // "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
// beside it the window's own popover flag, which is what File Board Info toggles, its // beside it the window's own popover flag, which is what File Board Info toggles, its
// purge-alert host, which the trash's two confirmed commands raise, and its search // purge-alert host, which the trash's two confirmed commands raise, and its search
@@ -147,59 +248,152 @@ struct BoardWindowHost: View {
/// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it, /// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it,
/// so "at most one card window per card (reopen focuses)" needs no bookkeeping here /// so "at most one card window per card (reopen focuses)" needs no bookkeeping here
/// (02-architecture.md § Windows). /// (02-architecture.md § Windows).
private var openCard: (ItemID) -> Void { private var openCard: @MainActor (ItemID) -> Void {
{ cardID in { cardID in
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID)) openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
} }
} }
private var windowTitle: String { private var windowTitle: String {
guard case let .open(store) = phase else { return "" } guard case let .open(store) = phase else {
return Self.loadingTitle(
record: recordID.flatMap { appModel.boardRegistry.record(id: $0) },
url: ref.url
)
}
return AppModel.displayName(of: store) return AppModel.displayName(of: store)
} }
/// What a window that has no snapshot yet is called **the registry record's cached name**, the
/// same no-scan source the welcome row reads (02 § Launch and window lifecycle: "its chrome
/// carrying the registry record's cached title and icon"; § Per-board app state, "the welcome row
/// reads only the record it never opens any board's `index.md`").
///
/// Read back off the record rather than recomputed, so a board that has opened before shows the
/// title it is known by and a first-ever open shows the folder name which is that record's
/// provisional display name, not a second rule. The `nil` fallback is the folder name anyway,
/// covering only the body evaluation that precedes `recordOpen`.
///
/// Static and pure so the rule is checkable without a window (`BoardWindowHostTests`).
static func loadingTitle(record: BoardRecord?, url: URL) -> String {
record?.displayName ?? AppModel.folderDisplayName(of: url)
}
// MARK: - Opening // MARK: - Opening
/// Starts the open as a task of its own, so something can hold it.
///
/// `.task` cancels on teardown but hands out no handle, and W during loading needs one *by
/// name* the ruled cancel is an act of the user's, not a consequence of a window that has
/// already gone away (02 § Launch and window lifecycle). So the walk runs in a task `openWalk`
/// keeps, and the cancellation handler forwards `.task`'s own cancellation into it, leaving both
/// routes the user's W and any teardown SwiftUI decides on ending in the same `cancel()`.
private func beginOpening() async {
let walk = Task { await start() }
openWalk.adopt(walk)
await withTaskCancellationHandler {
await walk.value
} onCancel: {
walk.cancel()
}
}
/// Acquires the board and starts its session, or fails it out to welcome. /// Acquires the board and starts its session, or fails it out to welcome.
/// ///
/// The order is load-bearing, and it now has one more step than the load itself does. Security- /// The order is load-bearing, and it now has one more step than the load itself does. Security-
/// scoped access is claimed **before** anything else, because the record and the load both need /// scoped access is claimed **before** anything else, because the record and the load both need
/// it. The registry record comes **before** `acquire` "the registry record is created before /// it. The registry record comes **before** the walk "the registry record is created before
/// loading" (02 § Per-board app state) so a fail-fast failure always has a row to land on; /// loading" (02 § Per-board app state) so a fail-fast failure always has a row to land on, and
/// `acquire`'s own first act is the tree walk, and a sandboxed read outside the claimed scope is /// so the loading window has a name to wear; the walk's first act is a directory read, and a
/// exactly the one that gets refused. `setOpenNow` comes **after** the load succeeds and after /// sandboxed read outside the claimed scope is exactly the one that gets refused. `setOpenNow`
/// the window has demonstrably opened a flag set on a board that never appeared would hand the /// comes **after** the load succeeds and after the window has demonstrably opened a flag set on
/// next launch a restoration set describing a failure. /// a board that never appeared would hand the next launch a restoration set describing a failure.
///
/// **The walk is the one suspension here**, and everything before it is what makes the window
/// real while it runs: the record, the loading window's chrome, and the grace clock. Everything
/// after it is the snap.
private func start() async { private func start() async {
guard case .opening = phase else { return } guard case .opening = phase else { return }
// Claimed even on the failure path: an unclaimed stash is a scope nobody balances. // Claimed even on the failure path: an unclaimed stash is a scope nobody balances. The origin
let access = appModel.claimPendingAccess(for: ref) // rides along one claim, one dictionary (`AppModel.claimPendingOpen`).
let url = access?.url ?? ref.url let claimed = appModel.claimPendingOpen(for: ref)
access = claimed.access
origin = claimed.origin
let url = claimed.access?.url ?? ref.url
boardURL = url
// Record before load (settled, 02 § Per-board app state). `displayName` is omitted an // Record before load (settled, 02 § Per-board app state). `displayName` is omitted an
// existing record's cached title survives untouched, and a brand-new one takes the folder // existing record's cached title survives untouched, and a brand-new one takes the folder
// name, both `recordOpen`'s own rule now. This is also this open's one bookmark mint: a // name, both `recordOpen`'s own rule now. This is also this open's one bookmark mint: a
// successful load below replaces the name through `syncDisplayState`, which never re-mints. // successful load below replaces the name through `syncDisplayState`, which never re-mints.
let recordID = appModel.boardRegistry.recordOpen(of: url) let recordID = appModel.boardRegistry.recordOpen(of: url)
// Published to the view *before* the walk: this is what the title bar reads while loading.
self.recordID = recordID
// The window is on screen and the user can act on it from here on placed where they left
// it, and closeable.
configureLoadingWindow(recordID: recordID)
loading.begin()
await attemptOpen(url: url, recordID: recordID, skipping: [])
}
/// **One walk, and what it lands in** the loop Repair and Open and Re-check re-enter.
///
/// It is a method rather than the tail of `start()` because the surface's two buttons "re-run the
/// whole walk" (01-storage-format.md § Malformed input) and must land in exactly the places this
/// lands: a clean walk proceeds into the ordinary open, and a walk that still refuses
/// re-aggregates into the *same* surface. Sharing the body is what makes "never a chained second
/// dialog" structural rather than remembered.
///
/// - Parameter skipping: the surface's consented skips, empty on a first attempt. It reaches the
/// walk *and* the store, which retains it for the session (`BoardStore.skippedPaths`).
private func attemptOpen(url: URL, recordID: UUID, skipping: Set<String>) async {
let store: BoardStore let store: BoardStore
do throws(BoardLoadError) { do throws(BoardLoadFailure) {
store = try appModel.storeRegistry.acquire(url) guard let acquired = try await appModel.storeRegistry.acquireOffMain(url, skipping: skipping) else {
// W landed while the walk was running, and the walk has now finished into a result
// nobody wants (`acquireOffMain`, discard-on-completion). The window is already
// closing and the registry kept nothing, so the only thing left to balance is this
// open's scoped access. There is no open-now flag to clear: `setOpenNow` is below,
// after the load, so a cancelled open never set one the very reason it lives there.
Self.logger.debug("board open cancelled during its walk")
loading.end()
releaseAccess()
return
}
store = acquired
} catch { } catch {
Self.logger.error("board failed to open: \(error.description, privacy: .public)") handleWalkFailure(error, url: url, recordID: recordID)
access?.stop()
phase = .failed
appModel.recordLaunchFailure(path: ref.path, message: error.description)
// The record above just changed the registry welcome, about to appear, must not
// render the stale list `AppModel` cached before this open began, or the failure would
// fall through to the unmatched-failures list for want of a row that already exists.
appModel.refreshRecents()
openWindow(id: WindowID.welcome)
dismissWindow(id: WindowID.board, value: ref)
return return
} }
// **The window retired while this walk was in flight** Cancel (or W) pressed during a
// Re-check, whose walk then landed successfully. The board must not open behind a window that
// has already gone to welcome, and the reference `acquireOffMain` took has to go back or the
// registry would hold a watcher for a board nobody is showing.
if case .failed = phase {
Self.logger.debug("a walk landed after the open was cancelled — releasing it")
appModel.storeRegistry.release(store)
releaseAccess()
return
}
loading.end()
// **The repair's receipts, into the board's own ledger before the session composes**
// (01: "On Pro boards the repairs drop heal-marked receipts and commit separately as one
// repair commit" every git board's since 12-editions.md PIVOT 2026-08-07, the sentence
// otherwise unchanged). `beginSession` is where that committer is built and started, and the
// committer harvests the ledger it is handed; receipts adopted after that line would be
// receipts the repair commit never sees, and the app's own repair would be authored
// `Lanework External`.
if let repairLedger {
store.echoes.adopt(repairLedger)
self.repairLedger = nil
}
// The load succeeded the frontmatter can be trusted now, so it replaces whatever // The load succeeded the frontmatter can be trusted now, so it replaces whatever
// provisional or stale name the record above was carrying. Through `syncDisplayState`, // provisional or stale name the record above was carrying. Through `syncDisplayState`,
// deliberately not a second `recordOpen`: this is a display-state refresh, not a second // deliberately not a second `recordOpen`: this is a display-state refresh, not a second
@@ -213,30 +407,240 @@ struct BoardWindowHost: View {
) )
appModel.boardRegistry.setOpenNow(id: recordID) appModel.boardRegistry.setOpenNow(id: recordID)
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access) appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
// The session owns the balance from here (`AppModel.beginSession`), so this window must not
// stop it on any later path.
access = nil
phase = .open(store) phase = .open(store)
configureWindow(store: store, recordID: recordID) configureWindow(store: store, recordID: recordID)
postSkipNoticeIfNeeded(store: store, skipping: skipping)
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless // "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
// when welcome is not open, which is the ordinary case. // when welcome is not open, which is the ordinary case.
dismissWindow(id: WindowID.welcome) dismissWindow(id: WindowID.welcome)
} }
/// Wires the window: the saved frame on the way in, frame changes on the way back out, the /// **The attendance branch** (01-storage-format.md § Malformed input, settled 2026-07-31): the
/// close interception that makes the flush unavoidable, and the title-bar widget. /// surface "appears on attended opens only restoration failures keep the retire-to-welcome-row
private func configureWindow(store: BoardStore, recordID: UUID) { /// landing".
// Filled in here rather than at declaration because the closure captures `openWindow`, an ///
// environment action; until the board has loaded there is also nothing for Open Card to act /// Three outcomes, and the third is the new one:
// on, which is exactly what the item's `nil` check reads. ///
cardOpener.open = openCard /// - **A restored open retires**, exactly as it did before this milestone. Nobody is sitting in
/// front of a launch that reopened four boards, and "launch never chains dialogs".
/// - **An attended open whose failure is environmental retires too** a root that is gone, or a
/// root that is a file. There is nothing on disk to repair, so a surface would offer the user a
/// decision with no choices in it; welcome's row says the same thing in one line, which is
/// where it belonged already. (The carve-out the ruling implies rather than states Redesign
/// Gap 87cd782a.)
/// - **Anything else transforms into the surface**, in place, in this window.
///
/// A surface is *entered* rather than shown: it holds this window's access, its record, and its
/// URL for as long as the user is deciding, which is why none of the three is released here.
private func handleWalkFailure(_ error: BoardLoadFailure, url: URL, recordID: UUID) {
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
// Retired while this walk ran (Cancel during a Re-check): the failure has already been
// recorded and the window is on its way out. A second `retire` would post the row twice.
if case .failed = phase { return }
guard Self.landing(for: error, origin: origin) == .decide else {
loading.end()
retire(message: error.description)
return
}
// Already deciding: this is a Re-check or a Repair and Open landing, and it re-aggregates in
// place the same model, the same window, no second dialog.
if case let .deciding(model) = phase {
model.reaggregate(error)
model.isWorking = false
return
}
loading.end()
phase = .deciding(BoardDecisionSurfaceModel(failure: error, boardRoot: url))
// W now means Cancel (01: the surface's own exit), replacing the loading half's
// cancel-the-walk closure a slot rather than a branch, `configureWindow`'s posture.
windowController.onCloseRequested = {
guard case let .deciding(model) = phase else { return }
cancelDecision(model, closingWindow: true)
}
}
/// Where a refused walk lands.
enum FailureLanding: Equatable {
/// Welcome, on the board's own recents row today's landing, unchanged.
case retire
/// The decision surface, in this window.
case decide
}
/// **The attendance branch as a pure rule** two facts in, one landing out, provable without a
/// window (`BoardDecisionSurfaceTests`).
///
/// It is a static rather than an `if` inside `handleWalkFailure` because it is the ruling's own
/// sentence and the one thing about this milestone that a regression would make silently wrong:
/// a launch that started showing surfaces would chain dialogs across four restored boards, and an
/// attended open that stopped showing one would look exactly like the app before this milestone.
static func landing(for failure: BoardLoadFailure, origin: OpenOrigin) -> FailureLanding {
guard origin == .attended else { return .retire }
return isEnvironmental(failure) ? .retire : .decide
}
/// **Nothing on disk to repair** the environmental carve-out's predicate.
///
/// A single defect, and that defect is a fact about the *root itself* rather than about a file
/// inside it: `BoardLoader` throws these immediately, before any walk, precisely because there is
/// nothing to walk. The single-defect check is stated rather than assumed the loader's
/// environmental throws are single by construction, and a future aggregate carrying one *among*
/// repairable defects should show the surface, because the rest of it is still actionable.
static func isEnvironmental(_ failure: BoardLoadFailure) -> Bool {
guard failure.defects.count == 1 else { return false }
switch failure.primary.reason {
case .unreadableRoot, .notADirectory:
return true
case .boardRootMissingIndex, .unparseableYAML, .missingSchema, .malformedSchema,
.schemaNewerThanApp, .missingOrder, .malformedOrder:
return false
}
}
// MARK: - The surface's three buttons
/// **Repair and Open**: apply every chosen fix in one write bracket, then re-run the whole walk
/// with the skip set (01-storage-format.md § Malformed input).
///
/// The repairs are store-less by necessity and heal-marked by rule (`BoardRepairRun`); the walk
/// that follows is the ordinary one, so a clean result proceeds into the ordinary open and a
/// dirty one re-aggregates here. A repair that failed does not abort anything the batch stops,
/// the failure shows on the surface, and the walk runs anyway, because "a partial repair simply
/// re-aggregates on the next walk".
private func repairAndOpen(_ model: BoardDecisionSurfaceModel) {
guard let url = boardURL, let recordID, !model.isWorking else { return }
model.isWorking = true
model.repairFailure = nil
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: url)
model.repairFailure = outcome.failure
// Held for the store the walk below may build see `repairLedger`. Merged with anything an
// earlier pass left, so two rounds of repair both reach the commit.
if let existing = repairLedger {
existing.adopt(outcome.ledger)
} else {
repairLedger = outcome.ledger
}
let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
openWalk.adopt(walk)
}
/// **Re-check**: re-run the whole walk with the current skip set, changing nothing on disk
/// (01: "a disk changed underneath re-aggregates into the *same* surface with the fresh defect
/// list a clean walk proceeds to the first snapshot").
private func recheck(_ model: BoardDecisionSurfaceModel) {
guard let url = boardURL, let recordID, !model.isWorking else { return }
model.isWorking = true
let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
openWalk.adopt(walk)
}
/// **Cancel** and W, which means exactly this while the surface is up (01: "**Cancel** aborts
/// the open: the window retires and the board lands row-level on welcome, record-before-load
/// unchanged").
///
/// It is the failure path's own sequence, run against the aggregate the surface was showing: the
/// record was minted before the walk, so the row is already waiting for this message.
///
/// - Parameter closingWindow: true when AppKit asked (W), where the interception has to be
/// released for the close to complete. The button's own press dismisses through SwiftUI.
private func cancelDecision(_ model: BoardDecisionSurfaceModel, closingWindow: Bool) {
// A walk may still be in flight behind the surface (Cancel during a Re-check). Its landing is
// guarded by `phase`, which this sets to `.failed`; cancelling as well means the open is not
// merely ignored but abandoned.
openWalk.cancel()
retire(message: model.failure.description)
if closingWindow {
windowController.closeAfterFlush()
}
}
/// The retire-to-welcome landing, in one place: the sequence a failed restore has always had, and
/// the sequence Cancel now shares with it.
private func retire(message: String) {
releaseAccess()
phase = .failed
// **The interception has to go with the phase.** `windowShouldClose` returns `false` whenever
// a closure is installed that is how the close flush gets its turn so a retired window
// whose closure had nothing left to do would *refuse every close request for the rest of the
// app's life*, quit included. There is nothing to intercept once this window is on its way to
// welcome: no store, no session, no decision.
windowController.onCloseRequested = nil
appModel.recordLaunchFailure(path: ref.path, message: message)
// The record minted before the walk just changed the registry welcome, about to appear,
// must not render the stale list `AppModel` cached before this open began, or the failure
// would fall through to the unmatched-failures list for want of a row that already exists.
appModel.refreshRecents()
openWindow(id: WindowID.welcome)
dismissWindow(id: WindowID.board, value: ref)
}
private func releaseAccess() {
access?.stop()
access = nil
}
/// **The skip notice** (01: "the opened board carries a warning-tone notice naming the skipped
/// items, each with Reveal in Finder").
///
/// Written from the walk's own `LoadWarning.userSkipped` entries rather than from the surface's
/// skip set, deliberately: what the notice owes the user is what actually left the board, and a
/// skip for a defect that repaired itself between the decision and the walk names nothing.
///
/// **Only the open that carried the skips posts it.** A second window onto the same board
/// acquires the store that already exists, whose warnings still describe the first open and a
/// notice repeated per window would be the app reporting one decision twice. `skipping` is
/// non-empty for exactly the open that made the decision.
private func postSkipNoticeIfNeeded(store: BoardStore, skipping: Set<String>) {
guard !skipping.isEmpty else { return }
let root = store.rootURL
let items = store.loadWarnings.compactMap { warning -> RevealTarget? in
guard case let .userSkipped(path) = warning else { return nil }
return RevealTarget(path: path, url: root.appendingPathComponent(path))
}
store.banners.postSkippedOnOpen(items)
}
/// **The half of the wiring a window needs before it has a board** everything here is about
/// the *window*, and nothing here mentions the store, which is exactly the split
/// 02-architecture.md's loading state forces: this runs before the walk, and
/// `configureWindow(store:recordID:)` runs after it.
///
/// Three things, and each is a rule from § Launch and window lifecycle:
///
/// - **The saved frame**, so the window appears "at its saved frame" rather than at the system's
/// cascade and then jumping to the user's place a second later.
/// - **The frame changes**, so a window the user moves *while it loads* is remembered. Not
/// store-dependent and so not worth deferring the alternative is a slow board's window whose
/// move is silently discarded.
/// - **The close interception**, which is what makes W during loading mean anything at all. It
/// is replaced wholesale by the flushing version once the board is open (see below); a single
/// closure branching on `phase` would be the same thing spelled as a state read.
///
/// The title bar keeps AppKit's own title display for now the string is the record's cached
/// name (`windowTitle`) and `hideTitle()` follows only once the board-popover widget is there
/// to say the name instead. Hiding it here would leave a loading window with no name anywhere in
/// its chrome, which is precisely what 02 asks the loading state to carry.
private func configureLoadingWindow(recordID: UUID) {
windowController.onAttach = { window in windowController.onAttach = { window in
guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return } guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return }
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true) window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
} }
// The window may already be attached `viewDidMoveToWindow` fires well before this task's // The window may already be attached `viewDidMoveToWindow` fires before this task's first
// load returns so the placement is applied directly too rather than waiting for a callback // suspension so the placement is applied directly too rather than waiting for a callback
// that has already happened. // that has already happened. The closure stays installed either way: the controller re-fires
// it if SwiftUI swaps the provisional window for the real one (`HostedWindowController
// .detach`).
if let window = windowController.window { if let window = windowController.window {
windowController.onAttach?(window) windowController.onAttach?(window)
} }
@@ -248,12 +652,39 @@ struct BoardWindowHost: View {
) )
} }
windowController.onCloseRequested = {
// **W during the walk** (02 § Launch and window lifecycle: "the walk is cancellable:
// W during loading cancels it and closes the window"). The window closes *now* there
// is no store, so there is nothing to flush and nothing to wait for and the walk's
// tail is wasted work we accept rather than thread a cancellation flag through the
// loader (`BoardStoreRegistry.acquireOffMain`, discard-on-completion).
//
// No open-now flag is cleared here because none was ever set: `setOpenNow` runs after
// the load, so a board that never finished loading is not in the restoration set. The
// ordinary user-close *does* clear it, in `AppModel.closeBoard`, which is the path the
// replacement closure below takes.
openWalk.cancel()
windowController.closeAfterFlush()
}
}
/// Wires the rest of the window, once there is a board to wire it to: the store's write-through,
/// the close flush, the undo stack, the title-bar widget and the toolbar.
///
/// Everything here **carries the store or the session**, which is the whole reason it waits for
/// them; the window-level half ran before the walk (`configureLoadingWindow(recordID:)`).
private func configureWindow(store: BoardStore, recordID: UUID) {
// Filled in here rather than at declaration because the closure captures `openWindow`, an
// environment action; until the board has loaded there is also nothing for Open Card to act
// on, which is exactly what the item's `nil` check reads.
cardOpener.open = openCard
// The registry's live write-through (02-architecture.md § Per-board app state) wired // The registry's live write-through (02-architecture.md § Per-board app state) wired
// the same way `onFrameChanged` just was: a closure that reaches into the registry, // the way `onFrameChanged` was a moment ago in the loading half: a closure that reaches into
// captured weakly on both sides so neither the store nor this closure's own home keeps // the registry, captured weakly on both sides so neither the store nor this closure's own
// the other alive past its window. `syncDisplayState` in `start()` already stamped the // home keeps the other alive past its window. `syncDisplayState` in `start()` already
// values current as of this open, so nothing is fired here immediately; this only fires // stamped the values current as of this open, so nothing is fired here immediately; this
// on the reloads that follow. // only fires on the reloads that follow.
store.displayStateDelegate = { [weak appModel, weak store] in store.displayStateDelegate = { [weak appModel, weak store] in
guard let appModel, let store else { return } guard let appModel, let store else { return }
appModel.boardRegistry.syncDisplayState( appModel.boardRegistry.syncDisplayState(
@@ -264,6 +695,10 @@ struct BoardWindowHost: View {
) )
} }
// **Replacing the loading half's cancel-and-close**: from here the window has a session, so
// a close is the flush (02 § Windows, "Close flushes") and the user-close that clears the
// open-now flag. A slot rather than a branch `onCloseRequested` is one closure, and the
// window that owns it has moved on.
windowController.onCloseRequested = { windowController.onCloseRequested = {
Task { @MainActor in Task { @MainActor in
await appModel.closeBoard(ref: ref, cause: .userClose) await appModel.closeBoard(ref: ref, cause: .userClose)
@@ -276,22 +711,63 @@ struct BoardWindowHost: View {
// nothing rather than a stack with no board behind it. The Edit menu's Undo/Redo rows and // nothing rather than a stack with no board behind it. The Edit menu's Undo/Redo rows and
// the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them // the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them
// up: `NSWindow` validates and crosses them against exactly this manager. // up: `NSWindow` validates and crosses them against exactly this manager.
windowController.boardUndoManager = { appModel.session(for: ref)?.undoManager } windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager }
// The window-title widget (03-board-ui.md § Board popover) **board windows only**, which // The window-title widget (03-board-ui.md § Board popover) **board windows only**, which
// is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and // is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and
// card windows share that machinery and have no board to describe. It goes in after the // card windows share that machinery and have no board to describe. It goes in after the
// load rather than at attach because it carries the store; the controller installs it once, // load rather than at attach because it carries the store; the controller installs it once,
// whichever of the two arrives second. // whichever of the two arrives second.
//
// The git state comes from the **session**, which `start()` began a moment ago, rather than
// from the entitlement or the disk: a board's popover must describe the board as it opened
// (12-editions.md The entitlement, "an open board finishes with the provider it composed";
// 06-history-undo.md Rules, mode is an open-time fact). A `nil` session cannot happen on
// this path `beginSession` precedes `configureWindow` and reads as a board with no
// repository, which is the harmless direction.
//
// **The tier is no longer passed down** (12 PIVOT 2026-08-07): git is tier-independent, so
// every one of these surfaces reads the board's mode and nothing else. `BoardSession.tier`
// still exists and is still recorded it just has no git-facing consumer here.
//
// The settings sheet used to adopt the same fact here, so its two doors could validate on it
// (2026-07-31 2026-08-07). The sheet is retired and the popover is the one configuration
// home, so the git state is handed to exactly one surface again this widget and the
// mid-session transition 06 sanctions (add-git flipping the mode) re-resolves the Git tab's
// postures under it live, with no second reader to disagree.
let session = appModel.session(for: ref)
windowController.installTitlebarAccessory( windowController.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: appModel.styleRecents, presentation: boardInfo) boardInfoTitlebarAccessory(
store: store,
recents: appModel.styleRecents,
git: session?.git,
presentation: boardInfo
)
) )
// The widget above now says the board's name (and, on a git-mode board, its branch)
// itself, so the system title display would only repeat it the card-window seam
// (`CardWindowHost.configureWindow`, `HostedWindowController.hideTitle`), applied here for
// the same reason. `.navigationTitle(windowTitle)` a few lines up in `body` is untouched
// `window.title` keeps feeding the Window menu, Exposé, VoiceOver and restoration; only the
// title bar's own rendering of that string is suppressed.
//
// **After the load, and only after it**, which is why it is not in the loading half above:
// this line and the widget it defers to are one exchange, and a loading window that hid its
// title before the widget existed would carry no name at all against 02's "its chrome
// carrying the registry record's cached title".
windowController.hideTitle()
// The board's customizable toolbar (03-board-ui.md Toolbar) installed here for the // The board's customizable toolbar (03-board-ui.md Toolbar) installed here for the
// accessory's reason exactly: it carries the store, and it is a board window's, not every // accessory's reason exactly: it carries the store, and it is a board window's, not every
// hosted window's. Its search item is the search field's home, and it is what tells // hosted window's. Its search item is the search field's home, and it is what tells
// `boardSearch` whether that home still exists. // `boardSearch` whether that home still exists.
windowController.installToolbar(BoardToolbar.controller(store: store, search: boardSearch)) windowController.installToolbar(BoardToolbar.controller(
store: store,
search: boardSearch,
zoom: appModel.zoom,
appearance: appModel.appearance,
session: appModel.dragSession
))
} }
// MARK: - Closing // MARK: - Closing
@@ -313,3 +789,33 @@ struct BoardWindowHost: View {
} }
} }
} }
// MARK: - BoardOpenWalk
/// The open walk's handle, held for exactly one reason: **W during loading has to be able to cancel
/// it** (02-architecture.md § Launch and window lifecycle).
///
/// A one-field box rather than the `Task` itself in `@State`, because the thing that cancels it is a
/// closure the window controller holds (`onCloseRequested`) and the thing that fills it is the
/// `.task` that starts the walk two places that must agree on one task, which is what a reference
/// type is. `@MainActor` like everything else on this path, so the box needs no synchronisation of
/// its own.
///
/// Cancelling does not stop the walk (see `BoardStoreRegistry.acquireOffMain` for why the walk is
/// deliberately not cooperatively cancellable). It stops the *open*: the task that would have
/// adopted the result never does.
@MainActor
final class BoardOpenWalk {
private var task: Task<Void, Never>?
init() {}
func adopt(_ task: Task<Void, Never>) {
self.task = task
}
func cancel() {
task?.cancel()
}
}
+400 -23
View File
@@ -55,6 +55,26 @@ final class CardWindowSession: CardSessionFlushing {
/// rather than pretending to have written it. /// rather than pretending to have written it.
let body: CardBodyEditSession let body: CardBodyEditSession
/// **This window's own undo stack** 13-native-undo.md Rules' second level (re-ruled
/// 2026-07-31): "a card window owns its own stack for the session it represents ... and
/// `window.undoManager` answers with it".
///
/// It lives here for the comments pane's reason exactly: the close owes the board one coarse step
/// folded from this stack, and a stack held only by the view would be gone by the time the fold
/// ran. Every window gesture registers into it through the store's own methods, which take it as
/// a parameter (`CardWindowUndo`).
let undo = CardWindowUndo()
/// The window's comments pane the thread, the composer's draft buffer, and the one open inline
/// edit session (05-card-window.md The comments column).
///
/// It lives **here** rather than as another `@State` beside the body's handles, and the close
/// flush is why: the pane owes the close three things in a fixed order the inline session's
/// flush, the draft's save, then the `comments/.trash/` purge and this object is the one the
/// coordinator already drives (`CardSessionFlushing`). A pane held only by the view would have its
/// close work run wherever SwiftUI happened to tear the view down.
let comments = CardComments()
/// The close-time save-or-lose moment, over this window's buffer (02-architecture.md § /// The close-time save-or-lose moment, over this window's buffer (02-architecture.md §
/// Write-failure surfacing: "the one modal moment on the write-failure path"). /// Write-failure surfacing: "the one modal moment on the write-failure path").
let bufferGuard: DirtyBufferGuard let bufferGuard: DirtyBufferGuard
@@ -67,9 +87,87 @@ final class CardWindowSession: CardSessionFlushing {
/// window that has not joined its board holds nothing, which is true. /// window that has not joined its board holds nothing, which is true.
var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)? var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)?
/// The Edit buffer's dirty text or a typed-in raw-source outlet see `CardSessionFlushing`. /// The window's raw-source outlet, as the save-or-discard step's two writes: **Apply** (which
/// validates, and answers `false` when it refuses) and **Cancel**. Wired by the host beside
/// `rawSourceHoldsUnsavedText`, and for its reason the outlet is window state living beside
/// this object rather than inside it.
var rawSourceApply: (@MainActor () -> Bool)?
var rawSourceCancel: (@MainActor () -> Void)?
var rawSourceIsActive: (@MainActor () -> Bool)?
/// **Where the close registers this session as one board step**
/// `BoardStore.registerCardSession(_:inCard:retiring:)`, wired by the host for
/// `CardBodyEditSession.save`'s reason: this object is a lifecycle, and it stays testable by
/// having no idea what a board is.
///
/// It answers whether the deferred `comments/.trash/` purge now has an owner see `endSession()`.
/// `nil` (a window that never joined its board) means nothing was registered, so the purge is this
/// object's to run, which is also true.
var registerSessionStep: (@MainActor (CardWindowUndo, @escaping @MainActor () -> Void) -> Bool)?
/// **Where the close gives up this window's ownership of its card's comment trash**
/// `BoardStore.cardWindowDidClose(inCard:)`, wired by the host beside `registerSessionStep`
/// (13-native-undo.md Interaction with the trash, ruled 2026-08-06):
///
/// > "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 ... the close then
/// > settles by the same gate."
///
/// A seam rather than a store reference, `registerSessionStep`'s reason: this object is a
/// lifecycle and stays testable by having no idea what a board is. `nil` a window that never
/// joined its board owns nothing on any store, which is also true.
///
/// Its **order** is the load-bearing part, and `endSession()` is where it is spelled.
var endCardWindowOwnership: (@MainActor () -> Void)?
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
/// holding keystrokes its file has not got see `CardSessionFlushing`.
///
/// **The composer's draft is deliberately not counted.** 05-card-window.md The comments column
/// gives it the opposite posture from every other buffer in this window "Close and quit just
/// proceed no DirtyBufferGuard, nothing to lose" because it is a durable file being edited in
/// place rather than unsaved work. An inline comment edit *is* the ordinary kind, so it counts
/// exactly as the body's does (`CardComments.holdsUnsavedContent`).
var holdsUnsavedContent: Bool { var holdsUnsavedContent: Bool {
body.isDirty || rawSourceHoldsUnsavedText?() == true body.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
}
/// **What a restore or a branch switch asks this window to settle** (06-history-undo.md Rules
/// Undo restore vs open Edit sessions).
///
/// ### The predicate is *open*, not *dirty*
///
/// An **open** Edit session is what needs settling even with a clean buffer, because its ~700 ms
/// saves are on disk and deliberately uncommitted the stage-around rule's whole point so a
/// restore landing over them would either bury text no commit protects or leave the session's
/// next debounced save to write pre-restore bytes back over the restored card, "a Z that visibly
/// doesn't happen". Same reading `CardBodyEditSession.isEditing` records for staging, applied to
/// the same fact.
///
/// An **open raw-source outlet** counts whether or not it has been typed in, and 06 says why: its
/// Apply "would write the *entire* pre-switch `index.md` byte-for-byte onto the new branch's
/// card". A buffer read from before the restore is the hazard; typing is not required for it.
var settlement: CardSessionSettlement? {
CardSessionSettlement(
needsSettling: { [self] in
body.isEditing || body.isDirty || rawSourceIsActive?() == true
},
saveAll: { [self] in
// The Edit session ends with its normal commit "each card's EditPreview flip".
body.endEditSession()
// Apply validates; a refusal is the whole operation's cancellation, and the alert it
// raised is already on the offending window.
guard rawSourceIsActive?() == true else { return true }
return rawSourceApply?() ?? true
},
discard: { [self] in
// The buffer goes back to what disk says; the *disk* goes back to the target state as
// part of the restore itself, which reconciles this card's folder against the working
// tree rather than against HEAD (`GitRestoreOperation.plan`).
body.discardBuffer()
rawSourceCancel?()
}
)
} }
private var hasEnded = false private var hasEnded = false
@@ -101,8 +199,36 @@ final class CardWindowSession: CardSessionFlushing {
// session, "never per save tick" (06-history-undo.md Rules Auto-commit). The debounced // session, "never per save tick" (06-history-undo.md Rules Auto-commit). The debounced
// saves inside the session are ordinary bracketed writes; what makes them one commit is that // saves inside the session are ordinary bracketed writes; what makes them one commit is that
// the committer's own debounce outlives them and this call is where the session is known to // the committer's own debounce outlives them and this call is where the session is known to
// be over. // be over. It is also where the body's *last* fine step joins this window's stack, which is
// why it has to precede the fold below.
body.endEditSession() body.endEditSession()
// **The saves, in the order the comments build fixed**: the inline session's flush, then the
// draft's (`CardComments.endSession`). Both may register their own last fine step, so both
// land before the fold.
comments.endSession()
// **The coarse close step, and the purge it defers** (13-native-undo.md Rules "Window
// close coarsens"; Interaction with the trash).
//
// This is the one place that knows both halves: the window's stack, which is the session's
// net effect, and the `comments/.trash/` purge, which must not run while a board step's undo
// still restores comments out of it. Registering answers whether the step took the purge on
// and a board whose substrate keeps no steps has already run it by the time that answer comes
// back, which is how a git board keeps purging at the close flush without a word about tiers
// here (and there is none to say since 12-editions.md PIVOT 2026-08-07 the substrate is
// the board's mode, not a subscription).
//
// **This window stops owning its card's comment trash first** (ruled 2026-08-06 the
// open-window carve-out). While the window is open the store defers every purge of this
// card's trash, because the entries this session deleted are backed by *this* stack's fine
// steps and the board-stack inventory cannot see them. The close is where that stops being
// true, and it has to stop being true **before** either branch below runs: a no-step close
// that unmarked itself afterwards would defer its own purge into a no-op, and a coarse step
// registered here can retire in the same breath (its registration clears the redo stack).
endCardWindowOwnership?()
let purge: @MainActor () -> Void = { [comments] in comments.purgeTrashNow() }
if registerSessionStep?(undo, purge) != true {
purge()
}
} }
} }
@@ -162,9 +288,18 @@ struct CardWindowHost: View {
/// snapshot the store applies a cache that died with the view would regenerate every thumbnail /// snapshot the store applies a cache that died with the view would regenerate every thumbnail
/// on every reload (`AttachmentThumbnailCache`). /// on every reload (`AttachmentThumbnailCache`).
@State private var thumbnails = AttachmentThumbnailCache() @State private var thumbnails = AttachmentThumbnailCache()
/// This card's commit trail (05-card-window.md History). Held here for `thumbnails`' reason
/// it must survive every snapshot and surfaced to the view only in git mode (`cardHistory`).
@State private var history = CardHistory()
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not /// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
/// flush; cleared by the resolution that lets the close resume. /// flush; cleared by the resolution that lets the close resume.
@State private var isClosePending = false @State private var isClosePending = false
/// The two app-wide comment bits, read here for one reason only: the window's **minimum size**
/// depends on them (`minimumSize`). The panes read them again themselves (`CardWindowView`)
/// two readers of one `UserDefaults` key, which is what `@AppStorage` is for and is cheaper than
/// threading the pair through a view that would then have to publish them back up.
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
private enum Phase { private enum Phase {
case opening case opening
@@ -236,6 +371,11 @@ struct CardWindowHost: View {
// File Add Attachment (A) and File Reveal in Finder's card-window scope reach the // File Add Attachment (A) and File Reveal in Finder's card-window scope reach the
// frontmost card window the same way (11-command-nexus.md). // frontmost card window the same way (11-command-nexus.md).
.focusedSceneValue(\.cardAttachments, attachments) .focusedSceneValue(\.cardAttachments, attachments)
// File Add Comment and the two View-menu comment toggles reach the frontmost card
// window the same way the toggles read their own persisted bits and use this only to
// know a card window is in front at all (11-command-nexus.md scopes all three to the card
// window).
.focusedSceneValue(\.cardComments, session.comments)
// The raw-source outlet's detailed alert, presented over this window a validation // The raw-source outlet's detailed alert, presented over this window a validation
// refusal on Apply, or a file that could not be opened as source. It hangs *here* rather // refusal on Apply, or a file that could not be opened as source. It hangs *here* rather
// than inside the editor because the second of those fires while source mode is still // than inside the editor because the second of those fires while source mode is still
@@ -277,8 +417,39 @@ struct CardWindowHost: View {
.onDisappear { finish() } .onDisappear { finish() }
} }
/// **This card's commit trail, or nothing at all** (05-card-window.md History).
///
/// `nil` is the section's absence rule, read from the board's own git state rather than from a
/// flag: a mode other than `git` means a board the app manages no history for, on any tier. The
/// question stopped having a tier half at 12-editions.md PIVOT 2026-08-07 every session
/// composes a git state now, so what decides the section is whether *this board's* history is
/// git-backed. The object is held by this host so it survives every snapshot, `thumbnails`'
/// reason exactly.
private var cardHistory: CardHistory? {
guard appModel.session(for: ref.board)?.gitMode == .git else { return nil }
return history
}
/// What a trail re-read depends on: this card, and the number of commits the board has landed.
///
/// The count is the committer's own (`GitAutoCommitter.commitCount`), which advances for every
/// commit the app makes the debounced ones, the launch catch-up, and a restore's. A foreign
/// commit an agent made *itself* moves HEAD without touching it; the trail then refreshes at the
/// next commit or the next open, which is the same freshness bound the popover's branch line has
/// and a great deal cheaper than polling HEAD from a sidebar.
private func historyReloadKey(store: BoardStore) -> String {
let commits = appModel.session(for: ref.board)?.git?.committer?.commitCount ?? 0
return "\(ref.cardID)#\(commits)"
}
/// **The minimum grows only while the comments pane is beside the body** (05-card-window.md
/// Composition) which is the whole reason the stacked mount exists, so a narrow display keeps
/// the minimum it always had.
private var minimumSize: CGSize { private var minimumSize: CGSize {
CardWindowMetrics.minimumSize(bodyPointSize: CardWindowMetrics.bodyPointSize) CardWindowMetrics.minimumSize(
bodyPointSize: CardWindowMetrics.bodyPointSize,
commentsColumn: showComments && commentsBesideBody
)
} }
@ViewBuilder @ViewBuilder
@@ -301,12 +472,23 @@ struct CardWindowHost: View {
// predicate too ("the attachment row's /Remove shares the posture"). // predicate too ("the attachment row's /Remove shares the posture").
isEditable: !store.isReadOnly, isEditable: !store.isReadOnly,
attachments: attachments, attachments: attachments,
comments: session.comments,
thumbnails: thumbnails, thumbnails: thumbnails,
undo: session.undo,
history: cardHistory,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id), fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in onToggleTask: { offset, checked in
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked) store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
} }
) )
// **The trail, re-read when a commit lands** (05 History). The id is the pair of facts
// the answer depends on: which card this is, and how many commits this board has made
// so the section refreshes after the app's own commits, after an agent's that the watcher
// committed, and after a Z's restore, with nothing here knowing what a committer is.
.task(id: historyReloadKey(store: store)) {
guard let cardHistory else { return }
await cardHistory.load(boardRoot: store.rootURL, cardFolderName: ref.cardID)
}
// **The listing is the snapshot's, republished** `Card.attachments`, which the loader // **The listing is the snapshot's, republished** `Card.attachments`, which the loader
// fills from `attachments/`'s top-level files in Finder order. Every write in the // fills from `attachments/`'s top-level files in Finder order. Every write in the
// section is bracketed, so the reload that refreshes this arrives by itself and the // section is bracketed, so the reload that refreshes this arrives by itself and the
@@ -319,9 +501,40 @@ struct CardWindowHost: View {
// folder rename moves the board, and rows resolving against where it used to be // folder rename moves the board, and rows resolving against where it used to be
// would open nothing. // would open nothing.
attachments.cardFolder = folder attachments.cardFolder = folder
session.comments.cardFolder = folder
}
// The announcer's subject, re-derived from every snapshot for the folder's reason: a card
// renamed mid-session is announced under its new name ("New comment on 'card'").
.onChange(of: placement.card.title.value, initial: true) { _, title in
session.comments.cardTitle = title
} }
.onChange(of: store.isReadOnly, initial: true) { _, locked in .onChange(of: store.isReadOnly, initial: true) { _, locked in
attachments.isEditable = !locked attachments.isEditable = !locked
session.comments.isEditable = !locked
}
// **The thread re-reads on every landed reload** (05 The comments column: "the pane
// reloads its thread from the same FSEvents stream").
//
// *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the
// store's observable surface publishes counters and a `BoardModel` it does not vend the
// changed paths, and comments are outside the snapshot entirely (01-storage-format.md
// § Enhanced schema), so there is nothing to filter *on* here.
//
// **`landedReloads`, not `snapshotGeneration`**, and for that same sentence's reason: a
// comment arriving changes the tree and leaves the model value-equal, and a value-equal
// landing skips the snapshot assignment (blessed 2026-07-31). Watching the applied counter
// would mean the one kind of change this pane exists to notice is the one kind it would
// sleep through.
// Re-reading one card's thread is a handful of small files and happens only while a card
// window is open; filtering would mean either widening the store's surface to carry paths,
// or the pane keeping its own watcher a second stream over the same tree, which the
// one-way flow rules out. The path shape is read on the other side of the re-read instead,
// where there *are* two pictures to compare: the pane diffs its threads and consumes the
// ledger's comment receipts through `CommentPath.classify` to tell a foreign arrival from
// its own echo (`CardComments.reload`). `initial:` is deliberately absent: `start()`
// already did the opening read, after the residue sweep that has to precede it.
.onChange(of: store.landedReloads) { _, _ in
session.comments.reload()
} }
} else { } else {
// Nothing to render and nothing worth animating: this window is on its way out. // Nothing to render and nothing worth animating: this window is on its way out.
@@ -396,7 +609,7 @@ struct CardWindowHost: View {
} }
let store: BoardStore let store: BoardStore
do throws(BoardLoadError) { do throws(BoardLoadFailure) {
store = try appModel.storeRegistry.acquire(ref.boardURL) store = try appModel.storeRegistry.acquire(ref.boardURL)
} catch { } catch {
Self.logger.error("card window could not acquire its board: \(error.description, privacy: .public)") Self.logger.error("card window could not acquire its board: \(error.description, privacy: .public)")
@@ -409,6 +622,37 @@ struct CardWindowHost: View {
phase = .open(store) phase = .open(store)
configureSession(store: store) configureSession(store: store)
configureWindow() configureWindow()
openCommentThread(store: store)
}
/// **The card window's open, comment-side** the crash-residue sweep, then the thread read
/// (01-storage-format.md § Enhanced schema: "crash residue sweeps at the next card-window open,
/// armed-then-cleared like every heal memo").
///
/// It runs from `start()` rather than from a `.task` on the pane, and the reason is the pane's
/// own visibility: Show Comments off means no pane at all, and the residue of a session that died
/// mid-delete must still be swept it is the app's leftovers, not a feature of the pane. The
/// same goes for the close purge, which rides the session's end for the same reason.
///
/// The pane's two window-scoped facts are set *before* the read, because both of them are things
/// the read's results are resolved against: the folder every comment's attachments hang off, and
/// whether the lock is on.
///
/// **And the window takes ownership of its card's comment trash here** (13-native-undo.md
/// Interaction with the trash, ruled 2026-08-06): "an open card window is itself an owner of its
/// card's comment trash". From this line until `CardWindowSession.endSession` gives it back, a
/// purge of this card's trash a foreign step's retirement, another window's close defers
/// entirely, because the fine steps backing this session's deletes live on a stack the board's
/// inventory cannot see. The sweep below is unaffected and runs as it always has: it is gated on
/// `backedContent`, and a window that has not made a gesture yet holds nothing that gate is
/// missing.
private func openCommentThread(store: BoardStore) {
store.cardWindowDidOpen(inCard: ref.cardIdentity)
if case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) {
session.comments.cardFolder = Self.cardFolder(root: store.rootURL, placement: placement)
}
session.comments.isEditable = !store.isReadOnly
session.comments.open()
} }
/// Points this window's Edit buffer at its card, and the mode flip at the buffer. /// Points this window's Edit buffer at its card, and the mode flip at the buffer.
@@ -427,15 +671,19 @@ struct CardWindowHost: View {
guard let store else { return .vanished } guard let store else { return .vanished }
return store.writeCardBody(inCard: cardID, body: text) return store.writeCardBody(inCard: cardID, body: text)
} }
// The session's one undo step, at the EditPreview flip (13-native-undo.md Rules). Weakly, Self.configureUndo(session, store: store, cardID: cardID)
// `save`'s rule: a session ending after the board window has gone registers nothing rather
// than resurrecting a released store and the board's stack died with it anyway.
session.body.registerUndo = { [weak store] priorBody, newBody in
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody)
}
bodyPresentation.flushEdits = { [session] in bodyPresentation.flushEdits = { [session] in
session.body.endEditSession() session.body.endEditSession()
} }
bodyPresentation.beginEdits = { [session] in
session.body.beginEditSession()
}
// **No stage-around wire here any more** (06-history-undo.md Rules Auto-commit, widened
// 2026-07-31 recorded because its absence is the change): the EditPreview flip used to open
// and close the committer's exclusion, and the unit is now the *window*, so the exclusion is
// opened by `AppModel.registerCardWindow` and released by `unregisterCardWindow` after the
// session's own last writes. A flip that still moved it would un-hold the folder in the middle
// of a session whose comment posts and draft saves are supposed to be inside one commit.
Self.configureRawSource( Self.configureRawSource(
rawSource, rawSource,
body: session.body, body: session.body,
@@ -446,7 +694,113 @@ struct CardWindowHost: View {
// The other half of the outlet's wiring: the session answers for this window's unsaved // The other half of the outlet's wiring: the session answers for this window's unsaved
// content, and the outlet is the half that does not live inside it (`CardWindowSession`). // content, and the outlet is the half that does not live inside it (`CardWindowSession`).
session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText } session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText }
// The save-or-discard step's half of the same wiring (06-history-undo.md Branch switching):
// Save All *applies* an open outlet validation included, so a refusal cancels the whole
// operation and Discard leaves it without writing.
session.rawSourceIsActive = { [rawSource] in rawSource.isActive }
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
Self.configureAttachments(attachments, store: store, cardID: cardID) Self.configureAttachments(attachments, store: store, cardID: cardID)
Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
}
/// Points this window's session at **its own undo stack** the three seams the two-level model
/// is made of (13-native-undo.md Rules, re-ruled 2026-07-31).
///
/// 1. the body Edit session's one step registers on *this window's* stack, not the board's;
/// 2. the window's Undo/Redo disable under the board's read-only lock, and the stack survives it;
/// 3. the close folds the window's stack into one coarse board step, which then owes the deferred
/// `comments/.trash/` purge.
///
/// The store is captured **weakly**, `configureSession`'s rule: a session ending after the board
/// window has gone registers nothing rather than resurrecting a released store and a window with
/// no board keeps the purge itself, which is what the `false` says.
///
/// `static`, and taking every collaborator as a parameter, for `configureComments`' reason: which
/// stack a gesture lands on is invisible in a running window until it is wrong, and this shape is
/// what lets a test drive the real wiring rather than a re-typed copy of it.
static func configureUndo(_ session: CardWindowSession, store: BoardStore, cardID: ItemID) {
session.body.registerUndo = { [weak store, undo = session.undo] priorBody, newBody in
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody, on: undo)
}
session.undo.isReadOnly = { [weak store] in store?.isReadOnly ?? false }
session.registerSessionStep = { [weak store] undo, purge in
store?.registerCardSession(undo, inCard: cardID, retiring: purge) ?? false
}
// The close half of the open-window carve-out (13 Interaction with the trash, ruled
// 2026-08-06). Its open half is `CardWindowHost.openCommentThread`, beside the residue sweep;
// the pair has to be wired from the two places the window already calls into the store, and
// these are they. A released store owns nothing to give up, which is what `weak` says.
session.endCardWindowOwnership = { [weak store] in store?.cardWindowDidClose(inCard: cardID) }
}
/// Points the comments pane at its card **the one place every comment gesture learns which card
/// it acts on** (05-card-window.md The comments column).
///
/// Every seam is one of the store's own bracketed methods, unchanged, which is the same rule the
/// attachments section keeps: there is deliberately no comment write of this window's own to keep
/// in step with the store's, so a post made here and a post made by anything else take one path
/// one bracket, one undo step, one commit shape.
///
/// The store is captured **weakly**, `configureSession`'s rule: a save still landing after the
/// board window has gone should write nothing rather than resurrect a released store. A `nil`
/// store answers what a vanished card answers an empty thread, a save that did not land, a
/// delete that did not happen which is exactly what the pane's own guards expect.
///
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: the
/// target resolution is invisible in a running window until it is wrong, and this shape is what
/// lets a test drive the real wiring rather than a re-typed copy of it.
///
/// - Parameter undo: **this window's stack** where every comment gesture's fine step lands
/// (13-native-undo.md Rules two levels). Not optional and not defaulted: a comments pane
/// only ever exists inside a card window, so a call with no window would be a call with no
/// answer to which stack it meant.
static func configureComments(
_ comments: CardComments,
store: BoardStore,
cardID: ItemID,
on undo: CardWindowUndo
) {
comments.readThread = { [weak store] in store?.commentThread(inCard: cardID) ?? .empty }
comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) }
comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) }
comments.purgeTrash = { [weak store] in store?.purgeCommentTrash(inCard: cardID) }
// Detection is the thread read's, the repair is the store's batch, and the notice is the
// banner surface's "the relocation-style warning-tone notice names the repair". This
// closure is only the join, which is why it is three lines and lives here rather than on
// either side of it.
comments.displaceSquatters = { [weak store] squatters in
guard let store else { return }
store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters))
}
comments.deleteComment = { [weak store] id in
store?.deleteComment(id, inCard: cardID, on: undo) ?? false
}
comments.editComment = { [weak store] id, body in
store?.editComment(id, inCard: cardID, body: body) ?? false
}
comments.registerCommentEdit = { [weak store] id, prior, new in
store?.registerCommentEdit(id, inCard: cardID, priorBody: prior, newBody: new, on: undo)
}
comments.importAttachments = { [weak store] urls, target in
store?.importCommentAttachments(urls, inCard: cardID, target: target)
}
comments.removeAttachment = { [weak store] name, target in
store?.removeCommentAttachment(named: name, inCard: cardID, target: target)
}
comments.composer.save = { [weak store] text in
store?.saveCommentDraft(inCard: cardID, body: text)
}
comments.composer.post = { [weak store] in
store?.postComment(inCard: cardID, on: undo)
}
// The announcer's gate: which of this thread's changes the app itself wrote, consumed once per
// reload (10-accessibility.md "app-mediated echoes never announce", per comment). A store
// that has gone vouches for nothing, which is the conservative direction and also the one the
// announcement cannot reach anyway a released store has no window left to speak in.
comments.vouchedComments = { [weak store] in
store?.vouchedComments(inCard: cardID) ?? []
}
} }
/// Points the attachments section at its card **the one place Add Attachment and Remove /// Points the attachments section at its card **the one place Add Attachment and Remove
@@ -546,14 +900,30 @@ struct CardWindowHost: View {
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments) CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
) )
// **The board's stack, not one of this window's own** (13-native-undo.md Rules: "not // **No title in the title bar** the card's name is shown as part of the card's body
// per-window: every window over a board (board window, its card windows) shares the store // instead (the large-title text at the top of the body column, `bodyColumn`), so the chrome
// and shares the stack"). Same closure shape as the board window's, and deliberately the // does not say it twice. `window.title` itself is untouched `.navigationTitle(windowTitle)`
// same object: Z with a card window in front crosses the board step the user last made, // on this view still sets it every time the card renames or a new card's window opens so
// wherever they made it. The card's *text* surfaces are untouched by this the body editor // the Window menu, Mission Control/Exposé, VoiceOver and state restoration all keep naming
// and the raw-source editor each vend their own manager to the responder chain, which is // this window correctly; only the title *bar's* rendering of that string is suppressed
// what keeps typing undo out of the board's stack (06-history-undo.md Undo routing). // (`HostedWindowController.hideTitle`). Board windows call the same thing now, for the same
windowController.boardUndoManager = { appModel.session(for: ref.board)?.undoManager } // reason, once their board-popover widget has a name of its own to say
// (`BoardWindowHost.configureWindow`) only the restore-bootstrap window still keeps
// AppKit's `.visible` default.
windowController.hideTitle()
// **This window's own stack** (13-native-undo.md Rules two levels, re-ruled 2026-07-31
// superseding the shared-stack wiring): "a card window owns its own stack for the session it
// represents ... and `window.undoManager` answers with it (standard per-window AppKit
// scoping)". Z with this window in front walks the gestures made *here*, newest first, and
// when they run out it beeps "no fall-through: exhausting the window's stack ... never
// reaches board history" (06-history-undo.md Undo routing). What board history gets is the
// one coarse step this session registers when the window closes.
//
// The card's *text* surfaces are untouched by this the body editor and the raw-source
// editor each vend their own manager to the responder chain, which is what keeps typing undo
// above either stack (06 Undo routing, unchanged).
windowController.windowUndoManager = { [session] in session.undo.manager }
windowController.onAttach = { window in windowController.onAttach = { window in
if let recordID, if let recordID,
@@ -638,15 +1008,22 @@ struct CardWindowHost: View {
/// Leaves the session and lets the store go. /// Leaves the session and lets the store go.
/// ///
/// The release rides **behind** the session's end rather than beside it: a session that has /// The release rides **behind** the session's end rather than beside it: a session that has
/// something to commit (m6) needs the store it is committing through, and a refcount that hit /// something to commit needs the store it is committing through, and a refcount that hit zero
/// zero first would have stopped the watcher underneath it. In m4 the hook is a no-op and the /// first would have stopped the watcher underneath it.
/// ordering costs one run-loop turn the point is that the shape is already right. ///
/// **Unregistering rides behind it too** (06-history-undo.md Rules Auto-commit: "window close
/// flushes the session as one commit"), which is new in this milestone and is the whole ordering
/// the one-commit rule rests on: unregistering is what releases the committer's stage-around, and
/// releasing it before `endSession()` had written the body's last keystrokes, posted the draft and
/// purged `comments/.trash/` would leave a debounce free to fire over a half-finished session
/// two commits where the design promises one. The board's own close flush drives the same two
/// steps in the same order through `CloseFlushCoordinator`, one window at a time.
private func finish() { private func finish() {
guard case let .open(store) = phase else { return } guard case let .open(store) = phase else { return }
phase = .closing phase = .closing
appModel.unregisterCardWindow(ref)
Task { @MainActor in Task { @MainActor in
await session.endSession() await session.endSession()
appModel.unregisterCardWindow(ref)
appModel.storeRegistry.release(store) appModel.storeRegistry.release(store)
} }
} }
+30 -22
View File
@@ -3,15 +3,13 @@ import Foundation
import UniformTypeIdentifiers import UniformTypeIdentifiers
// MARK: - The clipboard type // MARK: - The clipboard type
//
extension UTType { // `UTType.laneworkClipboard` what a Lanework copy puts on the pasteboard under its own type, the
// JSON `ClipboardManifest` below (04-interactions.md Clipboard: "the pasteboard carries a JSON
/// What a Lanework copy puts on the pasteboard under its own type the JSON `ClipboardManifest` // manifest + plain text"). Declared in `Info.plist` beside the two drag types, for the same reason
/// (04-interactions.md Clipboard: "the pasteboard carries a JSON manifest + plain text"). // those are: a payload nobody has declared is a payload the system will not carry. The constant
/// Declared as an exported type in `Info.plist` beside the two drag types, for the same reason // itself lives with its two siblings in `PasteboardTypes.swift`, exported once by the app that
/// those are: a payload nobody has declared is a payload the system will not carry. // owns it.
static let laneworkClipboard = UTType(exportedAs: "dev.rzen.indie.kanban.clipboard")
}
// MARK: - The manifest // MARK: - The manifest
@@ -19,13 +17,15 @@ extension UTType {
/// ///
/// **It is self-describing twice over**, and both halves earn their keep: /// **It is self-describing twice over**, and both halves earn their keep:
/// ///
/// - `copyID` ties the pasteboard to a staging directory `<Application Support>//Clipboard/<copyID>/`, /// - `copyID` ties the pasteboard to a staging directory `<Application Support>/Clipboard/<copyID>/`,
/// the full folder snapshots a paste reproduces byte-for-byte from and to a pending cut. It is /// the full folder snapshots a paste reproduces byte-for-byte from and to a pending cut. It is
/// also the whole of "the snapshot survives relaunch exactly as long as the pasteboard still points /// also the whole of "the snapshot survives relaunch exactly as long as the pasteboard still points
/// at it": a sweep keeps the one directory this id names and collects every other. /// at it": a sweep keeps the one directory this id names and collects every other.
/// - Each `Entry` embeds the item's complete `index.md` text, so a paste still lands when the /// - Each `Entry` embeds the item's complete `index.md` text as **identification metadata**
/// snapshot is missing or unreadable "the staging-less fallback: content intact, attachments /// (04-interactions.md Clipboard, re-ruled 2026-07-29): menu validation, the refusal's wording,
/// absent", announced by a banner rather than discovered later. /// and the plain-text flavor read it. It is emphatically **not** a materialization source a paste
/// whose staged snapshot is missing or unreadable refuses whole and writes nothing, because "an item
/// arrives whole index, attachments, loose files or not at all".
/// ///
/// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than /// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and /// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
@@ -69,15 +69,23 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
public var folder: String public var folder: String
/// The title as written, or `nil` for an untitled item "Untitled" is a rendering, never a /// The title as written, or `nil` for an untitled item "Untitled" is a rendering, never a
/// value (03-board-ui.md § Card face). Feeds the plain-text representation and the degraded /// value (03-board-ui.md § Card face). Feeds the plain-text representation and the refused
/// paste's banner. /// paste's banner, which names the offending entry from exactly this.
public var title: String? public var title: String?
/// The complete `index.md` at copy time the staging-less fallback's source bytes. /// The complete `index.md` at copy time **identification metadata, never materialized**
/// (see the type comment). Kept because it is what lets the app answer "what was on the
/// clipboard" without touching the staging store: the plain-text flavor and a refusal's wording
/// both come from here, and both have to work when the snapshot is exactly what is missing.
public var index: String public var index: String
/// How many files the item's own `attachments/` held. Zero for a lane, which has none; a /// How many files the item's own `attachments/` held. Zero for a lane, which has none; a
/// lane's attachments are its cards' and are counted there. /// lane's attachments are its cards' and are counted there.
///
/// Identification metadata like the rest of the entry. It used to feed the degraded paste's
/// loss accounting ("Pasted 'Fix login' without its 3 attachments"), which is retired with the
/// degraded paste itself an item now arrives whole or not at all, so there is no partial
/// arrival left to count.
public var attachmentCount: Int public var attachmentCount: Int
/// A **lane** entry's cards, index text and all "a lane entry embeds its cards' too, /// A **lane** entry's cards, index text and all "a lane entry embeds its cards' too,
@@ -85,9 +93,9 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
/// ///
/// **Exactly the lane's cards**, which needs no filter: "a lane carries exactly its cards /// **Exactly the lane's cards**, which needs no filter: "a lane carries exactly its cards
/// the trash is board-level, so there is nothing lane-nested to strip or carry" /// the trash is board-level, so there is nothing lane-nested to strip or carry"
/// (04-interactions.md Drag and drop, resettled 2026-07-28), and the fallback only ever /// (04-interactions.md Drag and drop, resettled 2026-07-28). Like the lane's own `index`, the
/// materializes a copy a cut's move carries the real folder whole and never comes near this /// cards' text is identification metadata: it describes what the copy held, and nothing
/// array. So the embedded set is exactly what a fallback paste should produce. /// materializes from it.
public var cards: [Card] public var cards: [Card]
/// One card inside a copied lane. /// One card inside a copied lane.
@@ -105,9 +113,9 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
} }
} }
/// Everything a fallback paste of this entry would leave behind its own attachments plus, /// Every file this entry's subtree carried in an `attachments/` its own plus, for a lane, its
/// for a lane, its cards'. /// cards'. Identification metadata; nothing gates on it since the degraded paste retired.
public var lostAttachmentCount: Int { public var totalAttachmentCount: Int {
attachmentCount + cards.reduce(0) { $0 + $1.attachmentCount } attachmentCount + cards.reduce(0) { $0 + $1.attachmentCount }
} }
+145 -48
View File
@@ -12,10 +12,12 @@ import os
/// ///
/// The pasteboard carries a small JSON manifest plus a plain-text rendering; the *content* whole /// The pasteboard carries a small JSON manifest plus a plain-text rendering; the *content* whole
/// folder trees, attachments and strays and all is **staged** under /// folder trees, attachments and strays and all is **staged** under
/// `<Application Support>/<bundle id>/Clipboard/<copyID>/`, so a paste reproduces the item /// `<Application Support>/Clipboard/<copyID>/`, so a paste reproduces the item byte-for-byte across
/// byte-for-byte across boards rather than reconstructing it from a summary. The manifest's embedded /// boards rather than reconstructing it from a summary. The manifest's embedded `index.md` per entry
/// `index.md` per entry is the fallback when a snapshot is missing, and a fallback paste is **loud**: /// is **identification metadata only** menu validation, the refusal's wording, the plain-text
/// a banner names exactly what was lost. /// flavor and never a materialization source: a paste whose staged snapshot is missing or
/// unreadable **refuses whole and writes nothing** (04-interactions.md Clipboard, re-ruled
/// 2026-07-29 Finder's invariant: an item arrives whole or not at all).
/// ///
/// ### The staging lifecycle, settled /// ### The staging lifecycle, settled
/// ///
@@ -102,22 +104,17 @@ public final class ClipboardStore {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "clipboard") private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "clipboard")
/// `~/Library/Application Support/<bundle id>/Clipboard/`, beside the board registry the same /// `<Application Support>/Clipboard/`, beside the board registry the same home, for the same
/// container convention, for the same reason (02-architecture.md § Per-board app state, "App-wide /// reason (`AppStateHome`; 02-architecture.md § Per-board app state, "App-wide state has the same
/// state has the same home"). /// home").
public static var defaultStagingRoot: URL { public static var defaultStagingRoot: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first AppStateHome.directory.appendingPathComponent("Clipboard", isDirectory: true)
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library/Application Support", isDirectory: true)
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"
return support
.appendingPathComponent(bundleIdentifier, isDirectory: true)
.appendingPathComponent("Clipboard", isDirectory: true)
} }
/// The app builds one of these with the system pasteboard and the real staging directory; a test /// The app builds one of these with the system pasteboard and the real staging directory; a test
/// passes its own of each, for the reason `BoardRegistry` takes a storage URL at all injecting /// passes its own of each, for the reason `BoardRegistry` takes a storage URL at all injecting
/// them is how a suite stays out of Application Support *and* off the machine's one pasteboard. /// them is how a suite stays out of the real Application Support home *and* off the machine's one
/// pasteboard.
/// ///
/// **The launch sweep is here** (04: "a sweep at launch and on each copy"): a fresh store reads /// **The launch sweep is here** (04: "a sweep at launch and on each copy"): a fresh store reads
/// the pasteboard once and collects every staged tree it no longer names, which is exactly the /// the pasteboard once and collects every staged tree it no longer names, which is exactly the
@@ -174,9 +171,10 @@ public final class ClipboardStore {
/// The order is the contract: capture from the snapshot (main actor, no I/O every item's /// The order is the contract: capture from the snapshot (main actor, no I/O every item's
/// `index.md` is already parsed into the snapshot and `FrontmatterDocument.serialized()` returns /// `index.md` is already parsed into the snapshot and `FrontmatterDocument.serialized()` returns
/// it verbatim), schedule the snapshots behind it, then write the pasteboard, then sweep. The /// it verbatim), schedule the snapshots behind it, then write the pasteboard, then sweep. The
/// pasteboard is written *before* the copies land, which is safe precisely because the manifest /// pasteboard is written *before* the copies land, which is safe because a paste **awaits the same
/// carries the fallback text: a paste that somehow beat the chain would still materialize the /// chain** (`paste(into:)`): it can never read a half-written snapshot, so it never sees a tree the
/// right items. /// staging has not finished. This used to lean on the manifest's fallback text instead; with
/// refuse-don't-degrade the chain is the whole guarantee, and it is the stronger one.
private func write(from store: BoardStore, cut: Bool) { private func write(from store: BoardStore, cut: Bool) {
guard let capture = Self.capture(selection: store.selection, snapshot: store.snapshot) else { return } guard let capture = Self.capture(selection: store.selection, snapshot: store.snapshot) else { return }
@@ -229,8 +227,16 @@ public final class ClipboardStore {
/// the text (04 Grammar). The field consumes the selector natively, so this guard is belt over /// the text (04 Grammar). The field consumes the selector natively, so this guard is belt over
/// braces but a board command that stayed armed under an editor would be exactly the kind of /// braces but a board command that stayed armed under an editor would be exactly the kind of
/// fall-through 04 is careful about. /// fall-through 04 is careful about.
///
/// **A mixed trash selection closes it this is one of the two exits the kind guard moved to**
/// (04-interactions.md The trash, ruled 2026-07-31 with kind-blind trash 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". It is also what keeps
/// `capture`'s single `kind` honest: the manifest names one payload type, and a set spanning both
/// never reaches it.
public func canCopy(from store: BoardStore) -> Bool { public func canCopy(from store: BoardStore) -> Bool {
guard !store.isEditingInline else { return false } guard !store.isEditingInline else { return false }
guard !SelectionGrammar.mixesKinds(store.selection, in: store.snapshot) else { return false }
return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil
} }
@@ -340,9 +346,8 @@ public final class ClipboardStore {
// doing nothing. // doing nothing.
guard payload?.copyID == manifest.copyID else { return } guard payload?.copyID == manifest.copyID else { return }
store.transient.noteUserCreation()
if let move = armedMove(for: manifest) { if let move = armedMove(for: manifest) {
store.transient.noteUserCreation()
let sources = move.folders.map(BoardStore.ItemSource.folder) let sources = move.folders.map(BoardStore.ItemSource.folder)
switch plan { switch plan {
case let .cards(target): case let .cards(target):
@@ -365,31 +370,29 @@ public final class ClipboardStore {
return return
} }
// The copy path the staged snapshot per entry, or the embedded `index.md` where that // **The copy path's preflight: refuse, never degrade** (04-interactions.md Clipboard,
// snapshot is missing or unreadable. Mixed is legal and is the honest outcome of a partial // re-ruled 2026-07-29). Every entry must have its staged snapshot on disk *before* anything is
// staging failure: the entries that have snapshots arrive whole. // materialized the first one that does not refuses the whole paste, names itself from the
// manifest's metadata, and writes nothing at all. All-or-nothing for the whole paste, which is
// the copies-are-transactions posture (01-storage-format.md § Frontmatter) read one level up:
// the transaction is the gesture, not the entry.
let stagingDir = stagingRoot.appendingPathComponent(manifest.copyID, isDirectory: true) let stagingDir = stagingRoot.appendingPathComponent(manifest.copyID, isDirectory: true)
var sources: [BoardStore.ItemSource] = [] var sources: [BoardStore.ItemSource] = []
var losses: [BannerCenter.AttachmentLoss] = []
for entry in manifest.entries { for entry in manifest.entries {
let staged = stagingDir.appendingPathComponent(entry.folder, isDirectory: true) let staged = stagingDir.appendingPathComponent(entry.folder, isDirectory: true)
if FileManager.default.fileExists( guard FileManager.default.fileExists(
atPath: staged.appendingPathComponent(BoardLoader.indexFileName).path atPath: staged.appendingPathComponent(BoardLoader.indexFileName).path
) { ) else {
sources.append(.folder(staged)) // The offending entry, named and the destination's search is left exactly as it was.
continue // "Any user-initiated creation on the board clears the query" (04 Search) is a rule
} // about creations, and this paste created nothing; the preflight therefore runs *before*
sources.append(.text(index: entry.index, cards: entry.cards.map(\.index))) // `noteUserCreation`, so a refusal costs the user neither content nor their filter.
// "A degraded paste is loud, never silent a one-shot banner names exactly what was store.banners.postRefusedPaste(title: entry.title, stagedAt: staged.path)
// lost." An entry with no attachments lost nothing its content is intact and its bytes return
// are the source bytes so it contributes no row.
if entry.lostAttachmentCount > 0 {
losses.append(BannerCenter.AttachmentLoss(
title: entry.title,
attachments: entry.lostAttachmentCount
))
} }
sources.append(.folder(staged))
} }
store.transient.noteUserCreation()
// A card copied out of the trash needs nothing done to it on arrival: it carries no // A card copied out of the trash needs nothing done to it on arrival: it carries no
// `deleted:` key, because there is no such key any more (03-board-ui.md § Trash, resettled // `deleted:` key, because there is no such key any more (03-board-ui.md § Trash, resettled
@@ -411,7 +414,6 @@ public final class ClipboardStore {
normalizingLooseFiles: true normalizingLooseFiles: true
) )
} }
store.banners.postDegradedPaste(losses)
} }
/// The armed cut's surviving originals, in flatten order and as folders under the **source** /// The armed cut's surviving originals, in flatten order and as folders under the **source**
@@ -478,9 +480,18 @@ public final class ClipboardStore {
let destination: URL let destination: URL
} }
/// Appends this copy's snapshots to the staging chain. Best-effort per item: one that fails to /// Appends this copy's snapshots to the staging chain. Best-effort per item, and the *consequence*
/// copy simply falls back to the manifest's embedded `index.md` at paste time, which is the /// of a failure changed with the refuse-don't-degrade ruling: an item whose snapshot never landed
/// degraded paste the banner already has words for. /// makes the next paste **refuse whole**, naming it (`perform`'s preflight), rather than
/// materializing it hollow from the manifest's embedded `index.md`. Failing to stage is therefore
/// as loud as it should be, one gesture later.
/// **Staging is a copy boundary**, so `comments/.trash/` does not survive it (01-storage-format.md
/// § Enhanced schema: "stripped at every copy boundary (clipboard staging, Duplicate, Save as
/// Template)"). Stripped from the *snapshot* rather than skipped during it, because the snapshot is
/// one monolithic `copyItem` and stripped through the Writer's own call so the rule is one
/// function (`BoardWriter.stripCommentTrash`), not a second reading of it here. Best-effort like
/// the copy above it: a snapshot that could not be tidied is still a snapshot, and the paste that
/// materializes from it strips again at its own boundary.
private func stage(_ jobs: [StagingJob], into stagingDir: URL) { private func stage(_ jobs: [StagingJob], into stagingDir: URL) {
enqueue { [jobs, stagingDir] in enqueue { [jobs, stagingDir] in
guard (try? FileManager.default.createDirectory( guard (try? FileManager.default.createDirectory(
@@ -489,6 +500,7 @@ public final class ClipboardStore {
)) != nil else { return } )) != nil else { return }
for job in jobs { for job in jobs {
try? FileManager.default.copyItem(at: job.source, to: job.destination) try? FileManager.default.copyItem(at: job.source, to: job.destination)
try? BoardWriter.stripCommentTrash(under: job.destination, operation: .copy(title: nil))
} }
} }
} }
@@ -508,14 +520,74 @@ public final class ClipboardStore {
enqueue { await Self.prune(root, keeping: keep) } enqueue { await Self.prune(root, keeping: keep) }
} }
/// Where a tree goes to die: a hidden sibling inside the staging root, so a removal is **two
/// steps, the first of them atomic**.
///
/// Hidden (`.`-prefixed) on purpose `prune` lists with `.skipsHiddenFiles`, so this folder is
/// invisible to the sweep that owns it and can never be mistaken for a staged copy.
///
/// `nonisolated` because `prune` is: the sweep runs off the main actor by design, and a constant
/// has no isolation to need.
private nonisolated static let sweepFolderName = ".sweeping"
/// The sweep, written **claim-then-delete** rather than delete-in-place.
///
/// There is one app and macOS runs one instance of it, so this is not the concurrency guard it was
/// written as (12-editions.md App-side state, re-ruled 2026-07-30 there is no sibling app to
/// race). It is kept because what it buys is cheap and still true of one process:
///
/// 1. **The claim is a rename, and a rename is atomic.** A tree either leaves the staging root
/// whole or stays there whole it is never briefly *visible half-removed*, which is the one
/// state a reader could misread. That covers a crash mid-delete, and it covers the developer's
/// own second copy launched with `open -n`, which shares this container because it is the same
/// app.
/// 2. **A missing entry means already swept, never an error.** Every failure here is swallowed:
/// the listing is stale by the time it is walked, and a tree that vanished between the two is
/// precisely the outcome asked for.
///
/// Leftovers in `.sweeping/` are collected on the next pass. A crash between the rename and the
/// delete therefore costs disk until the next sweep, which is the same guarantee the staging store
/// already gives about its own orphans.
private nonisolated static func prune(_ root: URL, keeping keep: String?) async { private nonisolated static func prune(_ root: URL, keeping keep: String?) async {
let sweepFolder = root.appendingPathComponent(sweepFolderName, isDirectory: true)
guard let entries = try? FileManager.default.contentsOfDirectory( guard let entries = try? FileManager.default.contentsOfDirectory(
at: root, at: root,
includingPropertiesForKeys: nil, includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles] options: [.skipsHiddenFiles]
) else { return } ) else { return }
var claimed: [URL] = []
for entry in entries where entry.lastPathComponent != keep { for entry in entries where entry.lastPathComponent != keep {
try? FileManager.default.removeItem(at: entry) // Created lazily: a sweep with nothing to collect must not leave a folder behind as proof
// it ran.
if claimed.isEmpty {
try? FileManager.default.createDirectory(at: sweepFolder, withIntermediateDirectories: true)
}
let claim = sweepFolder.appendingPathComponent(UUID().uuidString, isDirectory: true)
guard (try? FileManager.default.moveItem(at: entry, to: claim)) != nil else {
// Gone, or claimed by another pass. Either way it is not ours to delete and nothing
// is wrong.
continue
}
claimed.append(claim)
}
for claim in claimed {
try? FileManager.default.removeItem(at: claim)
}
// Anything a previous pass claimed and did not finish a crash between the claim and the
// delete. Best-effort, and an empty or missing folder is nothing to do.
if let stragglers = try? FileManager.default.contentsOfDirectory(
at: sweepFolder,
includingPropertiesForKeys: nil,
options: []
) {
for straggler in stragglers {
try? FileManager.default.removeItem(at: straggler)
}
try? FileManager.default.removeItem(at: sweepFolder)
} }
} }
@@ -551,13 +623,15 @@ public final class ClipboardStore {
/// ///
/// **The order is `SelectionGrammar.order`'s**, which is already the right answer for every /// **The order is `SelectionGrammar.order`'s**, which is already the right answer for every
/// (container, kind) pair: flatten order for board cards, left-to-right for lanes, and the /// (container, kind) pair: flatten order for board cards, left-to-right for lanes, and the
/// trash's own `order` for trash cards. Deriving it here would be a second definition of an order /// trash's own rank order for its cards and its lane rows alike. Deriving it here would be a
/// the app already states once. /// second definition of an order the app already states once.
/// ///
/// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line /// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line
/// span, so `serialized()` on an untouched document returns the file's bytes exactly which /// span, so `serialized()` on an untouched document returns the file's bytes exactly which makes
/// makes the manifest's fallback text genuinely *the source bytes* while costing C no file I/O /// the manifest's embedded text a faithful record of the item while costing C no file I/O at all,
/// at all, even for a lane carrying two hundred cards. /// even for a lane carrying two hundred cards. It is **identification metadata**, not a
/// materialization source (see the type comment): the refusal's wording and the plain-text flavor
/// read it, and nothing writes it.
static func capture( static func capture(
selection: ItemReferenceSet, selection: ItemReferenceSet,
snapshot: BoardModel snapshot: BoardModel
@@ -588,6 +662,29 @@ public final class ClipboardStore {
for card in snapshot.trash { for card in snapshot.trash {
addCard(card, at: .trashCard(card.id)) addCard(card, at: .trashCard(card.id))
} }
// **A trashed lane row copies and cuts like any other lane** (04-interactions.md The
// trash: "a trashed lane pastes after the anchor lane (the lane-paste rule above,
// verbatim)"), which makes X here the keyboard-native restore at the lane level.
//
// **No `cards` in the entry, and that is the opaque unit showing through**: a trashed
// lane's subtree is deliberately not in the snapshot (`TrashedLane`), so there is nothing
// here to describe it with and nothing is lost by that, because the manifest's embedded
// text is identification metadata only and the *content* comes from the staged folder,
// which is copied whole, cards and all. An entry that guessed at a card list would be the
// one place in the app claiming to know what an opaque unit holds.
for lane in snapshot.trashedLanes {
subjects[lane.id] = Subject(
id: lane.id,
path: .trashLane(lane.id),
entry: ClipboardManifest.Entry(
id: lane.id.rawValue,
folder: lane.id.rawValue,
title: lane.title.value,
index: lane.document.serialized(),
attachmentCount: 0
)
)
}
case .board: case .board:
for lane in snapshot.lanes { for lane in snapshot.lanes {
if kind == .lane { if kind == .lane {
+12
View File
@@ -42,11 +42,23 @@ public protocol CardSessionFlushing: AnyObject {
/// template that silently missed those keystrokes would break 09's never-misses-keystrokes /// template that silently missed those keystrokes would break 09's never-misses-keystrokes
/// guarantee, which outranks the item's availability. /// guarantee, which outranks the item's availability.
var holdsUnsavedContent: Bool { get } var holdsUnsavedContent: Bool { get }
/// **This window's answers to the save-or-discard step** (06-history-undo.md Rules Undo
/// restore vs open Edit sessions; Branch switching), or `nil` for a window with nothing a
/// wholesale tree operation could disturb.
///
/// Beside `holdsUnsavedContent` and deliberately not folded into it: that property answers "would
/// a *template copy* miss keystrokes", which is a read; this one carries the two writes Save
/// All and Discard that a restore or a branch switch needs the window to perform before it can
/// run on a settled tree. Same window, two different questions, and collapsing them would give
/// the settle step the draft-composer exemption `holdsUnsavedContent` deliberately makes.
var settlement: CardSessionSettlement? { get }
} }
public extension CardSessionFlushing { public extension CardSessionFlushing {
func endSession() async {} func endSession() async {}
var holdsUnsavedContent: Bool { false } var holdsUnsavedContent: Bool { false }
var settlement: CardSessionSettlement? { nil }
} }
// MARK: - CloseFlushCoordinator // MARK: - CloseFlushCoordinator
-31
View File
@@ -1,31 +0,0 @@
import IndieAbout
/// **Base Lanework's About-box configuration one of the two places base names Lanework Pro.**
///
/// 12-editions.md Quiet signposts: "Base presents as a complete app, not a demo: Pro is named
/// in exactly two places one line in the About box, and the contextual popover line on `.git`
/// boards. Nothing on the welcome screen, nothing in banners." The popover line is
/// `BoardGitNote`'s; this is the About line, riding as the second line of the copyright text
/// `IndieAbout` renders `copyrightText` as one multi-line `Text`, so the signpost is literally a
/// line in the box, no custom layout.
///
/// **The Pro target does not compile this file** (project.yml excludes it and compiles
/// `KanbanPro/Edition/EditionAbout.swift` instead): Pro's About has no signpost the app it
/// would point to is the one showing the box and falls back to the Info.plist copyright. The
/// difference is a file one target builds and the other does not, per the edition split's
/// no-`#if` doctrine (12-editions.md Targets).
enum EditionAbout {
/// The About window's content: version/build/date from the stamped Info.plist
/// (`update_build_info.sh` CFBundleVersion, BuildDate, BuildHash), the version line
/// opening the bundled CHANGELOG.md, and the bundled ISC LICENSE.md as the one document
/// link. Both documents are authored to the Apple inline-Markdown subset see the files'
/// own single-line paragraphs.
static var configuration: AppInfoConfiguration {
AppInfoConfiguration(
copyrightText: "© 2026 rzen\nLanework Pro adds git-backed board history and sync.",
documents: [.license(extension: "md")],
changelogDocument: .changelog()
)
}
}
+33 -6
View File
@@ -54,14 +54,36 @@ struct FutureCommand: View {
/// "disabled in the board window board search is a live filter, not a cursor" /// "disabled in the board window board search is a live filter, not a cursor"
/// (11-command-nexus.md). /// (11-command-nexus.md).
/// ///
// m6-card-window: joins `FindCommand` in the Edit menu once the card window's find-in-text exists /// ### They are live for exactly one find, and disabled for the others on purpose
// (05-card-window.md). Both rows are unconditionally disabled here rather than reading `boardStore` ///
// to prove "board window" disables them: there is no card-window find session anywhere yet for /// The card window has three finds (`CardWindowFindRoute`), and two of them are **`NSTextFinder`**'s
// either validation branch to check. /// the body's and an authoring editor's. `NSTextView` already answers G and G through the responder
/// chain, and an *enabled* menu item's key equivalent fires before the responder chain is consulted,
/// so a row that claimed the chord unconditionally would break the stepping it exists to provide. So
/// these validate on the **thread** find alone the one find with no responder to fall through to,
/// because its bar is the app's own and stay disabled everywhere else, which lets the platform's
/// stepping keep working where the platform owns the find.
///
/// `.disabled` on the rows rather than a guard in the action, for the reason every menu row here
/// wears its validation: a key equivalent that fires and does nothing is a chord the user cannot tell
/// from a broken one.
struct FindSteppingCommands: View { struct FindSteppingCommands: View {
@FocusedValue(\.cardComments) private var comments
/// The row's validation, as a value a test can hold: the pane's find bar is up, which is the only
/// state in which this app owns G.
static func isEnabled(_ comments: CardComments?) -> Bool {
comments?.find.isShowing == true
}
var body: some View { var body: some View {
FutureCommand(title: "Find Next", key: "g", modifiers: .command) Button("Find Next") { comments?.find.step(forward: true) }
FutureCommand(title: "Find Previous", key: "g", modifiers: [.shift, .command]) .keyboardShortcut("g", modifiers: .command)
.disabled(!Self.isEnabled(comments))
Button("Find Previous") { comments?.find.step(forward: false) }
.keyboardShortcut("g", modifiers: [.shift, .command])
.disabled(!Self.isEnabled(comments))
} }
} }
@@ -80,9 +102,14 @@ struct FindSteppingCommands: View {
// outright on mode `none` / repo-nested boards once that section exists (05-card-window.md, // outright on mode `none` / repo-nested boards once that section exists (05-card-window.md,
// 07-sync-collab.md). It remains unconditionally disabled here the sidebar reserves the section's // 07-sync-collab.md). It remains unconditionally disabled here the sidebar reserves the section's
// place (`CardWindowView.historySlot`) but draws nothing, so there is still no surface to focus. // place (`CardWindowView.historySlot`) but draws nothing, so there is still no surface to focus.
/// The comments pane's two rows join them (11-command-nexus.md lists Show Comments and Comments
/// Beside Body between Edit Body and Raw Source): both are live, both are app-wide persisted bits,
/// and both are scoped to the card window (`ShowCommentsCommand`, `CommentsBesideBodyCommand`).
struct CardViewCommands: View { struct CardViewCommands: View {
var body: some View { var body: some View {
EditBodyCommand() EditBodyCommand()
ShowCommentsCommand()
CommentsBesideBodyCommand()
RawSourceCommand() RawSourceCommand()
FutureCommand(title: "History") FutureCommand(title: "History")
} }
+24
View File
@@ -0,0 +1,24 @@
import Foundation
import UniformTypeIdentifiers
// MARK: - The family pasteboard types
/// **The three drag and clipboard types the app owns**, declared once with `UTType(exportedAs:)` to
/// match the `UTExportedTypeDeclarations` in `Kanban/Info.plist`. `exportedAs` is a claim of
/// ownership the system checks against the running app's plist, and one app making it is the whole
/// story there is no importing sibling to keep in step (12-editions.md Distribution, re-ruled
/// 2026-07-30: the types are "declared and exported once, by the one app no ownership twins").
///
/// What each type *is* stays documented at its payload `DragPayload` for cards and lanes,
/// `ClipboardManifest` for the clipboard type.
extension UTType {
/// A drag carrying board **cards** see `DragPayload`.
static let laneworkCards = UTType(exportedAs: "dev.rzen.indie.kanban.cards")
/// A drag carrying board **lanes** see `DragPayload`.
static let laneworkLanes = UTType(exportedAs: "dev.rzen.indie.kanban.lanes")
/// A Lanework copy's pasteboard manifest see `ClipboardManifest`.
static let laneworkClipboard = UTType(exportedAs: "dev.rzen.indie.kanban.clipboard")
}
+36 -18
View File
@@ -12,8 +12,9 @@ import os
/// Window menu whose only job is to run the pass and then dismiss itself. It exists for a few /// Window menu whose only job is to run the pass and then dismiss itself. It exists for a few
/// hundred milliseconds and never draws. /// hundred milliseconds and never draws.
/// ///
/// It is presented **only** when there is something to open (`KanbanApp` decides, via /// It is presented at **every** launch it is the app's one reliable presenter (see `KanbanApp`'s
/// `LaunchPlan.presentsBootstrap`), so the ordinary launch-to-welcome path never creates it. /// bootstrap scene for the macOS 26 behavior that forced this), so even the plain launch-to-welcome
/// path runs through it: the pass finds nothing flagged and opens welcome itself.
/// ///
/// ### What the pass does /// ### What the pass does
/// ///
@@ -63,28 +64,36 @@ struct RestoreBootstrapView: View {
private func restore() async { private func restore() async {
// Captured directly rather than waiting for `CaptureOpenWindow`'s `onAppear`: this task is // Captured directly rather than waiting for `CaptureOpenWindow`'s `onAppear`: this task is
// the app's first act, and `openBoard` needs the action now. // the app's first act, and `openBoard` needs the action now. The count is a cold Finder-open
appModel.captureWindowActions(open: openWindow, dismiss: dismissWindow) // that arrived before this window did a board already on its way to the screen, which the
// pass below must count as an open or it would put welcome up beside the user's document.
let replayedOpens = appModel.captureWindowActions(open: openWindow, dismiss: dismissWindow)
switch plan { switch plan {
case .uiTestFixture: case .uiTestFixture:
openFixtureBoard() openFixtureBoard()
case .restoreBoards, .welcome: case .restoreBoards, .welcome:
// `.welcome` never presents this window, so it cannot arrive here and if a future // `.welcome` arrives here by design this window presents at every launch, because it is
// launch path let it, the restoration pass is the harmless answer: it finds nothing // the app's one reliable presenter (see `KanbanApp`'s bootstrap scene) and the pass is
// flagged and shows welcome, which is what `.welcome` asked for anyway. // its answer: nothing is flagged, so it shows welcome, which is what `.welcome` asked
restoreFlaggedBoards() // for.
restoreFlaggedBoards(openedAlready: replayedOpens)
} }
dismissWindow(id: WindowID.restoreBootstrap) dismissWindow(id: WindowID.restoreBootstrap)
} }
private func restoreFlaggedBoards() { private func restoreFlaggedBoards(openedAlready: Int) {
var attempted = 0 var attempted = openedAlready
for board in appModel.boardRegistry.restorables() { for board in appModel.boardRegistry.restorables() {
switch board { switch board {
case let .available(_, url): case let .available(_, url):
appModel.openBoard(at: url) // **The one restored open in the app** (01-storage-format.md § Malformed input, the
// decision surface): a board that fails here keeps today's retire-to-welcome-row
// landing "launch never chains dialogs", and nobody is sitting in front of a
// restoration waiting to repair four boards at once. The row's retry click is the
// attended open that then shows the surface.
appModel.openBoard(at: url, origin: .restored)
attempted += 1 attempted += 1
case let .unavailable(record): case let .unavailable(record):
Self.logger.error("a flagged board could not be restored — its bookmark no longer resolves") Self.logger.error("a flagged board could not be restored — its bookmark no longer resolves")
@@ -100,20 +109,29 @@ struct RestoreBootstrapView: View {
} }
} }
/// The audit suite's board: built here, opened through the same `openBoard` every other path /// The UI suites' board: built here, opened through the same `openBoard` every other path uses,
/// uses, so it registers, bookmarks and titles itself exactly like a board the user opened. /// so it registers, bookmarks and titles itself exactly like a board the user opened.
/// ///
/// **A failure lands on welcome as an ordinary launch failure**, with the fixture's own path on /// **Which board is the launch arguments' to say** (`UITestLaunch.variant`), and this method does
/// it. That is deliberate: a suite whose fixture failed to build would otherwise audit an empty /// not care: the malformed variant is built and opened exactly like the other two, and its
/// screen and pass, which is the one outcome an accessibility gate must never produce. /// failure arrives one layer down as the *loader's*. It opens **attended**, like every other
/// board a person asks for, so its refusal transforms the loading window into the decision surface
/// (`BoardWindowHost.handleWalkFailure`) rather than retiring which is precisely what the
/// fail-fast UI pass is there to see. Special-casing it here would replace the behaviour under
/// test with a behaviour about the fixture.
///
/// **A failure to *build* lands on welcome as an ordinary launch failure**, with the fixture's own
/// path on it. That is deliberate: a suite whose fixture failed to build would otherwise audit an
/// empty screen and pass, which is the one outcome an accessibility gate must never produce.
private func openFixtureBoard() { private func openFixtureBoard() {
let variant = UITestLaunch.variant
do { do {
let url = try UITestLaunch.materializeFixtureBoard() let url = try UITestLaunch.materializeFixtureBoard(variant)
appModel.openBoard(at: url) appModel.openBoard(at: url)
} catch { } catch {
Self.logger.error("the UI-test fixture board could not be built: \(error.localizedDescription, privacy: .public)") Self.logger.error("the UI-test fixture board could not be built: \(error.localizedDescription, privacy: .public)")
appModel.recordLaunchFailure( appModel.recordLaunchFailure(
path: UITestLaunch.fixtureBoardURL.path, path: UITestLaunch.fixtureBoardURL(for: variant).path,
message: "The UI-test fixture board could not be built: \(error.localizedDescription)" message: "The UI-test fixture board could not be built: \(error.localizedDescription)"
) )
appModel.showWelcome() appModel.showWelcome()
+281
View File
@@ -0,0 +1,281 @@
import AppKit
import Foundation
// MARK: - Vocabulary
/// What the user chose at the save-or-discard step (06-history-undo.md Branch switching: "**Save
/// All** ends every session with its normal commit , **Discard** reverts buffers and uncommitted
/// saves to HEAD, **Cancel** keeps the current branch and the sessions").
public enum SessionSettleChoice: Sendable, Equatable {
case saveAll
case discard
case cancel
}
/// What the gate concluded the only thing the operation behind it branches on.
public enum SessionSettleOutcome: Sendable, Equatable {
/// Nothing needed settling, or everything did and did. The tree is settled; run.
case proceed
/// The user chose Cancel. "Cancel keeps everything" nothing was written, nothing reverted.
case cancelled
/// **Save All met a raw-source buffer that would not validate.** "Since Apply validates, a buffer
/// that fails validation cancels the whole switch with focus on the offending window, nothing
/// half-switched" (06 Branch switching). The payload is that window's session id, already
/// focused by the gate.
case failed(String)
}
// MARK: - What one card window offers the step
/// **A card window's three answers to the save-or-discard step**, handed over as closures.
///
/// A type of its own rather than three members on `CardSessionFlushing` because it is optional as a
/// unit: a window with nothing settleable in it has no settlement, and the gate should not have to
/// ask three questions to find that out. `nil` is also every window in a build with no card session
/// at all, which is what the protocol's default supplies.
@MainActor
public struct CardSessionSettlement {
/// Whether this window is holding state a wholesale tree operation would disturb.
public let needsSettling: @MainActor () -> Bool
/// Ends the Edit session with its normal commit and *applies* the raw buffer. `false` means the
/// raw buffer failed validation.
public let saveAll: @MainActor () -> Bool
/// Reverts the Edit buffer and leaves raw source without writing.
public let discard: @MainActor () -> Void
public init(
needsSettling: @escaping @MainActor () -> Bool,
saveAll: @escaping @MainActor () -> Bool,
discard: @escaping @MainActor () -> Void
) {
self.needsSettling = needsSettling
self.saveAll = saveAll
self.discard = discard
}
}
// MARK: - One settleable session
/// **A card window, as the save-or-discard step sees it** three closures and the card it is over.
///
/// A value of closures rather than a protocol over `CardWindowSession`, for `CloseFlushCoordinator`'s
/// reason exactly: what this gate is *about* is a decision procedure, and a procedure written against
/// a live window is verifiable only by running the app. The production values come from the card
/// windows; a test builds them from a counter.
@MainActor
public struct SettleableSession {
/// The window's identity `CardWindowRef.cardID` is what production passes. Opaque to the gate,
/// and only ever handed back to `focus`.
public let id: String
/// The **card's folder name** its id, which is its folder on disk (01-storage-format.md).
///
/// Matched component-wise against the paths a wholesale operation would write, which is what
/// makes the match survive a lane move: a card's own folder component never changes, only the
/// lane above it (`GitHistoryWalk.path(_:isInsideFolderNamed:)`, the same trick, same reason).
public let cardFolderName: String
/// Whether this session is holding state a wholesale tree operation would disturb: unsaved
/// keystrokes, an **open** Edit session whose ~700 ms saves are deliberately uncommitted, or a
/// raw-source outlet that is open at all.
public let needsSettling: @MainActor () -> Bool
/// **Save All** for this one session: end the Edit session with its normal commit, and *apply*
/// the raw buffer. `false` means the raw buffer failed validation the whole operation is off.
public let saveAll: @MainActor () -> Bool
/// **Discard** for this one session: revert the buffer and leave raw source without writing. The
/// on-disk uncommitted saves are reverted by the operation itself, which is comparing this card's
/// folder against the working tree rather than against HEAD for exactly that reason
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
public let discard: @MainActor () -> Void
public init(
id: String,
cardFolderName: String,
needsSettling: @escaping @MainActor () -> Bool,
saveAll: @escaping @MainActor () -> Bool,
discard: @escaping @MainActor () -> Void
) {
self.id = id
self.cardFolderName = cardFolderName
self.needsSettling = needsSettling
self.saveAll = saveAll
self.discard = discard
}
}
// MARK: - SessionSettleGate
/// **The save-or-discard step**, as one reusable decision procedure (06-history-undo.md Rules
/// Undo restore vs open Edit sessions; Branch switching).
///
/// ### One machinery, two callers, by design
///
/// 06 does not describe two gates. It describes the branch-switch step and then hands undo the same
/// one by name: "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)." So this object is written for both from the start; the undo provider is its first
/// caller and the branch controls will be its second, passing the paths a checkout would write
/// instead of the paths a restore would.
///
/// ### The diff decides whether it appears at all
///
/// "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."
/// That is `settle(touching:)`'s first line, and it is why the gate takes paths rather than a
/// yes/no: a modal that appeared on every Z because *some* window somewhere was in Edit would be a
/// different, much worse feature.
///
/// ### Why the ask is a closure
///
/// Presenting three buttons is AppKit's job and cannot be asserted without a display. The rule this
/// file exists to hold which sessions are asked about, what each answer does to them, and that a
/// failing raw buffer stops everything with focus on the offender is decidable from values, so the
/// presentation is a seam and the decision is testable.
@MainActor
public struct SessionSettleGate {
/// Every open card session on this board, read live: a window can open or close between the
/// moment an operation starts and the moment it asks.
public var sessions: () -> [SettleableSession]
/// Presents the three-button step and answers what the user chose.
public var ask: () async -> SessionSettleChoice
/// Brings one session's window forward the "focus on the offending window" half of the
/// validation-failure rule.
public var focus: (String) -> Void
public init(
sessions: @escaping () -> [SettleableSession],
ask: @escaping () async -> SessionSettleChoice,
focus: @escaping (String) -> Void = { _ in }
) {
self.sessions = sessions
self.ask = ask
self.focus = focus
}
// MARK: The decision
/// Settles whatever the operation's paths reach, and answers whether it may run.
///
/// - Parameter paths: board-root-relative paths the operation would write.
public func settle(touching paths: Set<String>) async -> SessionSettleOutcome {
await decide(over: Self.reached(by: paths, among: sessions()))
}
/// **Settles every open session, whatever the operation writes** the branch switch's gate
/// (06 Branch switching).
///
/// The path filter above is the *restore's* narrowing and belongs to it alone: "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". A branch switch has no such
/// property. It moves the whole tree at once, and the raw-source hazard 06 singles out "an
/// unsettled raw buffer its Apply later writes the *entire* pre-switch `index.md` byte-for-byte
/// onto the new branch's card" is about the buffer belonging to the old branch, not about
/// whether the checkout happened to rewrite that card. So this asks about every session that is
/// holding something, and about no path at all.
public func settleAll() async -> SessionSettleOutcome {
await decide(over: sessions())
}
private func decide(over candidates: [SettleableSession]) async -> SessionSettleOutcome {
let candidates = candidates.filter { $0.needsSettling() }
guard !candidates.isEmpty else { return .proceed }
switch await ask() {
case .cancel:
return .cancelled
case .discard:
for session in candidates { session.discard() }
return .proceed
case .saveAll:
for session in candidates {
guard session.saveAll() else {
// "Nothing half-switched": the sessions saved before this one are saved, which is
// an ordinary Save and loses nothing, but the operation itself does not run.
focus(session.id)
return .failed(session.id)
}
}
return .proceed
}
}
/// **Which sessions a set of paths reaches** pure, and the whole of "the diff touches a session
/// card".
///
/// Component-exact folder matching, so a card whose id happens to be a prefix of another's cannot
/// drag that other card's window into the step.
public static func reached(
by paths: Set<String>,
among sessions: [SettleableSession]
) -> [SettleableSession] {
guard !paths.isEmpty else { return [] }
return sessions.filter { session in
paths.contains { GitHistoryWalk.path($0, isInsideFolderNamed: session.cardFolderName) }
}
}
}
// MARK: - The presented step
/// **The three buttons**, as an `NSAlert` the production `SessionSettleGate.ask`.
///
/// One of 02-architecture.md's sanctioned modal moments, and it is modal for `DirtyBufferGuard`'s
/// reason exactly: the operation behind it cannot proceed until the user has decided what happens to
/// text no commit protects, and there is no non-modal shape for a question whose three answers are
/// mutually exclusive and immediate.
///
/// The wording is 06's own vocabulary. The default is **Cancel**, deliberately: a Return pressed
/// reflexively at a dialog nobody read must be the answer that changes nothing, and both other
/// answers write.
public enum SessionSettleStep {
public static let title = "Unsaved card edits"
public static let message = """
Restoring an earlier state would change cards you are editing. \
Save them, discard the changes, or cancel.
"""
/// The same three buttons, asked for the other caller. **One sentence differs, deliberately**: the
/// consequence a user is deciding about is not the same one a restore would change the cards
/// being edited, while a switch takes every card to a different branch and a step that described
/// the wrong operation would be a worse modal than no wording at all.
public static let branchSwitchMessage = """
Switching branches would replace the cards you are editing. \
Save them, discard the changes, or cancel.
"""
@MainActor
public static func ask(message: String = message) async -> SessionSettleChoice {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = title
alert.informativeText = message
// Order matters for the key equivalents AppKit assigns: the first button takes Return, so
// Cancel leads and the two writing answers follow. Escape reaches Cancel either way.
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: "Save All")
alert.addButton(withTitle: "Discard")
switch alert.runModal() {
case .alertSecondButtonReturn: return .saveAll
case .alertThirdButtonReturn: return .discard
default: return .cancel
}
}
}
+12 -6
View File
@@ -27,7 +27,7 @@ import os
/// ///
/// ### Reveal in Finder, and how fresh the list is /// ### Reveal in Finder, and how fresh the list is
/// ///
/// 09 keeps the Application Support store honest with "a **Reveal in Finder** affordance in the /// 09 keeps the app-side store honest with "a **Reveal in Finder** affordance in the
/// template chooser": the button beside the header, which **creates the store and then reveals it** /// template chooser": the button beside the header, which **creates the store and then reveals it**
/// (`TemplateEngine.createUserStore`) the store's two minters are Save as Template and this, so a /// (`TemplateEngine.createUserStore`) the store's two minters are Save as Template and this, so a
/// user who has never saved one still gets a folder to drop a board into rather than a Finder window /// user who has never saved one still gets a folder to drop a board into rather than a Finder window
@@ -183,10 +183,16 @@ struct TemplateChooserView: View {
) { ) {
ForEach(rows) { row in ForEach(rows) { row in
TemplateCard(row: row, isSelected: row.id == selected?.id) TemplateCard(row: row, isSelected: row.id == selected?.id)
.onTapGesture { selection = row.id } // A double click is how a chooser is answered without reaching for a
// The list convention welcome's recents use, for the same reason: a // button (welcome's list convention). One recogniser branching on
// double click is how a chooser is answered without reaching for a button. // `PointerClick.count`, never a second two-tap one stacked, it delays
.onTapGesture(count: 2) { choose() } // the single click by the whole double-click interval; simultaneous, it
// still holds clicks on a view with no drag source (`PointerClick`). The
// first click of the pair selects the tile, which is also what aims
// `choose()` at the clicked row.
.onTapGesture {
if PointerClick.count > 1 { choose() } else { selection = row.id }
}
// **Tab-reachable, and a button to the accessibility tree** the tile is // **Tab-reachable, and a button to the accessibility tree** the tile is
// the chooser's one act of choosing, so it has to be a control rather than a // the chooser's one act of choosing, so it has to be a control rather than a
// decorated rectangle that happens to answer clicks (10-accessibility.md // decorated rectangle that happens to answer clicks (10-accessibility.md
@@ -285,7 +291,7 @@ struct TemplateChooserView: View {
/// ///
/// A store that cannot be created is logged and *still* revealed at its parent by /// A store that cannot be created is logged and *still* revealed at its parent by
/// `activateFileViewerSelecting`, which is the honest failure: something is wrong with /// `activateFileViewerSelecting`, which is the honest failure: something is wrong with
/// Application Support, and the user is standing where they can see it. /// the app-side store, and the user is standing where they can see it.
private func revealUserStore() { private func revealUserStore() {
let store = TemplateEngine.userStore let store = TemplateEngine.userStore
do { do {
+61 -26
View File
@@ -8,7 +8,8 @@ import os
/// ///
/// A template is a board folder, so discovery is `BoardLoader.load` and instantiation is a tree copy /// A template is a board folder, so discovery is `BoardLoader.load` and instantiation is a tree copy
/// plus the Writer's own remint-and-restamp machinery (`BoardWriter.remintDescendants`, /// plus the Writer's own remint-and-restamp machinery (`BoardWriter.remintDescendants`,
/// `stampCopiedDescendant`, `updateIndex`). There is no template schema, no template catalog in /// `checkCopiedDescendantsAreStampable`, `applyCopyContract`, `stampCopiedDescendant`,
/// `updateIndex`). There is no template schema, no template catalog in
/// Swift, and no second copy path which is 09's "dogfood" clause and 02-architecture.md's single /// Swift, and no second copy path which is 09's "dogfood" clause and 02-architecture.md's single
/// write door, both held by having nothing here to hold them with. /// write door, both held by having nothing here to hold them with.
/// ///
@@ -95,7 +96,7 @@ enum TemplateEngine {
// MARK: - Where templates live // MARK: - Where templates live
/// The store folder's name in both locations the bundle's and Application Support's. /// The store folder's name in both locations the bundle's and the app's own.
static let storeFolderName = "Templates" static let storeFolderName = "Templates"
/// The bundled store: `<app bundle>/Contents/Resources/Templates/`, holding one board folder per /// The bundled store: `<app bundle>/Contents/Resources/Templates/`, holding one board folder per
@@ -105,23 +106,21 @@ enum TemplateEngine {
Bundle.main.resourceURL?.appendingPathComponent(storeFolderName, isDirectory: true) Bundle.main.resourceURL?.appendingPathComponent(storeFolderName, isDirectory: true)
} }
/// The user store: `<Application Support>/<bundle id>/Templates/`, beside the board registry and /// The user store: `<Application Support>/Templates/`, beside the board registry and the
/// the clipboard staging directory 09's settled location ("Application Support inside the /// clipboard's staging store 09's settled location ("Application Support inside the app
/// app container friction-free sandbox writes, no location ceremony"), spelled the way every /// container friction-free sandbox writes, no location ceremony"; 02-architecture.md
/// other app-wide store in this app spells it (`ClipboardStore.defaultStagingRoot`, /// § Per-board app state, "App-wide state has the same home").
/// `BoardRegistry`; 02-architecture.md § Per-board app state, "App-wide state has the same home"). ///
/// This store needs none of the machinery the registry does: a template is a folder inside the
/// app's own container, so there is no bookmark to mint and nothing to grant. Spelled through
/// `AppStateHome` like every other app-wide store (`ClipboardStore.defaultStagingRoot`,
/// `BoardRegistry.defaultStorageURL`), so all three move together if the home ever does.
/// ///
/// **Named, never created here.** Discovery of a store that does not exist is an empty list, not /// **Named, never created here.** Discovery of a store that does not exist is an empty list, not
/// a directory the app made on the off-chance: the store is minted by the first Save as Template, /// a directory the app made on the off-chance: the store is minted by the first Save as Template,
/// and by Reveal in Finder, both of which are 09's other cards. /// and by Reveal in Finder, both of which are 09's other cards.
static var userStore: URL { static var userStore: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first AppStateHome.directory.appendingPathComponent(storeFolderName, isDirectory: true)
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library/Application Support", isDirectory: true)
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"
return support
.appendingPathComponent(bundleIdentifier, isDirectory: true)
.appendingPathComponent(storeFolderName, isDirectory: true)
} }
// MARK: - Discovery // MARK: - Discovery
@@ -143,12 +142,17 @@ enum TemplateEngine {
/// The loader's error is handed back whole rather than reworded: the chooser's unloadable row /// The loader's error is handed back whole rather than reworded: the chooser's unloadable row
/// shows "the loader's fail-fast specifics" (09 Why this format), and a second taxonomy of /// shows "the loader's fail-fast specifics" (09 Why this format), and a second taxonomy of
/// board problems is precisely what a files-first app must not grow. /// board problems is precisely what a files-first app must not grow.
///
/// **One defect of the walk's aggregate the first** (`BoardLoadFailure.primary`). The chooser's
/// unloadable row is one line about a folder the user is not being invited to repair: a template
/// store is picked from, not opened, and the decision surface exists for the board being opened.
/// Saying which thing is wrong first is the whole of what that row can act on.
static func load(templateAt url: URL, origin: BoardTemplate.Origin) -> Result<BoardTemplate, BoardLoadError> { static func load(templateAt url: URL, origin: BoardTemplate.Origin) -> Result<BoardTemplate, BoardLoadError> {
do { do {
let result = try BoardLoader.load(boardRoot: url) let result = try BoardLoader.load(boardRoot: url)
return .success(BoardTemplate(url: url, origin: origin, model: result.model)) return .success(BoardTemplate(url: url, origin: origin, model: result.model))
} catch { } catch {
return .failure(error) return .failure(error.primary)
} }
} }
@@ -375,27 +379,49 @@ enum TemplateEngine {
/// The born half, on the tree already at the destination: fresh identities, fresh stamps, the /// The born half, on the tree already at the destination: fresh identities, fresh stamps, the
/// chosen title, and the loose-file normalization an import boundary owes. /// chosen title, and the loose-file normalization an import boundary owes.
/// ///
/// **The root is strict and the descendants are lenient**, which is `BoardWriter.copyItem`'s /// **An instantiation is a copy transaction**, which is `BoardWriter.copyItem`'s posture since
/// split for its reason: the root *must* be rewritten (it carries the title the user just typed), /// 2026-07-29 and for its reason: the whole tree is preflighted for stampability before a single
/// so a template whose own `index.md` cannot be edited in place refuses the create while a /// folder is reminted, and a template carrying one readable-but-uneditable card refuses the create
/// nested card that is readable-but-uneditable is copied byte-verbatim and simply not stamped, /// whole, naming that card (01-storage-format.md § Frontmatter: "preflights the entire subtree and
/// because failing a whole create over one hand-dropped flow mapping would be hostile. Its stale /// refuses whole, loudly, naming the offending item never a partial copy, never a silently
/// `modified-by` surviving is the self-reported-provenance honest limit 01 § Frontmatter already /// unstamped descendant").
/// acknowledges. ///
/// This retired the former root-strict/descendants-lenient split, which copied such a card
/// byte-verbatim and skipped its stamp. Two things were wrong with the kindness: an unstamped
/// descendant keeps a `modified-by` naming somebody who never touched this board, and since the
/// tracker sever joined the copy contract a live `remote` claim on an object the new board has
/// no relationship with. "Proceed partially, lose a little" is never a verdict (01's leniency
/// doctrine).
///
/// **The preflight runs on the destination, not the template**, deliberately: the copy has already
/// applied its top-level exclusions, so `.trash/`'s cards which are not part of what a template
/// instantiates cannot refuse a create they were never going to appear in. Nothing is lost by
/// preflighting a step later, because `instantiate`'s construct-then-clean removes the whole
/// destination on any throw (09-templates.md's atomicity).
private static func mintIdentitiesAndStamps( private static func mintIdentitiesAndStamps(
at root: URL, at root: URL,
title: String, title: String,
operation: WriteOperation operation: WriteOperation
) throws(Failure) { ) throws(Failure) {
do throws(BoardWriteError) { do throws(BoardWriteError) {
// Before the remint, so a refusal names folders by the paths the user's template actually
// has rather than by minted UUIDs they have never seen.
try BoardWriter.checkCopiedDescendantsAreStampable(of: root, operation: operation)
var materialized: [URL] = [] var materialized: [URL] = []
try BoardWriter.remintDescendants(of: root, collecting: &materialized, operation: operation) try BoardWriter.remintDescendants(of: root, collecting: &materialized, operation: operation)
// One `Date` for the whole tree, so the board and every item in it are born at the same // One `Date` for the whole tree, so the board and every item in it are born at the same
// instant rather than merely close (`BoardWriter.newDocumentText`'s convention). // instant rather than merely close (`BoardWriter.newDocumentText`'s convention).
let now = Date() let now = Date()
try BoardWriter.updateIndex(inItemFolder: root, operation: operation) { document in // `kind: .board` an instantiated board's root is the one file whose kind position
document.set(FrontmatterKeys.created, to: .date(now)) // cannot answer, and this write is where a template's kind-less root gains it
// (`BoardWriter.updateIndex`'s on-touch backfill; no template migration, by design).
try BoardWriter.updateIndex(inItemFolder: root, kind: .board, operation: operation) { document in
// `.born` restamps `created`; the copy contract also severs the reserved tracker keys,
// which at board level is the `remote` a template could have carried in from the board
// it was saved from (01 Identity lifecycle, ruled 2026-07-29).
BoardWriter.applyCopyContract(to: &document, stamps: .born, now: now)
document.set(FrontmatterKeys.title, to: .string(title)) document.set(FrontmatterKeys.title, to: .string(title))
} }
for folder in materialized { for folder in materialized {
@@ -407,6 +433,15 @@ enum TemplateEngine {
// The lane list is the loader's own level detection `directoryCandidates` (hidden // The lane list is the loader's own level detection `directoryCandidates` (hidden
// entries and symlinks already out) narrowed by the identity predicate so a stray // entries and symlinks already out) narrowed by the identity predicate so a stray
// folder at board level is never descended into here either. // folder at board level is never descended into here either.
// **The seed, before the normalization it governs** (01-storage-format.md § Fractal
// layout Rules and 06-history-undo.md Repository hygiene, ruled 2026-07-31: every
// board carries a `.gitignore`, and an instantiated board is a board created today). A
// template that carries one of its own copies through and is honored the seed writes
// only into a free name which is 09-templates.md's "strays copy through" doing the
// right thing by itself; a template written before the ruling gains the seed here rather
// than a reload later, so the new board's very first walk has its noise gate.
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: root)
let lanes = ((try? BoardLoader.directoryCandidates(in: root)) ?? []) let lanes = ((try? BoardLoader.directoryCandidates(in: root)) ?? [])
.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) } .filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
for lane in lanes { for lane in lanes {
@@ -442,7 +477,7 @@ enum TemplateEngine {
/// ///
/// **The store's two minters are Save as Template and Reveal in Finder** (see `userStore`, which /// **The store's two minters are Save as Template and Reveal in Finder** (see `userStore`, which
/// only names it): a store that exists because the app made it on the off-chance would be an /// only names it): a store that exists because the app made it on the off-chance would be an
/// empty folder in Application Support for a user who never used the feature, while a Reveal /// empty folder in the shared container for a user who never used the feature, while a Reveal
/// that opened nothing or a save that failed because its own home was missing would be the /// that opened nothing or a save that failed because its own home was missing would be the
/// app being pedantic about a directory it owns. /// app being pedantic about a directory it owns.
@discardableResult @discardableResult
@@ -591,7 +626,7 @@ enum TemplateEngine {
operation: WriteOperation operation: WriteOperation
) throws(Failure) { ) throws(Failure) {
do throws(BoardWriteError) { do throws(BoardWriteError) {
try BoardWriter.updateIndex(inItemFolder: root, operation: operation) { document in try BoardWriter.updateIndex(inItemFolder: root, kind: .board, operation: operation) { document in
document.set(BoardLoader.templateKey, to: .raw("{order: \(orderText(order))}")) document.set(BoardLoader.templateKey, to: .raw("{order: \(orderText(order))}"))
} }
} catch { } catch {
+360 -39
View File
@@ -42,20 +42,16 @@ enum LaunchPlan: Equatable, Sendable {
) ? .restoreBoards : .welcome ) ? .restoreBoards : .welcome
} }
/// Whether the throwaway bootstrap window is presented at launch everything except the plain
/// welcome case, since both other plans have to open windows and only a view can do that
/// (`RestoreBootstrapView`'s own reason for wearing a window).
var presentsBootstrap: Bool {
self != .welcome
}
} }
// MARK: - UITestLaunch // MARK: - UITestLaunch
/// **The accessibility audit suite's board**, and the launch argument that asks for it /// **The UI suites' boards**, and the launch arguments that ask for them the accessibility audit's
/// (10-accessibility.md Verification: "Xcode's accessibility audit runs in UI tests over every /// fixture (10-accessibility.md Verification: "Xcode's accessibility audit runs in UI tests over
/// surface board (trash shown and hidden), card window (Preview, Edit, raw source), welcome, /// every surface board (trash shown and hidden), card window (Preview, Edit, raw source), welcome,
/// template chooser, board popover"). /// template chooser, board popover") and, since the end-to-end pass, two more shapes that the audit
/// never needed: a **large** board for reflow and launch cost, and a **malformed** one whose only job
/// is to fail to load.
/// ///
/// ### Why the app builds the board instead of being handed one /// ### Why the app builds the board instead of being handed one
/// ///
@@ -73,6 +69,12 @@ enum LaunchPlan: Equatable, Sendable {
/// fixture is a board the app made, not a board a test file *believes* is well-formed. A format /// fixture is a board the app made, not a board a test file *believes* is well-formed. A format
/// change that broke this would break the app first. /// change that broke this would break the app first.
/// ///
/// **The one deliberate exception is the malformed variant**, which builds its board through the very
/// same door and then overwrites exactly one card's `index.md` with raw bytes. That write is the
/// point of the variant there is no Writer call that produces an unparseable file, and there should
/// not be one and it happens *last*, so everything around the broken file is still a board the app
/// made.
///
/// ### It is inert without the flag /// ### It is inert without the flag
/// ///
/// Every entry point below is reached only from `LaunchPlan.uiTestFixture`, and that case is reached /// Every entry point below is reached only from `LaunchPlan.uiTestFixture`, and that case is reached
@@ -84,12 +86,13 @@ enum LaunchPlan: Equatable, Sendable {
/// ///
/// ### What the flag also switches off /// ### What the flag also switches off
/// ///
/// **The registry moves into the scratch directory** with the board. Without that, every audit run /// **The registry and the clipboard's staging store move into the scratch directory** with the board.
/// would stamp a temp folder into the user's real recents list (`BoardRegistry.defaultStorageURL`, /// Without that, every audit run would stamp a temp folder into the user's real recents list
/// in Application Support), where it would sit for good as an unavailable row pointing at a /// (`BoardRegistry.defaultStorageURL`), where it would sit for good as an unavailable row pointing at a
/// directory that no longer exists. Tying it to the same flag rather than to a second argument is /// directory that no longer exists and its launch sweep would collect the user's real staged copy
/// deliberate: the two are one decision "this launch is synthetic" and a second argument is a /// (`ClipboardStore.defaultStagingRoot`). Tying them to the same flag rather than to separate
/// second chance to apply only half of it. /// arguments is deliberate: they are one decision "this launch is synthetic" and a second argument
/// is a second chance to apply only half of it.
/// ///
/// The honest residual: `UserDefaults` is **not** redirected, so an audit run can still write the /// The honest residual: `UserDefaults` is **not** redirected, so an audit run can still write the
/// three app-wide scalars (`AppPreferences`) into the real domain. They are a window size, a restore /// three app-wide scalars (`AppPreferences`) into the real domain. They are a window size, a restore
@@ -102,20 +105,31 @@ enum UITestLaunch {
// MARK: - The flag // MARK: - The flag
/// The launch argument the audit suite passes (`KanbanUITests/AccessibilityAuditTests.swift`). /// The launch argument every UI suite passes (`KanbanUITests/UITestSupport.swift`), on its own or
/// beside a variant flag. It means "this launch is synthetic" and nothing narrower which board
/// is `FixtureVariant`'s to say.
/// ///
/// `--`-prefixed on purpose: a single-dash `-key value` pair is swallowed by `UserDefaults`' /// `--`-prefixed on purpose: a single-dash `-key value` pair is swallowed by `UserDefaults`'
/// `NSArgumentDomain` and would silently become a preference, which is precisely the kind of /// `NSArgumentDomain` and would silently become a preference, which is precisely the kind of
/// side effect a test-only switch must not have. /// side effect a test-only switch must not have.
static let fixtureFlag = "--ui-test-fixture-board" static let fixtureFlag = "--ui-test-fixture-board"
/// Whether `arguments` asks for the fixture board **pure**, so the rule is pinned by /// Whether `arguments` asks for a fixture board **pure**, so the rule is pinned by
/// `UITestLaunchTests` rather than by launching an app and looking. /// `UITestLaunchTests` rather than by launching an app and looking.
/// ///
/// Exact match, not a prefix: `--ui-test-fixture-boards-elsewhere` is not this flag, and a /// Exact match, not a prefix: `--ui-test-fixture-boards-elsewhere` is not this flag, and a
/// `hasPrefix` check that accepted it would be a launch switch with a fuzzy edge. /// `hasPrefix` check that accepted it would be a launch switch with a fuzzy edge. That is also
/// why the variants below wear *sibling* flags rather than a `=value` suffix or a
/// `--flag value` pair a suffix would demand exactly the prefix matching this rules out, and a
/// pair is the shape `NSArgumentDomain` swallows.
///
/// **A variant flag on its own is enough**, which is belt over braces rather than a second
/// spelling: every call site passes `fixtureFlag` too (it is what "this launch is synthetic"
/// means), but a test bundle that passed only `--ui-test-fixture-large` must not get a launch
/// that reopens the developer's real boards into a scratch registry's blind spot.
static func isFixtureLaunch(arguments: [String]) -> Bool { static func isFixtureLaunch(arguments: [String]) -> Bool {
arguments.contains(fixtureFlag) arguments.contains(fixtureFlag)
|| FixtureVariant.allCases.contains { arguments.contains($0.flag) }
} }
/// The running process's answer to the same question. /// The running process's answer to the same question.
@@ -123,6 +137,60 @@ enum UITestLaunch {
isFixtureLaunch(arguments: ProcessInfo.processInfo.arguments) isFixtureLaunch(arguments: ProcessInfo.processInfo.arguments)
} }
// MARK: - The variants
/// **Which fixture board a launch asks for.** Three shapes, because the three suites that consume
/// them are asking three different questions:
///
/// - `standard` the audit's board (three lanes, six cards, a rich card, a trashed card). It is
/// also the end-to-end suite's board: every golden flow is expressed against a shape small
/// enough to state in a sentence, so an assertion about a lane's card count is readable.
/// - `large` many lanes × many cards, for the masonry, the reflow, and the launch-cost
/// measurements. Nothing about it is *interesting*; the point is that there is a lot of it.
/// - `malformed` a well-formed board with exactly one unparseable card `index.md`, for the
/// fail-fast pass (01-storage-format.md § Malformed input). It is the only variant whose
/// *successful* materialization is expected to produce a *failed* load.
///
/// The raw value is the flag's tail, so the flag and the case can never drift; the flag is
/// double-dashed for `fixtureFlag`'s reason and exact-matched for its reason too.
enum FixtureVariant: String, CaseIterable, Sendable {
case standard
case large
case malformed
/// The launch argument naming this variant. Paired with `fixtureFlag` at every call site.
var flag: String { "--ui-test-fixture-\(rawValue)" }
/// The board's title and, through `fixtureBoardURL(for:)`, its folder name and its window
/// title, so a test can wait on `app.windows["Audit Board"]` and its neighbours.
///
/// Distinct per variant on purpose: a suite that waited on the wrong title would otherwise
/// pass against the wrong board, and the malformed variant's whole assertion is that *no*
/// window by its name ever appears.
var boardTitle: String {
switch self {
case .standard: UITestLaunch.boardTitle
case .large: "Large Board"
case .malformed: "Malformed Board"
}
}
}
/// The variant `arguments` asks for `standard` when none is named, which is what the audit
/// suite's bare `--ui-test-fixture-board` has always meant.
///
/// **Declaration order breaks a tie**, so a launch naming two variants is deterministic rather
/// than dependent on argument order. Nothing produces that today; stating the rule is cheaper
/// than discovering it.
static func variant(arguments: [String]) -> FixtureVariant {
FixtureVariant.allCases.first { arguments.contains($0.flag) } ?? .standard
}
/// The running process's answer to the same question.
static var variant: FixtureVariant {
variant(arguments: ProcessInfo.processInfo.arguments)
}
// MARK: - The scratch directory // MARK: - The scratch directory
/// Everything a fixture launch writes, under one removable root inside the app's container. /// Everything a fixture launch writes, under one removable root inside the app's container.
@@ -141,15 +209,33 @@ enum UITestLaunch {
scratchRoot.appendingPathComponent("board-registry.json", isDirectory: false) scratchRoot.appendingPathComponent("board-registry.json", isDirectory: false)
} }
/// The fixture board's own folder. `.kanban`-suffixed because a board the app made through the /// Where the fixture launch's clipboard snapshots live, on the registry's terms: an audit run's
/// ordinary create path is a document, and the audit should be looking at the shape a user's /// launch sweep would otherwise collect the developer's own staged copy. Redirected by the same
/// board actually has (01-storage-format.md § Document packaging). /// flag, because it is the same one decision.
static var fixtureBoardURL: URL { static var clipboardStagingRoot: URL {
scratchRoot.appendingPathComponent("\(boardTitle).kanban", isDirectory: true) scratchRoot.appendingPathComponent("Clipboard", isDirectory: true)
} }
/// Wipes and recreates the scratch root, and answers the registry URL to build the app model /// A fixture board's own folder. `.kanban`-suffixed because a board the app made through the
/// with. Called once, from `KanbanApp.init()`, **before** the model reads its registry. /// ordinary create path is a document, and the audit should be looking at the shape a user's
/// board actually has (01-storage-format.md § Document packaging).
///
/// One folder per variant, all under the one scratch root: the root is wiped per launch anyway,
/// so the separation buys nothing at runtime it buys a *name*, which is what a suite waits on.
static func fixtureBoardURL(for variant: FixtureVariant) -> URL {
scratchRoot.appendingPathComponent("\(variant.boardTitle).kanban", isDirectory: true)
}
/// The audit fixture's folder `fixtureBoardURL(for: .standard)`, kept as a name because that
/// board is the one every caller predating the variants meant.
static var fixtureBoardURL: URL {
fixtureBoardURL(for: .standard)
}
/// Wipes and recreates the scratch root, and answers the registry URL for the caller's convenience.
/// Called once, from `KanbanApp.init()`, **before** the model reads its registry which is also why
/// the return value is discardable: that caller now names both redirected homes explicitly
/// (`registryStorageURL`, `clipboardStagingRoot`) rather than taking one of them from here.
/// ///
/// **Wiped rather than reused**: every audit test launches its own app instance, and an audit is /// **Wiped rather than reused**: every audit test launches its own app instance, and an audit is
/// only meaningful against a board whose contents the test knows a previous run's leftovers /// only meaningful against a board whose contents the test knows a previous run's leftovers
@@ -175,10 +261,10 @@ enum UITestLaunch {
return registryStorageURL return registryStorageURL
} }
// MARK: - The fixture board's shape // MARK: - The standard board's shape
/// The board's title and, through `fixtureBoardURL`, its folder name and its window title, so /// The standard board's title and, through `fixtureBoardURL`, its folder name and its window
/// a test can wait on `app.windows["Audit Board"]`. /// title, so a test can wait on `app.windows["Audit Board"]`.
static let boardTitle = "Audit Board" static let boardTitle = "Audit Board"
/// The lane titles, in board order. Three because the tree the audit walks should have more than /// The lane titles, in board order. Three because the tree the audit walks should have more than
@@ -248,6 +334,40 @@ enum UITestLaunch {
The audit fixture's attachment. Its only job is to exist, so the attachments section has a row. The audit fixture's attachment. Its only job is to exist, so the attachments section has a row.
""" """
/// **The rich card's comment thread** three comments, because the pane's accessibility audit
/// needs one of each shape 10-accessibility.md's comments row can take ( Comments: "each comment
/// is one flattened element author, date, edited state, body").
///
/// In order: an ordinary comment (author line, date, body), an **authorless** one (the date alone
/// carries the line "missing renders unattributed", and no placeholder stands in for a name),
/// and one that has been **edited** (its author line ends "· edited", which is `modified`
/// differing from `created` and no extra field). Between them they cover every branch of
/// `CommentAuthorLine.text(author:timestamp:isEdited:)` that a written file can produce.
static let commentBodies = [
"""
The audit's specimen thread. This one is ordinary: a name, a date, and a paragraph of \
Markdown with some *emphasis* in it.
""",
"""
This one has no `author` key at all, so it renders unattributed a date and a body, and no \
placeholder standing in for a name.
""",
"""
And this one has been edited since it was posted, so its author line carries the edited marker.
"""
]
/// Which comment gets its `author` key removed, by index into `commentBodies`.
static let authorlessCommentIndex = 1
/// Which comment is edited after posting, by index into `commentBodies`, and what it is edited to.
static let editedCommentIndex = 2
static let editedCommentBody = """
And this one has been edited since it was posted, so its author line carries the edited \
marker this sentence is the edit.
"""
/// The card that is deleted into `.trash/`, named by `(lane, card)` index. /// The card that is deleted into `.trash/`, named by `(lane, card)` index.
/// ///
/// A trash with something in it is the only way the trash-shown audit reaches the elements /// A trash with something in it is the only way the trash-shown audit reaches the elements
@@ -255,18 +375,104 @@ enum UITestLaunch {
/// and Reveal in Finder and never Open. An empty column audits its own label and stops there. /// and Reveal in Finder and never Open. An empty column audits its own label and stops there.
static let trashedCardIndex = (lane: 0, card: 1) static let trashedCardIndex = (lane: 0, card: 1)
// MARK: - The large board's shape
/// The large board's lanes and cards **8 × 40**, which is 320 cards.
///
/// The numbers are a budget, not a maximum. They are large enough that the two things the large
/// board exists to exercise actually happen a lane wide enough to lay out in several interior
/// masonry columns has plenty to lay out, and the board's reflow, scrolling and select-all all
/// have real work to do and small enough that materializing it (a folder and an `index.md` per
/// card, through the ordinary Writer) stays in the seconds a UI test can afford. Raising them is
/// a decision about how long every performance run takes; they are stated here so that decision
/// is made in one place.
static let largeLaneCount = 8
static let largeCardsPerLane = 40
static func largeLaneTitle(_ index: Int) -> String {
"Lane \(index + 1)"
}
/// A large-board card's title unique across the whole board, and **deliberately of four
/// different lengths**.
///
/// A wall of identical one-line cards would lay out as a perfect grid, which is exactly the case
/// masonry has nothing to do. Cycling the length gives the layout genuinely different card
/// heights to balance, so a reflow measured against this board is measuring the work the real
/// algorithm does.
static func largeCardTitle(lane laneIndex: Int, card cardIndex: Int) -> String {
let base = "Card \(laneIndex + 1)-\(cardIndex + 1)"
let tail = String(repeating: " with a longer title that wraps", count: cardIndex % 4)
return base + tail
}
// MARK: - The malformed board's shape
/// The malformed board's lanes and cards small, because nothing about this variant is about
/// size. Two lanes so the tree has a shape at all, and two cards in the first so the broken one
/// has an intact sibling the loader walked past on its way to it.
static let malformedLaneTitles = ["Intact", "Also intact"]
static let malformedCardTitles: [[String]] = [
["A good card", "The malformed card"],
["Another good card"],
]
/// Which card gets the raw overwrite, named by `(lane, card)` index.
static let malformedCardIndex = (lane: 0, card: 1)
/// A string appearing only in the malformed file, so a test can *find* that file on disk without
/// knowing the UUID the Writer minted for it and then assert its bytes are exactly what were
/// written, which is the "nothing was silently repaired" half of fail-fast.
static let malformationMarker = "lanework-ui-test-malformed-fixture"
/// The bytes written over the doomed card's `index.md`.
///
/// **The frontmatter opens a flow sequence and never closes it**, so the YAML between the
/// delimiters cannot parse `BoardLoader` rejects the whole board with `.unparseableYAML` naming
/// this file, which is the failure the fail-fast pass is about. Everything else in the file is
/// well-formed on purpose: the delimiters are there, the body is ordinary text, and the two
/// required fields are present in spirit. The one thing wrong with it is the one thing under
/// test.
static let malformedIndexText = """
---
schema: 1
title: The malformed card
order: [1024
---
\(malformationMarker): this card's frontmatter opens a flow sequence and never closes it.
The loader must reject the whole board rather than repair, skip, or rewrite anything and
these bytes must still be here, unchanged, after the app has refused to open the board.
"""
// MARK: - Materialization // MARK: - Materialization
/// Builds the fixture board and answers its URL every write through `BoardWriter`, in the order /// Builds the fixture board `variant` asks for and answers its URL every write through
/// a user would have produced them. /// `BoardWriter`, in the order a user would have produced them, with the malformed variant's one
/// raw overwrite as the documented exception (see this type's note).
/// ///
/// Called from `RestoreBootstrapView` rather than from `KanbanApp.init()`: it is filesystem work, /// Called from `RestoreBootstrapView` rather than from `KanbanApp.init()`: it is filesystem work,
/// and the launch path already has a place for filesystem work that has to happen before the /// and the launch path already has a place for filesystem work that has to happen before the
/// first real window (that view's whole reason for existing). A throw surfaces as a launch /// first real window (that view's whole reason for existing). A throw surfaces as a launch
/// failure on welcome the same treatment a board that fails to restore gets so a broken /// failure on welcome the same treatment a board that fails to restore gets so a broken
/// fixture is visible rather than a suite that quietly audits an empty screen. /// fixture is visible rather than a suite that quietly audits an empty screen.
static func materializeFixtureBoard() throws -> URL { ///
let root = fixtureBoardURL /// **The malformed variant does not throw here.** Building it succeeds; *loading* it is what
/// fails, one layer up, through the ordinary board-window path which is the whole point, since
/// a fixture that failed to build would surface a message about the fixture rather than the
/// loader's own sentence about the offending file.
static func materializeFixtureBoard(_ variant: FixtureVariant = .standard) throws -> URL {
switch variant {
case .standard: try materializeStandardBoard()
case .large: try materializeLargeBoard()
case .malformed: try materializeMalformedBoard()
}
}
/// The audit suite's board (see `boardTitle` and the constants above it).
private static func materializeStandardBoard() throws -> URL {
let root = fixtureBoardURL(for: .standard)
try BoardWriter.createBoard(at: root, title: boardTitle) try BoardWriter.createBoard(at: root, title: boardTitle)
var laneURLs: [URL] = [] var laneURLs: [URL] = []
@@ -288,20 +494,135 @@ enum UITestLaunch {
let richCard = cardURLs[richCardIndex.lane][richCardIndex.card] let richCard = cardURLs[richCardIndex.lane][richCardIndex.card]
try BoardWriter.writeBody(inItemFolder: richCard, body: richCardBody) try BoardWriter.writeBody(inItemFolder: richCard, body: richCardBody)
try importFixtureAttachment(into: richCard) try importFixtureAttachment(into: richCard)
try seedCommentThread(into: richCard, cardTitle: cardTitles[richCardIndex.lane][richCardIndex.card])
// The delete goes last so the trashed card's identity is one the lanes above have already // The delete goes last so the trashed card's identity is one the lanes above have already
// finished with and through the ordinary delete door, so `.trash/` ends up holding exactly // finished with and through the ordinary delete door, so `.trash/` ends up holding exactly
// what a user's would have put there, stamps and `order` included. // what a user's would have put there: the `modified` stamp that positions it, its `order`
// riding along untouched.
let doomed = cardURLs[trashedCardIndex.lane][trashedCardIndex.card] let doomed = cardURLs[trashedCardIndex.lane][trashedCardIndex.card]
try BoardWriter.deleteCardToTrash( try BoardWriter.deleteCardToTrash(at: doomed, inBoard: root)
at: doomed,
inBoard: root,
order: Ranks.append(toVisible: [] as [Double])
)
return root return root
} }
/// The performance suite's board `largeLaneCount` lanes of `largeCardsPerLane` cards, built
/// through the same three Writer calls the standard board uses and nothing else.
///
/// No rich body, no attachment, no trashed card: every one of those is a *feature* the audit
/// wanted a specimen of, and this board is not about features. What it is about is quantity, and
/// quantity is the only thing that differs.
private static func materializeLargeBoard() throws -> URL {
let root = fixtureBoardURL(for: .large)
try BoardWriter.createBoard(at: root, title: FixtureVariant.large.boardTitle)
for laneIndex in 0 ..< largeLaneCount {
let laneID = try BoardWriter.createLane(inBoard: root, title: largeLaneTitle(laneIndex))
let laneURL = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
for cardIndex in 0 ..< largeCardsPerLane {
_ = try BoardWriter.createCard(
inLane: laneURL,
title: largeCardTitle(lane: laneIndex, card: cardIndex)
)
}
}
return root
}
/// The fail-fast suite's board: an ordinary small board, with one card's `index.md` overwritten
/// by `malformedIndexText` **after** every Writer call has finished.
///
/// The order is the whole design. Building first means the board around the broken file is one
/// the app made right frontmatter, right ranks, right stamps so the load that follows fails
/// for exactly one reason and the loader's sentence names exactly one file. Overwriting first, or
/// hand-writing the tree, would have produced a board whose *many* problems the loader would
/// report whichever it reached first.
///
/// The write is `Data.write`, not `BoardWriter.atomicReplace`: the Writer refuses to produce a
/// file its own loader would reject, which is a guarantee worth keeping rather than a door worth
/// opening. This is the one place in the app that goes around it, and it is unreachable without
/// the launch flag.
private static func materializeMalformedBoard() throws -> URL {
let root = fixtureBoardURL(for: .malformed)
try BoardWriter.createBoard(at: root, title: FixtureVariant.malformed.boardTitle)
var cardURLs: [[URL]] = []
for (laneIndex, laneTitle) in malformedLaneTitles.enumerated() {
let laneID = try BoardWriter.createLane(inBoard: root, title: laneTitle)
let laneURL = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
var lane: [URL] = []
for title in malformedCardTitles[laneIndex] {
let cardID = try BoardWriter.createCard(inLane: laneURL, title: title)
lane.append(laneURL.appendingPathComponent(cardID.rawValue, isDirectory: true))
}
cardURLs.append(lane)
}
let doomed = cardURLs[malformedCardIndex.lane][malformedCardIndex.card]
.appendingPathComponent(BoardLoader.indexFileName, isDirectory: false)
try Data(malformedIndexText.utf8).write(to: doomed, options: .atomic)
return root
}
/// Builds the rich card's thread **the way the composer does** a draft saved, then posted, once
/// per body so the fixture's `comments/` is a folder the app made: minted identities, the
/// `kind: comment` field table, `created`/`modified` restamped at the post.
///
/// Two of the three then need a shape no gesture in the app produces, and each is applied
/// afterwards, narrowly:
///
/// - **The edit** is an ordinary Writer call (`editComment`), which is exactly what an inline edit
/// session's save does so the edited marker in the fixture is the real mechanism, not a
/// hand-set field.
/// - **The two frontmatter amendments** are raw writes, for the malformed variant's reason (see
/// this type's note). Neither shape has a door in the app: it always writes the account's full
/// name, and it cannot post a comment an hour ago. Both shapes are ordinary on disk, though
/// agents and tracker sync write comments with no `author` at all (01-storage-format.md
/// Enhanced schema), and every comment that has ever been edited was posted before it. Both go
/// through `FrontmatterDocument`, so the rest of each file is byte-identical to what the Writer
/// produced.
///
/// The backdating is not decoration: `created` and `modified` serialize to the second, and a
/// comment posted and edited inside one second would render as **not** edited the marker is
/// `modified` differing from `created` and no extra field (`Comment.isEdited`).
private static func seedCommentThread(into cardFolder: URL, cardTitle: String) throws {
var posted: [ItemID] = []
for body in commentBodies {
try BoardWriter.saveCommentDraft(inCard: cardFolder, body: body, cardTitle: cardTitle)
posted.append(try BoardWriter.postComment(inCard: cardFolder, cardTitle: cardTitle).id)
}
try BoardWriter.editComment(
at: CommentThread.commentFolder(posted[editedCommentIndex], inCard: cardFolder),
body: editedCommentBody,
cardTitle: cardTitle
)
try amendComment(posted[authorlessCommentIndex], inCard: cardFolder) { document in
document.remove(FrontmatterKeys.author)
}
try amendComment(posted[editedCommentIndex], inCard: cardFolder) { document in
document.set(FrontmatterKeys.created, to: .date(Date().addingTimeInterval(-3600)))
}
}
/// One comment's frontmatter, amended in place the fixture's narrow way around the Writer, kept
/// to one function so both amendments share its round trip and neither invents a second one.
private static func amendComment(
_ id: ItemID,
inCard cardFolder: URL,
_ amend: (inout FrontmatterDocument) -> Void
) throws {
let indexURL = CommentThread
.commentFolder(id, inCard: cardFolder)
.appendingPathComponent(BoardLoader.indexFileName, isDirectory: false)
var document = try FrontmatterDocument.parse(String(decoding: try Data(contentsOf: indexURL), as: UTF8.self))
amend(&document)
try Data(document.serialized().utf8).write(to: indexURL, options: .atomic)
}
/// Writes the attachment's source into the scratch root and imports it the way a Finder drop /// Writes the attachment's source into the scratch root and imports it the way a Finder drop
/// would (`BoardWriter.importAttachments`), so the card ends up with a real `attachments/` /// would (`BoardWriter.importAttachments`), so the card ends up with a real `attachments/`
/// folder rather than a hand-placed file the loader would have to normalize. /// folder rather than a hand-placed file the loader would have to normalize.
+11 -6
View File
@@ -379,10 +379,15 @@ private struct RecentBoardRow: View {
/// The app's preferences (, 11-command-nexus.md). /// The app's preferences (, 11-command-nexus.md).
/// ///
/// One control, which is the whole of v1: "Restore open boards at launch". The preference gates only /// One section today: "Restore open boards at launch", whose preference gates only whether the
/// whether the registry's open-now flags are *consulted* at launch the flags themselves are /// registry's open-now flags are *consulted* at launch the flags themselves are maintained either
/// maintained either way, which is what keeps crash recovery working for a user who has restoration /// way, which is what keeps crash recovery working for a user who has restoration turned off and then
/// turned off and then turns it back on. /// turns it back on.
///
/// **The Pro section is dormant, not deleted** (12-editions.md PIVOT 2026-08-07 git leaves the
/// paywall): `ProSettingsSection` still exists and still compiles, but this scene no longer renders
/// it the base/Pro split is being re-decided, and until it's ruled, no surface in the app names or
/// sells Lanework Pro.
struct SettingsView: View { struct SettingsView: View {
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey) @AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
@@ -397,8 +402,8 @@ struct SettingsView: View {
} }
} }
.formStyle(.grouped) .formStyle(.grouped)
// Font-derived: this pane is `.fixedSize()`, so a 420-point literal would clip its one // Font-derived: this pane is `.fixedSize()`, so a 420-point literal would clip a footer
// toggle's footer sentence at a large system text size with no way to resize out of it // sentence at a large system text size with no way to resize out of it
// (10-accessibility.md Text scaling). // (10-accessibility.md Text scaling).
.frame(width: BoardMetrics.em(32.3, bodyPointSize: BoardMetrics.bodyPointSize)) .frame(width: BoardMetrics.em(32.3, bodyPointSize: BoardMetrics.bodyPointSize))
.fixedSize() .fixedSize()
+117 -20
View File
@@ -56,19 +56,18 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// window that has nothing to flush. /// window that has nothing to flush.
var onCloseRequested: (() -> Void)? var onCloseRequested: (() -> Void)?
/// This window's board undo stack, asked for afresh every time AppKit wants it the board /// **The stack this window's Z crosses**, asked for afresh every time AppKit wants it
/// window's and its card windows' shared answer (13-native-undo.md Rules: "one stack per /// 13-native-undo.md Rules' two levels (re-ruled 2026-07-31): a **board** window answers with
/// board, owned by the board session ... `window.undoManager` for board surfaces returns the /// its session's stack, and a **card** window with its own, "standard per-window AppKit scoping".
/// session's manager").
/// ///
/// A closure rather than a stored manager for two reasons: the session does not exist yet when /// A closure rather than a stored manager for two reasons: a board window's session does not
/// the window attaches, and it stops existing at teardown while the window is still closing /// exist yet when the window attaches, and it stops existing at teardown while the window is
/// answering `nil` then is what keeps a torn-down board's stack from being reachable through a /// still closing answering `nil` then is what keeps a torn-down board's stack from being
/// window that outlived it by a run-loop turn. /// reachable through a window that outlived it by a run-loop turn.
/// ///
/// `nil` on every window that is not showing a board (welcome, the bootstrap, the template /// `nil` on every window that has no stack of its own (welcome, the bootstrap, the template
/// chooser), which `BoardUndoRouting` reads as "the platform default". /// chooser), which `BoardUndoRouting` reads as "the platform default".
var boardUndoManager: (() -> UndoManager?)? var windowUndoManager: (() -> UndoManager?)?
/// The text manager this window hands back while a field editor holds the keyboard, and the one /// The text manager this window hands back while a field editor holds the keyboard, and the one
/// it hands back when there is no board 06-history-undo.md Undo routing, via /// it hands back when there is no board 06-history-undo.md Undo routing, via
@@ -92,6 +91,42 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// their board has loaded. /// their board has loaded.
private var toolbarController: WindowToolbarController? private var toolbarController: WindowToolbarController?
/// Whether this window's title is hidden from the title bar **card and board windows**: the
/// card's name is shown as part of the card's body instead of the chrome (05-card-window.md
/// Window), and the board's is said by the board-popover widget in the titlebar instead
/// (03-board-ui.md Board popover; `BoardWindowHost.configureWindow`). `nil` leaves AppKit's own
/// default (`.visible`) untouched the restore-bootstrap window's posture, the one
/// `HostedWindowController`-hosted window with no opinion here, the same "nothing to do" posture
/// `titlebarAccessory` has on welcome (which never attaches a controller at all), the bootstrap
/// window, and now for that slot specifically card windows too.
///
/// A slot, not a one-shot write, for the accessory and toolbar's own reason: the value has to
/// survive the provisional-window swap (`detach()`'s doc comment) and reapply itself when the
/// real window attaches, which a write made once at `onAttach` time would not survive if that
/// closure only fired for the provisional window. `NSWindow.title` itself is a different slot
/// entirely SwiftUI's `navigationTitle` sets it directly, and it is left alone on purpose: the
/// Window menu, Mission Control/Exposé, VoiceOver and state restoration all read the string, not
/// what the chrome draws from it.
private var titleVisibility: NSWindow.TitleVisibility?
/// Whether this window's content runs the full height of the frame, under a transparent title
/// bar **a board window carrying a custom background**, and nothing else (03-board-ui.md §
/// Styling Capabilities: the board's colour or image "paints the full window"; `BoardView
/// .boardBackground` draws the frosted strip that keeps the chrome legible over it).
///
/// `nil` leaves AppKit's own posture untouched, exactly as `titleVisibility` does the welcome,
/// bootstrap and card windows have no opinion, and neither does a board window while it loads
/// (the flag is driven off the snapshot, which does not exist yet). `nil` and `false` therefore
/// render identically; they differ only in whether this controller has *said* anything, which is
/// what keeps the loading half from having to state a default it does not own.
///
/// A slot rather than a one-shot write, and **repeat-safe rather than install-once** the
/// `hideTitle` pattern, for a stronger version of its reason: the value has to survive the
/// provisional-window swap (`detach()`), *and* it genuinely changes over a window's life. A
/// `background:` edited on disk reloads the snapshot, and the chrome follows it in both
/// directions.
private var extendsUnderTitlebar: Bool?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window") private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
// MARK: Attachment // MARK: Attachment
@@ -111,17 +146,24 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
// window existed is installed here instead, and one handed over later installs immediately. // window existed is installed here instead, and one handed over later installs immediately.
addTitlebarAccessoryIfPossible() addTitlebarAccessoryIfPossible()
applyToolbarIfPossible() applyToolbarIfPossible()
applyTitleVisibilityIfPossible()
applyTitlebarExtensionIfPossible()
} }
/// Puts the previous delegate back, and takes the titlebar accessory and toolbar back out. /// Puts the previous delegate back and takes the titlebar accessory and toolbar off the window
/// Called when the hosting view goes away; the delegate half is a no-op if something else has /// **without discarding them**. The delegate half is a no-op if something else has since taken
/// since taken the delegate, because stomping a third party's would be the bug this whole file /// the delegate, because stomping a third party's would be the bug this whole file exists to
/// exists to avoid. /// avoid.
///
/// The held chrome survives a detach deliberately: its lifetime is this controller's, not the
/// sensing view's. SwiftUI dismantles and re-makes the background representable while it moves a
/// scene's content into its final window (observed on macOS 26: install arrives before any
/// window, a dismantle follows, and only *then* does the real window attach) so chrome
/// discarded here would never reach the window it was made for. `attach` reinstalls whatever is
/// held; a controller that is genuinely done takes its slots down with it.
func detach() { func detach() {
removeTitlebarAccessory() removeTitlebarAccessory()
titlebarAccessory = nil
removeToolbar() removeToolbar()
toolbarController = nil
guard let window, window.delegate === self else { return } guard let window, window.delegate === self else { return }
window.delegate = previousDelegate window.delegate = previousDelegate
self.window = nil self.window = nil
@@ -188,6 +230,61 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
window.toolbar = nil window.toolbar = nil
} }
// MARK: Title visibility
/// Hides this window's title from the title bar, leaving the toolbar exactly as it renders today
/// the card-window seam (`CardWindowHost`, 05-card-window.md Window) and, since the
/// board-popover widget grew to say the board's name itself, the board-window one too
/// (`BoardWindowHost`, 03-board-ui.md Board popover). `window.title` is untouched by this call
/// on purpose; see the property's doc comment for why.
///
/// Safe to call whenever the caller learns it wants this before the window exists (the value is
/// held and applied at `attach`) or after (applied immediately) and safe to call more than once,
/// unlike the accessory and toolbar slots: writing `NSWindow.titleVisibility` twice has no side
/// effect worth guarding against, so this is not an install-once seam.
func hideTitle() {
titleVisibility = .hidden
applyTitleVisibilityIfPossible()
}
private func applyTitleVisibilityIfPossible() {
guard let window, let titleVisibility else { return }
window.titleVisibility = titleVisibility
}
// MARK: Content under the title bar
/// Runs this window's content the full height of its frame, under a transparent title bar or
/// puts the standard chrome back (see `extendsUnderTitlebar`).
///
/// Safe whenever the caller learns the answer before the window exists (held, applied at
/// `attach`) or after (applied now) and safe to call repeatedly with the same value, which
/// matters more here than for `hideTitle`: the board window drives this off its snapshot, so it
/// is called on every reload that changes the reading and on plenty that do not.
///
/// **Not undone at `detach`**, `titleVisibility`'s posture: the slot survives the provisional-
/// window swap and reapplies itself to whichever window attaches next, and a window that is
/// genuinely going away takes its chrome with it.
func setExtendsContentUnderTitlebar(_ flag: Bool) {
extendsUnderTitlebar = flag
applyTitlebarExtensionIfPossible()
}
/// The two AppKit knobs the effect needs, and they are one decision: `fullSizeContentView` is
/// what lets the content view reach under the title bar, and `titlebarAppearsTransparent` is
/// what stops the title bar from painting its own material over it. Either alone is a visible
/// half-state an opaque bar over the board, or a board that stops at a bar that no longer
/// draws.
private func applyTitlebarExtensionIfPossible() {
guard let window, let extendsUnderTitlebar else { return }
window.titlebarAppearsTransparent = extendsUnderTitlebar
if extendsUnderTitlebar {
window.styleMask.insert(.fullSizeContentView)
} else {
window.styleMask.remove(.fullSizeContentView)
}
}
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the /// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
/// standard path runs SwiftUI's own delegate gets its callbacks, tabbing behaves with the /// standard path runs SwiftUI's own delegate gets its callbacks, tabbing behaves with the
/// flag telling our own `windowShouldClose` to stand aside. /// flag telling our own `windowShouldClose` to stand aside.
@@ -210,16 +307,16 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
} }
/// The window-level half of 06-history-undo.md Undo routing (see `BoardUndoRouting`, which /// The window-level half of 06-history-undo.md Undo routing (see `BoardUndoRouting`, which
/// owns the rule and the reasoning): the board's stack when the keyboard is on the board, a /// owns the rule and the reasoning): this window's stack when the keyboard is on the content, a
/// text manager of this window's own while a field editor has it. /// text manager of this window's own while a field editor has it.
/// ///
/// **Answered here rather than forwarded**, unlike the proxy's other selectors, on the one /// **Answered here rather than forwarded**, unlike the proxy's other selectors, on the one
/// condition that this window has a board: `responds(to:)` reports this method whatever the /// condition that this window has a stack: `responds(to:)` reports this method whatever the
/// previous delegate does, so a `nil` return would leave a window with *no* undo manager at all /// previous delegate does, so a `nil` return would leave a window with *no* undo manager at all
/// rather than the one AppKit creates for a delegate that stays silent. A window with no board /// rather than the one AppKit creates for a delegate that stays silent. A window with no stack
/// still defers to SwiftUI's delegate if it has an opinion. /// still defers to SwiftUI's delegate if it has an opinion.
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
let board = boardUndoManager?() let board = windowUndoManager?()
if board == nil, let previousDelegate, if board == nil, let previousDelegate,
previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))), previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))),
let inherited = previousDelegate.windowWillReturnUndoManager?(window) { let inherited = previousDelegate.windowWillReturnUndoManager?(window) {
+141 -32
View File
@@ -53,7 +53,7 @@ struct ToolbarItemSpec {
let identifier: NSToolbarItem.Identifier let identifier: NSToolbarItem.Identifier
let label: String let label: String
/// The SF Symbol the item draws. `nil` for a hosted control, which draws itself. /// The SF Symbol the item draws. `nil` for the search item, whose field draws itself.
let symbol: String? let symbol: String?
let behavior: Behavior let behavior: Behavior
@@ -68,10 +68,33 @@ struct ToolbarItemSpec {
/// is how their menu rows work too, so "matching their menu items" is one mechanism rather /// is how their menu rows work too, so "matching their menu items" is one mechanism rather
/// than two (03 Toolbar; 06-history-undo.md). /// than two (03 Toolbar; 06-history-undo.md).
case responderAction(Selector) case responderAction(Selector)
/// A hosted control the board's search field. `make` is handed `true` when the view is /// A search field in AppKit's own `NSSearchToolbarItem` the board's search
/// bound for the toolbar itself and `false` when it is the customization palette's copy, so /// (03-board-ui.md Toolbar). The item owns the field's layout, so `focusedWidth` is a
/// only the real one claims window-scoped wiring. /// preference rather than a constraint: it is the width the field takes *when it has the
case control(width: CGFloat, make: (_ willBeInsertedIntoToolbar: Bool) -> NSView) /// keyboard*, the resting width being the item's own.
///
/// `make` is handed `true` when the field is bound for the toolbar itself and `false` when
/// it is the customization palette's copy, so only the real one claims window-scoped
/// wiring. `install` runs for that real item alone, and is where a caller wires the things
/// that need the *item* rather than the field expanding it and putting the keyboard in it
/// is one call on `NSSearchToolbarItem`, and no field can make it.
case searchField(
focusedWidth: CGFloat,
make: (_ willBeInsertedIntoToolbar: Bool) -> NSSearchField,
install: (NSSearchToolbarItem) -> Void
)
/// A pull-down of mutually exclusive choices **Appearance** (03-board-ui.md Toolbar): an
/// `NSMenuToolbarItem`, item image plus indicator, whose menu lists `options` in order.
/// `selected()` names the option index carrying the checkmark, read fresh whenever AppKit
/// opens the menu rather than polled the same freshness every other menu row in the app
/// gets (`validateMenuItem(_:)`) and `select(_:)` is a chosen row's whole action. The one
/// behavior with no `activate()` of its own: firing lives in the dropdown's rows, not in the
/// item itself, the way `responderAction`'s lives in the responder chain rather than here.
case picker(
options: [(title: String, symbol: String?)],
selected: () -> Int?,
select: (Int) -> Void
)
} }
/// The vocabulary rule applied: an item that mirrors a menu row takes that row's title, minus a /// The vocabulary rule applied: an item that mirrors a menu row takes that row's title, minus a
@@ -103,14 +126,14 @@ struct ToolbarItemSpec {
// MARK: State // MARK: State
/// The item's live enablement. Responder-chain items and hosted controls answer `true`: the /// The item's live enablement. Responder-chain items and the search item answer `true`: the
/// first is validated by the chain itself (which is the point of it), and the second has no /// first is validated by the chain itself (which is the point of it), and the second has no
/// enablement of its own. /// enablement of its own.
var isEnabled: Bool { var isEnabled: Bool {
switch behavior { switch behavior {
case let .button(isEnabled, _): isEnabled() case let .button(isEnabled, _): isEnabled()
case let .toggle(isEnabled, _, _): isEnabled() case let .toggle(isEnabled, _, _): isEnabled()
case .responderAction, .control: true case .responderAction, .searchField, .picker: true
} }
} }
@@ -118,17 +141,18 @@ struct ToolbarItemSpec {
var isOn: Bool? { var isOn: Bool? {
switch behavior { switch behavior {
case let .toggle(_, isOn, _): isOn() case let .toggle(_, isOn, _): isOn()
case .button, .responderAction, .control: nil case .button, .responderAction, .searchField, .picker: nil
} }
} }
/// Firing the item: a button performs, a toggle flips. A no-op for the two kinds AppKit drives /// Firing the item: a button performs, a toggle flips. A no-op for the kinds AppKit drives itself
/// itself. /// or that fire from somewhere other than the item's own primary action (`.picker`'s dropdown
/// rows).
func activate() { func activate() {
switch behavior { switch behavior {
case let .button(_, perform): perform() case let .button(_, perform): perform()
case let .toggle(_, isOn, setOn): setOn(!isOn()) case let .toggle(_, isOn, setOn): setOn(!isOn())
case .responderAction, .control: break case .responderAction, .searchField, .picker: break
} }
} }
} }
@@ -155,10 +179,12 @@ struct ToolbarItemSpec {
/// the pair enables and disables with the menu rows by construction rather than by agreement, /// the pair enables and disables with the menu rows by construction rather than by agreement,
/// reading the board window's `BoardUndoManager` through `NSWindow`'s own validation /// reading the board window's `BoardUndoManager` through `NSWindow`'s own validation
/// (13-native-undo.md). A SwiftUI `Button` cannot express that. /// (13-native-undo.md). A SwiftUI `Button` cannot express that.
/// - **The search item hosts a real `NSSearchField`** with explicit first-responder control, settled /// - **The search item is AppKit's own `NSSearchToolbarItem`**, hosting a real `NSSearchField` with
/// in m5 for reasons `BoardSearchFieldController` records (F must focus it from a menu item; /// explicit first-responder control, settled in m5 for reasons `BoardSearchFieldController`
/// Escape in an empty field must hand the keyboard back to the strip). A toolbar that already /// records (F must focus it from a menu item; Escape in an empty field must hand the keyboard
/// speaks AppKit hosts it directly. /// back to the strip). The item is where grow-on-focus, the cancel button's staging, and the
/// overflow row all live, and it exists only in AppKit; a toolbar that already speaks AppKit
/// hosts it directly.
/// - **The window is already proxied.** `HostedWindowController` fronts SwiftUI's window delegate /// - **The window is already proxied.** `HostedWindowController` fronts SwiftUI's window delegate
/// and installs the board's titlebar accessory; a toolbar is the same kind of thing hung on the /// and installs the board's titlebar accessory; a toolbar is the same kind of thing hung on the
/// same window, through the same install-once seam. /// same window, through the same install-once seam.
@@ -280,8 +306,17 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
return makeToggleItem(spec) return makeToggleItem(spec)
case let .responderAction(selector): case let .responderAction(selector):
return makeResponderItem(spec, selector: selector) return makeResponderItem(spec, selector: selector)
case let .control(width, make): case let .searchField(focusedWidth, make, install):
return makeControlItem(spec, width: width, view: make(willBeInsertedIntoToolbar)) return makeSearchItem(
spec,
focusedWidth: focusedWidth,
field: make(willBeInsertedIntoToolbar),
// The palette's copy is a picture of the item, not a second live one: it claims no
// window-scoped wiring, so it is handed none.
install: willBeInsertedIntoToolbar ? install : nil
)
case let .picker(options, _, _):
return makePickerItem(spec, options: options)
} }
} }
@@ -346,21 +381,78 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
return item return item
} }
/// The board's search field, hosted. Sized rather than flexible, matching the width the field /// The board's search field in the item AppKit wrote for it.
/// shipped with in m5. ///
private func makeControlItem(_ spec: ToolbarItemSpec, width: CGFloat, view: NSView) -> NSToolbarItem { /// **No width constraint here.** `NSSearchToolbarItem` manages the field's layout, and its
view.translatesAutoresizingMaskIntoConstraints = false /// header says custom width constraints "should not conflict with" the preferred width so the
view.widthAnchor.constraint(equalToConstant: width).isActive = true /// em-based figure is handed over as a *preference*, which the item applies "whenever it gets
/// the keyboard focus". The resting width is the item's own, and grow-on-focus is what that
let item = NSToolbarItem(itemIdentifier: spec.identifier) /// pair of facts means on screen.
///
/// **Neither the overflow row nor the visibility priority is written here, and both omissions
/// are decisions.** The item ships its own `menuFormRepresentation` a row titled from `label`
/// carrying a live AppKit action that widens the window until the field is usable where a
/// custom-view item ships a blank one that has to be replaced; assigning here (`nil` included)
/// destroys it. And its `visibilityPriority` already starts one step *above* `.high`, so the
/// nudge a custom-view item needs to stay out of the overflow would be a demotion here.
private func makeSearchItem(
_ spec: ToolbarItemSpec,
focusedWidth: CGFloat,
field: NSSearchField,
install: ((NSSearchToolbarItem) -> Void)?
) -> NSToolbarItem {
let item = NSSearchToolbarItem(itemIdentifier: spec.identifier)
decorate(item, with: spec) decorate(item, with: spec)
item.view = view // Configured before assignment, as the item's header asks with one exception the header
// No menu form representation, deliberately: a search field in the overflow *menu* is a // does not name: assignment stamps the *item's* enablement onto the field, so a field the
// field nobody can type into, so AppKit's generated row label, no action, disabled is // caller made inert (the customization palette's copy) comes back live. The caller's answer
// the honest presentation. F covers that window: an installed field that cannot take the // is the one that counts, so it is put back.
// keyboard falls through to the transient strip (`BoardSearchPresentation.focusField`). let isFieldEnabled = field.isEnabled
// The high priority keeps the board's one default item out of the overflow to begin with. item.searchField = field
item.visibilityPriority = .high field.isEnabled = isFieldEnabled
item.preferredWidthForSearchField = focusedWidth
// **Escape is staged** (04-interactions.md Search, 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"). AppKit's default is for the cancel button to clear *and* resign, which collapses
// the first two steps of that staircase into one so the field keeps the keyboard, and the
// second press is what hands it back (`BoardSearchFieldController`).
item.resignsFirstResponderWithCancel = false
install?(item)
return item
}
/// A pull-down of mutually exclusive options **Appearance**, so far the one item of this shape.
///
/// `selected`/`select` are deliberately not captured here: every row's action and every row's
/// validation re-fetch the spec fresh from `specs[identifier]` (`pickerItemFired(_:)`,
/// `validateMenuItem(_:)`), the same indirection `itemFired(_:)` and `toggleFired(_:)` already use
/// for their own specs so a spec rebuilt between two menu presentations is never read stale.
private func makePickerItem(_ spec: ToolbarItemSpec, options: [(title: String, symbol: String?)]) -> NSToolbarItem {
let item = NSMenuToolbarItem(itemIdentifier: spec.identifier)
decorate(item, with: spec)
// "Pull-down: item image + indicator" the item's own glyph draws at rest, the indicator
// chevron shows there is a menu, and the rows are what actually name Auto/Light/Dark.
item.showsIndicator = true
let menu = NSMenu()
for (index, option) in options.enumerated() {
let menuItem = NSMenuItem(
title: option.title,
action: #selector(pickerItemFired(_:)),
keyEquivalent: ""
)
menuItem.target = self
// The row's position in `options`, not an identifier of its own `select(_:)` and
// `selected()` both speak in this same index, which is what lets one closure pair stand
// for every row rather than one closure per option.
menuItem.tag = index
menuItem.representedObject = spec.identifier.rawValue
if let symbol = option.symbol {
menuItem.image = NSImage(systemSymbolName: symbol, accessibilityDescription: option.title)
}
menu.addItem(menuItem)
}
item.menu = menu
return item return item
} }
@@ -402,13 +494,30 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
revalidate() revalidate()
} }
/// A row in a `.picker` item's own dropdown **Appearance**'s Auto/Light/Dark, fired straight
/// from the menu rather than through `itemFired(_:)`, since the item has no primary action of its
/// own (`ToolbarItemSpec.activate()` is a no-op for `.picker`).
@objc private func pickerItemFired(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String,
let spec = specs[NSToolbarItem.Identifier(raw)],
case let .picker(_, _, select) = spec.behavior
else { return }
select(sender.tag)
revalidate()
}
/// The overflow menu's copy of an item validates like the item itself including the checkmark, /// The overflow menu's copy of an item validates like the item itself including the checkmark,
/// which is where a toggle's on-state goes when the menu is its face (03 Toolbar: "the system /// which is where a toggle's on-state goes when the menu is its face (03 Toolbar: "the system
/// overflow"). /// overflow") and where a `.picker` row's checkmark goes too, against its own index rather than
/// against `isOn` (which answers `nil` for the whole item, having no single on-state to give).
@objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { @objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
guard let raw = menuItem.representedObject as? String, guard let raw = menuItem.representedObject as? String,
let spec = specs[NSToolbarItem.Identifier(raw)] let spec = specs[NSToolbarItem.Identifier(raw)]
else { return true } else { return true }
if case let .picker(_, selected, _) = spec.behavior {
menuItem.state = selected() == menuItem.tag ? .on : .off
return true
}
if let isOn = spec.isOn { if let isOn = spec.isOn {
menuItem.state = isOn ? .on : .off menuItem.state = isOn ? .on : .off
} }
+234
View File
@@ -0,0 +1,234 @@
import Foundation
// MARK: - BoardGitMode
/// **Which git mode a board opened in** (07-sync-collab.md the mode state machine;
/// 06-history-undo.md Rules Detection).
///
/// A board has exactly one mode at a time and the mode is not fixed at creation "a board may be
/// created plain, gain git later, and later still gain a remote" (07). What decides it is one
/// question asked of the filesystem, `nearest-.git-wins`, and `detect(boardRoot:)` below is the
/// whole of that question.
///
/// ### Four cases, and the fourth is not a fifth mode
///
/// `repoNested` is not "git mode with the repository somewhere else". A board inside a user's
/// existing repository gets **no app-managed git at all** "no nested repo, no commits into the
/// user's repo" (06 Rules) which makes it as distinct from `git` as `none` is, and the reason it
/// is a case rather than a flag on `git`. What it no longer costs is Z: the native stack binds here
/// too (re-ruled 2026-07-31 13-native-undo.md's header; `AppModel.makeHistoryProvider`), because
/// that stack is memory-only and touches no repository, anybody's.
///
/// `unverifiable` answers a question `repoNested` cannot: what a sandboxed ancestor check *refuses*
/// to say (06 Rules Detection, "Denial is not absence", ruled 2026-07-31). A check the sandbox
/// answers `EACCES`/`EPERM` to is not "no repo there" it is "cannot tell" and folding that into
/// `.none` would let add-git offer app-managed init on a board that might already sit inside a
/// repository the app simply could not see. `unverifiable` therefore takes `repoNested`'s posture
/// everywhere structural (no add-git, no app-managed git, `BoardGitSetupSection.resolve` empty), since
/// the two share the one property every surface but the popover's prose cares about: neither may be
/// added to. Its prose is its own "unverifiable" is not "nested", and telling a user their board
/// sits inside a repository when the honest answer is "couldn't check" would be a lie dressed as
/// caution.
///
/// ### The remote half is deliberately absent
///
/// 07's state machine has a fourth state, git + remote. It is not here because a remote is a
/// property of a repository the app has already decided it manages remote detection, tracking and
/// the ahead/behind badge are pro-m2's, behind this same seam. What this enum answers is the
/// question every later surface starts from: does the app manage git for this board at all.
public enum BoardGitMode: String, Sendable, Equatable, CaseIterable {
/// No `.git` at the board root and none above it **every** ancestor check answered not-found,
/// "clean none" in 06's own words. The mode of every board nobody has opted into git for which
/// is what a board without app-managed git *is* now that the tier axis is gone (12-editions.md
/// PIVOT 2026-08-07; it used to be the only mode the free tier shipped, over the retired
/// inert-`.git` posture). The one add-git moves a board out of, and now that this axis exists the one mode
/// add-git's own re-detection requires before it will act: a raced or stale read that turns out
/// to be `.unverifiable` or `.repoNested` refuses the init exactly as those modes always did.
case none
/// A `.git` at the board root: the app manages this board's history. Reached two ways and they
/// are indistinguishable by design the app's own add-git (opt-in init), or **adoption**, "a
/// board whose root already contains `.git` opens in git mode, silently the repo's presence
/// *is* the opt-in" (06 Rules).
case git
/// No `.git` at the board root, but one was found at an ancestor **certain**, found on the
/// walk rather than inferred: the board lives inside somebody else's repository, which the app
/// "leaves strictly alone" (06 Rules). The popover says so in prose the add-git action is
/// absent because it cannot apply, never hidden or greyed.
case repoNested
/// No `.git` was found at the board root or any ancestor, but at least one check along the way
/// was **denied** (`EACCES`/`EPERM`) rather than answered the sandbox refusing to say whether
/// an ancestor above its grant carries a repository (06 Rules Detection, "Denial is not
/// absence", ruled 2026-07-31). Structurally this takes `repoNested`'s posture: no add-git, no
/// app-managed git anywhere, `BoardGitSetupSection.resolve` empty a denial can never be told
/// apart from a repository actually being there, so the conservative posture is the only honest
/// one. Its prose is its own: the popover explains that Lanework could not verify whether the
/// board sits inside a repository, never the `repoNested` sentence verbatim denial is not
/// nesting.
case unverifiable
}
// MARK: - Detection
public extension BoardGitMode {
/// **What a `.git` path check reported** `stat(2)`'s errno, classified into the three answers
/// 06's ruling cares about. `exists`/`absent` are the two an unsandboxed filesystem check would
/// ever produce; `denied` is what "Denial is not absence" exists to pull apart from `absent`: a
/// check the sandbox refuses to answer must never read as "no repo there".
enum GitEntryProbe: Sendable, Equatable {
case exists
case absent
case denied
}
/// Probes whether `url` directly contains a `.git`, **whatever kind of node that is** (a
/// directory in an ordinary repository, a plain file `gitdir: ` in a linked worktree or a
/// submodule; both are repositories to git, so both are `.exists` here). `stat`, not `lstat`, so
/// a `.git` that is itself a symlink resolves the way `FileManager.fileExists` always has
/// a broken symlink reads `.absent`, never a false `.exists`.
///
/// **Classification is deliberately narrow**: `ENOENT`/`ENOTDIR` is an honest absence,
/// `EACCES`/`EPERM` is a sandbox denial, and **every other errno reads as `.absent`, not
/// `.denied`** `ELOOP` (a symlink cycle), `ENAMETOOLONG` and the rest are honest reports about
/// the path itself, not the sandbox refusing to look, and folding them into `.denied` would widen
/// `.unverifiable` past what the ruling is actually about. Only `EACCES`/`EPERM` name a refusal
/// to check.
static func probeGitEntry(at url: URL) -> GitEntryProbe {
let gitURL = url.appendingPathComponent(".git")
var info = stat()
let (status, failureErrno): (Int32, Int32) = gitURL.withUnsafeFileSystemRepresentation { representation in
guard let representation else { return (-1, ENOENT) }
let result = stat(representation, &info)
return (result, result == 0 ? 0 : errno)
}
if status == 0 { return .exists }
switch failureErrno {
case ENOENT, ENOTDIR:
return .absent
case EACCES, EPERM:
return .denied
default:
return .absent
}
}
/// Whether `url` directly contains a `.git` the boolean-shaped convenience for call sites
/// outside detection that only ever act on a board already known to be in git mode (the
/// `GitBranchOperation`/`GitCommitOperation`/`GitHeadSnapshot`/`GitHistoryWalk`/
/// `GitHousekeeping` family's guards): `.exists` is `true`, `.absent` and `.denied` alike are
/// `false`, since neither leaves an entry there to use.
///
/// **Detection itself never calls this.** `detect(boardRoot:)` reads `probeGitEntry` directly so
/// a denial can surface as `.unverifiable` instead of silently collapsing to `false` here.
static func hasGitEntry(at url: URL) -> Bool {
probeGitEntry(at: url) == .exists
}
/// The result of walking `boardRoot`'s ancestors for an enclosing repository: the nearest one
/// found, if any, and whether a probe anywhere along the way was denied.
struct AncestorWalk: Sendable, Equatable {
/// The nearest ancestor carrying a `.git`, or `nil` when none was found **certain either
/// way**, regardless of whether a *nearer* ancestor's probe was denied (06 Rules
/// Detection: "a farther ancestor showing `.git` makes repo-nested certain regardless of the
/// denied nearer one nearest-wins only affects which root you'd name, not whether one
/// exists").
public let root: URL?
/// Whether any ancestor probe on the walk answered denied, whether or not the walk
/// ultimately found a `.git`. A denial never ends the walk early it is recorded and the
/// walk continues past it, because only the *complete* walk can tell `.repoNested`
/// (something was found) from `.unverifiable` (nothing was found, but something couldn't be
/// checked) from clean `.none` (everything answered not-found).
public let sawDenial: Bool
}
/// Walks the ancestors above `boardRoot` for the nearest `.git`, denial-aware `detect`'s own
/// ancestor half, exposed because `enclosingRepositoryRoot` and `detect` are both one walk.
///
/// **The walk runs on plain path strings, never on `URL`s** carried over from the pathfinder,
/// where the URL version was a shipped hang. URLs arriving from AppKit surfaces (save panel,
/// bookmark resolution, window restoration) are NSURL-bridged, and for those
/// `deletingLastPathComponent` above `/` grows `/..` forever instead of reaching a fixed point
/// the way native Swift URLs do: the loop never terminated in the app (one core pegged, no repo
/// ever detected) while URL-based unit tests passed. `NSString`'s path math is a pure string
/// operation that terminates at `/` regardless of where the URL came from.
static func ancestorWalk(above boardRoot: URL) -> AncestorWalk {
var sawDenial = false
var path = (boardRoot.standardizedFileURL.path as NSString).deletingLastPathComponent
while !path.isEmpty {
let candidate = URL(fileURLWithPath: path, isDirectory: true)
switch probeGitEntry(at: candidate) {
case .exists:
return AncestorWalk(root: candidate, sawDenial: sawDenial)
case .denied:
// Denial does not end the walk: a farther ancestor's `.git` still makes repo-nested
// certain (the doc comment above). Recorded, and the walk continues past it.
sawDenial = true
case .absent:
break
}
if path == "/" { break }
path = (path as NSString).deletingLastPathComponent
}
return AncestorWalk(root: nil, sawDenial: sawDenial)
}
/// The nearest ancestor of `boardRoot` that carries a `.git`, or `nil` when the walk found
/// none the repo-nested half of detection, exposed because the popover's honest explanation is
/// about a repository that exists somewhere specific, and a later card may well want to name it.
///
/// **Existence only.** A denial recorded along the way is not observable through this call
/// `ancestorWalk(above:)` above is the sibling that reports it, and is what `detect` itself
/// calls; this stays the narrower question it always answered, unchanged in shape by this axis.
static func enclosingRepositoryRoot(above boardRoot: URL) -> URL? {
ancestorWalk(above: boardRoot).root
}
/// **Nearest-`.git`-wins, freshly at every board open, denial-aware** (06-history-undo.md
/// Rules Detection):
///
/// - `.git` at the board root `.git`.
/// - The board-root probe itself denied `.unverifiable` can't rule out git mode at the root.
/// - No `.git` at the root: walk the ancestors. Any `.git` found `.repoNested`, **certain
/// regardless of a denied nearer ancestor** (a farther ancestor's `.git` still settles it).
/// - No `.git` found on the walk, but a denial recorded along the way `.unverifiable`.
/// - Every ancestor answered not-found `.none`, genuinely clean.
///
/// ### Open-time only, and this function is the whole of "open-time"
///
/// "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)" (06). Nothing
/// calls this on a reload path, and `FolderWatcher`'s `.git` filtering which exists to ignore
/// git churn is what makes that structural rather than a rule somebody has to keep: there is
/// no event a re-detection could hang off even if one wanted it. The one deliberate mid-session
/// transition is the app's own add-git (`HistoryStore.addGit`), a *commanded* flip, which sets
/// the mode directly rather than re-running this.
///
/// A board can therefore be a different mode at its next open than at this one, and that is the
/// designed behaviour, not a cache to invalidate: "the app just reflects what it finds" which
/// now includes `.unverifiable` clearing to `.none` or `.git` once the sandbox grants visibility
/// it did not have before, or the reverse.
///
/// Pure and total but no longer silent about what it cannot see: a denied check surfaces as
/// `.unverifiable` rather than being folded into `.none`, exactly the distinction "Denial is not
/// absence" exists to draw.
static func detect(boardRoot: URL) -> BoardGitMode {
switch probeGitEntry(at: boardRoot) {
case .exists:
return .git
case .denied:
return .unverifiable
case .absent:
break
}
let walk = ancestorWalk(above: boardRoot)
if walk.root != nil { return .repoNested }
if walk.sawDenial { return .unverifiable }
return .none
}
}
+281
View File
@@ -0,0 +1,281 @@
import Foundation
// MARK: - A harvested receipt
/// **One EchoLedger receipt, copied out for the committer** (02-architecture.md Components
/// EchoLedger; 06-history-undo.md Interaction with external writers).
///
/// ### Why a copy and not a read
///
/// The ledger's receipts are **consumed** by the landing reload that classifies them "one write,
/// one echo", which is what buys the announcer its silence. The committer asks its question two
/// seconds later, by which time several reloads have landed and every receipt for the user's own
/// card edit is gone. Reading the live ledger at flush time would therefore attribute the user's own
/// work to `Lanework External`, which is the one misattribution this whole mechanism exists to
/// prevent.
///
/// So the committer harvests at the **close of each write bracket** the moment a receipt describes
/// a completed write and nothing has had a chance to consume it and keeps its own copy for the
/// life of the debounce window. Supersession still works: a later bracket's harvest overwrites the
/// same key with the newer hash, exactly as the ledger's own `recordWrite` does.
///
/// The satisfaction check stays the ledger's rule, re-applied against disk at commit time, so the
/// two races 02 settles land the same way here: byte-identical foreign bytes over a fresh app write
/// classify app-mediated, and a foreign edit that misses the hash classifies foreign.
public struct HarvestedReceipt: Sendable, Equatable {
public let receipt: EchoLedger.Receipt
/// **Whether the write that dropped it was a heal** the flag 06 (ruled 2026-07-29) keys the
/// third commit class on: "a debounce window holding a scheduled heal's changes alongside anyone
/// else's splits the heal's paths into their own commit".
public let isHeal: Bool
public init(receipt: EchoLedger.Receipt, isHeal: Bool) {
self.receipt = receipt
self.isHeal = isHeal
}
}
// MARK: - The split
/// One debounce window's changed paths, divided into the commits they will become.
///
/// **Three classes, committed in this order** foreign, then heal, then the user's:
///
/// - *Foreign first* is 06's own ordering, stated as a consequence of flush-before-overwrite:
/// "flush-before-overwrite already orders them: foreign first, then the user's overwrite". The log
/// then reads causally what arrived, then what the user did about it.
/// - *Heal in the middle* is a judgment call, recorded: DESIGN fixes the heal's **separation** and
/// not its position. A scheduled heal repairs what a load found, so it follows the foreign change
/// that usually caused it and precedes the user's gesture, which is the order the three actually
/// happened in.
public struct CommitSplit: Sendable, Equatable {
/// Changes nobody vouched for an agent, a text editor, a terminal, or a blind window at launch.
public var foreign: [GitChangedPath] = []
/// The scheduled healers' paths, heal-marked in the ledger by the Writer operations that made
/// them (`EchoLedger.markHeal`).
public var heal: [GitChangedPath] = []
/// The user acting through the app.
public var user: [GitChangedPath] = []
public init() {}
/// One class of one window's changes, ready to become a commit.
public struct Group: Sendable, Equatable {
public let paths: [GitChangedPath]
/// Which class it is carried rather than re-derived, so the planner never has to ask a
/// list whether it contains its own members.
public let kind: Kind
public enum Kind: Sendable, Equatable { case foreign, heal, user }
}
/// The classes in commit order, empty ones dropped what the planner turns into `PlannedCommit`s.
public var ordered: [Group] {
[
Group(paths: foreign, kind: .foreign),
Group(paths: heal, kind: .heal),
Group(paths: user, kind: .user)
].filter { !$0.paths.isEmpty }
}
public var isEmpty: Bool { foreign.isEmpty && heal.isEmpty && user.isEmpty }
}
// MARK: - CommitAttribution
/// **Who a commit is by** (06-history-undo.md Interaction with external writers: "Commit
/// attribution is structural, not just a message convention").
///
/// A pure enum of statics over values: the changed paths, the harvested receipts, and the bytes on
/// disk. Nothing here opens a repository, so every rule below is provable from a fixture rather than
/// from a commit graph.
public enum CommitAttribution {
// MARK: The pinned identities
/// **API, not decoration** (06): "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."
public static let externalAuthorName = "Lanework External"
public static let externalAuthorEmail = "[email protected]"
/// The domain a self-reported `modified-by` stamp authors under "distinct from both the user
/// and the generic external author".
public static let agentEmailDomain = "agents.lanework.invalid"
/// **Who a heal commit is by** (06 Commit messages Healing mutations commit separately, 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).
///
/// It replaced authoring heals as the user, which made the separate commit filterable only by
/// message shape and the shape vocabulary deliberately never says "healed".
public static let integrityAuthorName = "Lanework Integrity"
public static let integrityAuthorEmail = "[email protected]"
/// The frontmatter key a foreign writer refines its own attribution with
/// (01-storage-format.md; 08-agent-integration.md teaches it).
static let modifiedByKey = "modified-by"
public static var externalIdentity: GitIdentity {
GitIdentity(name: externalAuthorName, email: externalAuthorEmail)
}
/// The heal class's author (`integrityAuthorName`). The *committer* beside it is still the user's
/// identity, every time "every commit the app makes, foreign-authored included, records the
/// user's app as its committer" (06).
public static var integrityIdentity: GitIdentity {
GitIdentity(name: integrityAuthorName, email: integrityAuthorEmail)
}
/// **A `modified-by` stamp, as an author** (06): "that commit is authored as **X** with the
/// synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part
/// slugified)".
///
/// The local part is lowercased on top of the slug a judgment call, recorded: DESIGN says
/// "slugified" without fixing case, addresses are conventionally lower, and the guide's own
/// example stamp is `modified-by: claude`. The display name is untouched, so `Claude Code` still
/// renders as `Claude Code <claude-code@agents.lanework.invalid>`.
public static func agentIdentity(named displayName: String) -> GitIdentity {
let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
let local = GitIdentity.addressComponent(name, fallback: "agent").lowercased()
return GitIdentity(name: name.isEmpty ? externalAuthorName : name, email: "\(local)@\(agentEmailDomain)")
}
// MARK: - Classification
/// **Every changed file, sorted into its commit** the per-file rule 06 states, applied to the
/// paths `git status` reported.
///
/// A path is the app's when the ledger holds a receipt for it (or for a folder above it) that
/// **disk still satisfies**; it is a heal when that receipt is heal-marked; it is foreign
/// otherwise. "No receipt anywhere foreign" is the launch-catch-up doctrine and the whole of
/// *the app never vouches for changes it didn't witness*.
///
/// ### Why the walk goes up the folders
///
/// Because the ledger keys some facts at *folders* while git only ever reports *files*. A card
/// the app moved between lanes has one `.move` receipt on its folder and no receipt at all on
/// the `index.md` that travelled inside it; a card the app deleted has one `.absence` receipt on
/// its folder and git reports every file underneath as gone. Asking only the file's own key
/// would classify both as foreign the user's own delete, attributed to an agent.
///
/// The **nearest** receipt wins, so a rewritten `index.md` inside a moved folder answers with
/// its own content receipt rather than with the move above it.
public static func split(
_ paths: [GitChangedPath],
under boardRoot: URL,
receipts: [String: HarvestedReceipt]
) -> CommitSplit {
var split = CommitSplit()
for path in paths {
let absolute = EchoLedger.key(boardRoot.appendingPathComponent(path.path))
switch vouched(forAbsolutePath: absolute, boardRoot: boardRoot, receipts: receipts) {
case .none: split.foreign.append(path)
case .some(true): split.heal.append(path)
case .some(false): split.user.append(path)
}
}
return split
}
/// `nil` when nothing vouches for this path; otherwise whether the vouching receipt was a heal.
private static func vouched(
forAbsolutePath absolute: String,
boardRoot: URL,
receipts: [String: HarvestedReceipt]
) -> Bool? {
let root = EchoLedger.key(boardRoot)
var candidate = absolute
while candidate.hasPrefix(root), candidate.count >= root.count {
if let held = receipts[candidate] {
switch held.receipt {
case let .content(hash):
// Content is a claim about *these* bytes, so only the file's own key may answer
// with it. A content receipt sitting on an ancestor would be a claim about a
// folder's bytes, which is not a thing.
if candidate == absolute {
return hash == hashOfFile(atPath: absolute) ? held.isHeal : nil
}
case .absence:
return exists(candidate) ? nil : held.isHeal
case let .move(from, to):
if candidate == to { return exists(candidate) ? held.isHeal : nil }
if candidate == from { return exists(candidate) ? nil : held.isHeal }
}
}
guard candidate != root else { break }
let parent = (candidate as NSString).deletingLastPathComponent
guard parent != candidate else { break }
candidate = parent
}
return nil
}
// MARK: - The foreign author
/// **`modified-by` refines foreign attribution** (06): the whole rule, as one function.
///
/// > when every file changed in a foreign debounce window carries the same `modified-by: X`,
/// > that commit is authored as **X** Any disagreement between stamps, any unstamped changed
/// > file, or any true deletion in the window falls back to `Lanework External`.
///
/// **A folder move is not a deletion.** A rename's departure end is a file that is gone from
/// disk and has no stamp to read, but it is not "a deletion [that] leaves no file to stamp"
/// its arrival end is right there in the same window, carrying whatever the writer stamped on
/// it. So a paired departure is skipped rather than demoting the window. A bare `mv` that
/// re-stamps nothing still demotes, through the unstamped-file clause, exactly as 06 says it
/// does which is why the agent guide teaches re-stamping on move.
///
/// A window of nothing but rename departures leaves no stamp to agree on and falls back too.
public static func foreignIdentity(for paths: [GitChangedPath], under boardRoot: URL) -> GitIdentity {
var stamps: Set<String> = []
for path in paths {
if path.isDeletion {
guard path.isRename else { return externalIdentity }
continue
}
guard let stamp = modifiedBy(atRelativePath: path.path, under: boardRoot) else {
return externalIdentity
}
stamps.insert(stamp)
}
guard stamps.count == 1, let name = stamps.first else { return externalIdentity }
return agentIdentity(named: name)
}
/// The `modified-by` a changed file carries, or `nil` for a file that carries none **which
/// every non-`index.md` path does, by construction**: a stray, an attachment, and `CLAUDE.md`
/// have no frontmatter to stamp, so they are unstamped changed files and demote the window.
static func modifiedBy(atRelativePath relativePath: String, under boardRoot: URL) -> String? {
guard relativePath == BoardLoader.indexFileName
|| relativePath.hasSuffix("/" + BoardLoader.indexFileName) else { return nil }
let url = boardRoot.appendingPathComponent(relativePath)
guard let text = try? String(contentsOf: url, encoding: .utf8),
let document = try? FrontmatterDocument.parse(text),
let raw = document.rawValue(for: modifiedByKey) else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
// MARK: - Disk
private static func exists(_ path: String) -> Bool {
FileManager.default.fileExists(atPath: path)
}
/// The hash of what is at `path` now, or `nil` when nothing is the same digest the ledger's
/// receipts were minted with, so the comparison is the ledger's own.
private static func hashOfFile(atPath path: String) -> String? {
guard let data = FileManager.default.contents(atPath: path) else { return nil }
return EchoLedger.hash(of: data)
}
}
+148
View File
@@ -0,0 +1,148 @@
import Foundation
// MARK: - Authorship
/// Which of the three classes a commit is the axis the message engine is allowed to know about.
///
/// The composer receives it because 06-history-undo.md Commit messages gives one rule that turns
/// on it (the root commit's fixed subject) and one that deliberately does not: "**Foreign commits
/// speak the same vocabulary.** Origin lives in the author field (structural attribution), not in
/// message prose a foreign move reads 'Move card ' exactly like an app-mediated one". The
/// composer card therefore gets the fact and is expected to ignore it for phrasing; having it means
/// it never has to be plumbed later, and having it *named* means the rule about not using it has
/// something to point at.
public enum CommitAuthorship: Sendable, Equatable {
/// The user acting through the app.
case user
/// A scheduled heal's own commit (ruled 2026-07-29).
case heal
/// Everything else, carrying the author it will be committed under.
case foreign(GitIdentity)
}
// MARK: - The request
/// Everything the message engine is handed for one commit.
///
/// **A struct rather than an argument list**, because the point of this seam is that the semantic
/// composer plugs into it without reshaping the engine: any input it turns out to need joins this
/// type rather than every call site. It did need two `previousSnapshot` and `agentGuideText`, both
/// below and that is exactly what this shape was for.
///
/// **Everything here is a value, and that is the design.** The composer (`CommitMessageEngine`) reads
/// no file and opens no repository: the flush resolves the environment once the board as HEAD has
/// it, the board as the app has it, the guide's bytes and the message is then a pure function of
/// this struct. Resolving once per *flush* rather than once per planned commit also means a
/// three-way-split window materializes HEAD's tree once, not three times.
public struct CommitMessageRequest: Sendable {
/// The board this is a commit in.
public let boardRoot: URL
/// **The changed-path list** (06 Commit messages Non-snapshot files commit too: "beside the
/// snapshot diff it receives the changed-path list, and non-snapshot paths compose *path-shaped
/// events*"), narrowed to the paths *this* commit stages.
public let changedPaths: [GitChangedPath]
/// Which class this commit is.
public let authorship: CommitAuthorship
/// Whether this is the repository's first commit the one commit with a subject of its own
/// ("Initial board state", 06 Rules Abnormal repo states).
public let isRootCommit: Bool
/// **The current half** of the composer's "last-committed vs. current" diff the board as the
/// app last read it, or, where no store is attached, as the flush read it off disk itself.
///
/// `nil` only when neither could answer: a board whose working tree does not load at all, which
/// is a commit that will have to be described by its paths.
public let snapshot: BoardModel?
/// **The last-committed half**: the board as HEAD's tree has it (`GitHeadSnapshot`).
///
/// `nil` on an unborn HEAD where `isRootCommit` already says everything and on a HEAD whose
/// tree does not load as a board. It is read from the repository rather than carried forward from
/// the last commit the app made, because the app is not the only writer and because launch
/// catch-up has no carried value to offer: see `GitHeadSnapshot` for the whole of that argument.
public let previousSnapshot: BoardModel?
/// **The board-root `CLAUDE.md` as it now reads**, when this commit touches it the one
/// non-snapshot file with a subject of its own ("Update agent guide (vN)", 06 Commit messages).
///
/// The *text*, not the version: N is "a pure function of file content"
/// (`AgentGuide.installedVersion`), and keeping the parse on the composer's side is what keeps
/// that rule where the message vocabulary is. `nil` when the guide is not in this commit, cannot
/// be read, or has been deleted.
public let agentGuideText: String?
/// **When each of this commit's comments was created** keyed by the comment folder's
/// board-root-relative path, as `CommitMessageEngine.commentFolder(of:)` spells it.
///
/// The second value on this struct that a *file* has to be read for, and it is here for
/// `agentGuideText`'s reason exactly: "a commit's comment bullets sort chronologically by the
/// comments' own `created`, folder name on ties" (06 Rules Auto-commit, blessed 2026-07-31),
/// and `created` lives in a comment's own `index.md` because comments are window-scoped and the
/// board snapshot never carries them (01-storage-format.md § Enhanced schema). The flush resolves
/// it once (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`) and the engine stays a pure
/// function of values.
///
/// **Missing is normal, not a defect.** A comment whose folder left the tree in this very commit
/// (the close purge), one whose `index.md` does not parse, one written by hand with no `created`
/// at all each is simply absent here and sorts after its dated siblings in folder-name order,
/// which is `CommentThread.sorted`'s own fallback for the same field.
public let commentTimestamps: [String: Date]
public init(
boardRoot: URL,
changedPaths: [GitChangedPath],
authorship: CommitAuthorship,
isRootCommit: Bool,
snapshot: BoardModel?,
previousSnapshot: BoardModel? = nil,
agentGuideText: String? = nil,
commentTimestamps: [String: Date] = [:]
) {
self.boardRoot = boardRoot
self.changedPaths = changedPaths
self.authorship = authorship
self.isRootCommit = isRootCommit
self.snapshot = snapshot
self.previousSnapshot = previousSnapshot
self.agentGuideText = agentGuideText
self.commentTimestamps = commentTimestamps
}
}
// MARK: - The seam
/// **What a commit says** (06-history-undo.md Commit messages).
///
/// The implementation is `SemanticCommitMessage` below, over `CommitMessageEngine`: "a pure, testable
/// function" composing from a structural diff of two board snapshots, with the whole
/// Add/Delete/Move/Rename/Edit vocabulary, plural folding, path-shaped events for non-snapshot files,
/// and the trash pair. The protocol survives its interim purpose because it is still what lets a test
/// inject a fake and assert *when* a message was asked for without asserting what it said.
///
/// `Sendable` because composition runs off the main actor, inside the same detached task that stages
/// and commits the message has to be in hand before `git_commit_create` is called, and none of the
/// work is main-actor work.
public protocol CommitMessageComposing: Sendable {
func message(for request: CommitMessageRequest) -> String
}
// MARK: - The wired composer
/// **The semantic composer**, and the committer's default (`GitAutoCommitter.composer`).
///
/// A one-line conformance over `CommitMessageEngine`, deliberately: the vocabulary is worth a file of
/// its own and nothing about it should have to know that a protocol exists. The type stays because
/// the seam takes an existential, and because a *named* default is what makes "the engine's composer
/// is the semantic one" assertable.
public struct SemanticCommitMessage: CommitMessageComposing {
public init() {}
public func message(for request: CommitMessageRequest) -> String {
CommitMessageEngine.message(for: request)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+342
View File
@@ -0,0 +1,342 @@
import Foundation
import libgit2
import os
// MARK: - Outcome
/// **How a branch operation ended** the four answers 06-history-undo.md gives every app-initiated
/// git operation, in the shape `GitCommitOutcome` already gives the committer's.
///
/// The kinship is deliberate: contention is never an error, a paused repository is a hold rather than
/// a failure, and everything else is a clean failure carrying libgit2's own message ("An operation
/// that fails *cleanly* disk error, refused checkout surfaces as a one-shot banner failure naming
/// the operation and the error, the tree left as it was").
public enum GitBranchOutcome: Sendable, Equatable {
/// HEAD now names this branch and the working tree is its state.
case switched(String)
/// `index.lock` was held. The payload is the lock file's path what an implausibly long wait
/// names (06 Interaction with external writers: "a wait that persists implausibly long names
/// the lock path").
case locked(path: String)
/// The repository is in a state the app does not write in (`GitRepositoryPause`). Branch controls
/// disable in that state, so this is the race a terminal started a merge between the popover
/// rendering and the click landing.
case held(GitRepositoryPause)
/// A clean failure: a refused checkout, an unwritable object store, a name that is not a branch.
/// **The tree is untouched** libgit2's safe checkout either applies wholly or refuses.
case failed(GitOperationFailure)
}
// MARK: - GitBranchOperation
/// **Branch switching and create-and-switch, over the bundled libgit2** (06-history-undo.md Branch
/// switching) the repository half of the operation, with nothing in it that knows about editors,
/// banners, or the undo stack.
///
/// ### The checkout is `SAFE`, and that is the whole safety story
///
/// `git_checkout_tree` with `GIT_CHECKOUT_SAFE` "allows safe updates that cannot overwrite
/// uncommitted data": a working tree carrying changes that conflict with the target refuses the
/// checkout wholesale (`GIT_ECONFLICT`) and leaves every byte where it was. Nothing here ever passes
/// `GIT_CHECKOUT_FORCE` not on the switch, not on the create-and-switch, and not on the
/// own-leftovers abort, which is the one path that could plausibly want it. That is what makes "a
/// refused checkout is a clean one-shot failure, tree untouched" a property of the call rather than a
/// promise, and it is checkable by grepping this file for `FORCE`.
///
/// The caller's contract is the other half: the switch runs on a settled tree open Edit sessions
/// settled explicitly, the pending auto-commit flushed so in practice `SAFE` has nothing to refuse
/// ("checkout runs on a truly settled tree: it cannot fail dirty").
///
/// ### Isolation
///
/// `GitCommitOperation`'s rule, unchanged and for its reason: every function is `nonisolated`, opens
/// its own `git_repository`, and frees it in the same synchronous scope. No handle crosses an
/// `await`, a `Task`, or a stored property.
enum GitBranchOperation {
/// What a failure calls itself on the banner in the user's words, not libgit2's.
static let operationName = "Switching branches"
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
/// libgit2's global state `GitCommitOperation.startUp`'s twin, and for its reason.
private static let startUp: Bool = {
git_libgit2_init() >= 0
}()
// MARK: - Reads
/// **Every local branch**, sorted the way a menu should list them.
///
/// Local only: remote-tracking branches are 07-sync-collab.md's, and a picker that offered
/// `origin/main` would be offering a detached HEAD precisely the state 06 pauses the whole git
/// surface for.
///
/// An unborn HEAD answers with an empty list, which is honest: `git init` has created no branch
/// yet, only a symbolic ref naming the one the first commit will make.
nonisolated static func localBranches(at boardRoot: URL) -> [String] {
_ = startUp
guard let repository = open(boardRoot) else { return [] }
defer { git_repository_free(repository) }
var iterator: OpaquePointer?
guard git_branch_iterator_new(&iterator, repository, GIT_BRANCH_LOCAL) == 0, let iterator else {
return []
}
defer { git_branch_iterator_free(iterator) }
var names: [String] = []
var reference: OpaquePointer?
var kind = GIT_BRANCH_LOCAL
while git_branch_next(&reference, &kind, iterator) == 0 {
defer {
reference.map(git_reference_free)
reference = nil
}
var name: UnsafePointer<CChar>?
guard git_branch_name(&name, reference) == 0, let name else { continue }
names.append(String(cString: name))
}
return names.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
/// Whether libgit2 would accept `name` as a branch name `git check-ref-format --branch`'s
/// answer, asked before anything is created so the failure names the input rather than a ref.
nonisolated static func isValidBranchName(_ name: String) -> Bool {
_ = startUp
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
var valid: Int32 = 0
guard git_branch_name_is_valid(&valid, trimmed) == 0 else { return false }
return valid == 1
}
/// Whether a local branch by this name already exists the create path's own refusal, phrased
/// against the name the user typed instead of against libgit2's `GIT_EEXISTS`.
nonisolated static func branchExists(_ name: String, at boardRoot: URL) -> Bool {
localBranches(at: boardRoot).contains(name)
}
/// `.git/index.lock`'s path, for the waiting state that names it.
nonisolated static func indexLockPath(at boardRoot: URL) -> String {
_ = startUp
guard let repository = open(boardRoot) else {
return boardRoot.appendingPathComponent(".git/index.lock").path
}
defer { git_repository_free(repository) }
return gitDirectory(of: repository).appendingPathComponent("index.lock").path
}
// MARK: - The switch
/// **The checkout itself** (06 Branch switching): materialize the branch's tree with the safe
/// strategy, then move HEAD's symbolic ref onto it.
///
/// The order is libgit2's own recommended one and it matters: the checkout's baseline is the
/// *current* HEAD, so the tree is updated against what is actually checked out, and HEAD moves
/// only once the bytes are there. An interruption between the two leaves a tree that matches the
/// target under a HEAD that does not which is exactly the leftover `GitOperationStamp` exists to
/// recognize as the app's own.
///
/// - Parameter allowingPause: whether to proceed against a repository in a pause state. `false`
/// everywhere except the own-leftovers abort, which is 06's one exemption from "the app never
/// mutates repo state it didn't create" see `abort(_:at:)`.
nonisolated static func checkout(
_ branch: String,
at boardRoot: URL,
allowingPause: Bool = false
) -> GitBranchOutcome {
_ = startUp
// The state check runs immediately before the write, never from a caller's earlier read: 06's
// rule is that it runs "again before every flush", and a terminal can start a merge between a
// popover rendering and a click landing.
let reading = GitCommitOperation.reading(at: boardRoot)
if let pause = reading.pause, !allowingPause { return .held(pause) }
if reading.isIndexLocked { return .locked(path: indexLockPath(at: boardRoot)) }
guard let repository = open(boardRoot) else {
return .failed(GitOperationFailure(
operation: operationName,
message: "this board's repository could not be opened"
))
}
defer { git_repository_free(repository) }
let fullName = "refs/heads/" + branch
var reference: OpaquePointer?
guard git_reference_lookup(&reference, repository, fullName) == 0, let reference else {
return .failed(GitOperationFailure(
operation: operationName,
message: "there is no local branch named '\(branch)'"
))
}
defer { git_reference_free(reference) }
var target: OpaquePointer?
guard git_reference_peel(&target, reference, GIT_OBJECT_COMMIT) == 0, let target else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
defer { git_object_free(target) }
var options = git_checkout_options()
guard git_checkout_options_init(&options, UInt32(GIT_CHECKOUT_OPTIONS_VERSION)) == 0 else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
// **`SAFE`, never `FORCE`** see the type's note. The value is libgit2's zero, spelled out
// rather than left implicit so the strategy is visible at the point it is chosen.
options.checkout_strategy = GIT_CHECKOUT_SAFE.rawValue
let checked = git_checkout_tree(repository, target, &options)
guard checked == 0 else { return classify(checked, at: boardRoot) }
guard git_repository_set_head(repository, fullName) == 0 else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
logger.notice("checked out branch \(branch, privacy: .public)")
return .switched(branch)
}
/// **Create-and-switch** (06 Branch switching, and 03-board-ui.md Board popover: "branch
/// switching and creation"): a new branch at the current HEAD, then the ordinary switch onto it.
///
/// **The checkout is not skipped**, though the new branch's tree is HEAD's by construction and the
/// working tree therefore cannot change. The reason is a race the app shares its repository with
/// by design (06 "Two writers, one repository"): an agent's self-commit landing between the
/// branch's creation and the switch moves HEAD, and a `set_head` with no checkout would then leave
/// the working tree describing a commit the new branch does not point at. Running the same
/// checkout every switch runs costs one no-op index write in the ordinary case and is correct in
/// the racing one.
///
/// **An unborn HEAD creates nothing and only moves the symbolic ref** which is exactly what
/// `git checkout -b` does on a repository with no commits: there is no commit to branch from, and
/// the name HEAD points at is the branch the first commit will make (06 Rules Abnormal repo
/// states: "an unborn HEAD is normal git mode").
nonisolated static func createAndSwitch(_ branch: String, at boardRoot: URL) -> GitBranchOutcome {
_ = startUp
let name = branch.trimmingCharacters(in: .whitespacesAndNewlines)
guard isValidBranchName(name) else {
return .failed(GitOperationFailure(
operation: operationName,
message: "'\(branch)' is not a valid branch name"
))
}
guard !branchExists(name, at: boardRoot) else {
return .failed(GitOperationFailure(
operation: operationName,
message: "a branch named '\(name)' already exists"
))
}
let reading = GitCommitOperation.reading(at: boardRoot)
if let pause = reading.pause { return .held(pause) }
if reading.isIndexLocked { return .locked(path: indexLockPath(at: boardRoot)) }
guard let repository = open(boardRoot) else {
return .failed(GitOperationFailure(
operation: operationName,
message: "this board's repository could not be opened"
))
}
if reading.isUnborn {
defer { git_repository_free(repository) }
guard git_repository_set_head(repository, "refs/heads/" + name) == 0 else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
return .switched(name)
}
var created: OpaquePointer?
let outcome: GitBranchOutcome? = {
defer { git_repository_free(repository) }
guard let head = headCommit(of: repository) else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
defer { git_commit_free(head) }
guard git_branch_create(&created, repository, name, head, 0) == 0 else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
created.map(git_reference_free)
return nil
}()
if let outcome { return outcome }
return checkout(name, at: boardRoot)
}
// MARK: - The app's own leftovers
/// **Aborts an interrupted app-run switch** (06 Rules Abnormal repo states: "The one exemption
/// is the app's own leftovers finding a pause state with a matching stamp, the app **aborts its
/// own unfinished operation** to restore the pre-operation state").
///
/// The abort *is* a checkout back to the branch the stamp recorded the interrupted operation ran
/// forwards, so undoing it is running the same operation backwards. It carries `allowingPause`
/// because the leftover it is clearing is precisely a state that would otherwise refuse; that
/// exemption is the stamp's whole purpose, and it is why nothing else in the app passes the flag.
///
/// **Still `SAFE`, still never `FORCE`.** An abort that overwrote uncommitted work to tidy up
/// would be the app losing the user's bytes on its own initiative and "abort discards nothing"
/// is the design's own promise about it. A refused abort therefore stays refused and says so.
///
/// It deliberately does **not** call `git_repository_state_cleanup`: a branch switch never creates
/// `MERGE_HEAD` or a rebase directory, so a leftover of *that* shape is not this operation's even
/// when a stamp is standing, and removing it would be the never-mutate rule broken in the one
/// place the exemption does not reach. (Recorded as a judgment call; the rebase that can leave one
/// is 07-sync-collab.md's pull, whose own abort will own it.)
nonisolated static func abort(_ stamp: GitOperationStamp, at boardRoot: URL) -> GitBranchOutcome {
guard !stamp.fromBranch.isEmpty else {
return .failed(GitOperationFailure(
operation: operationName,
message: "the interrupted operation recorded no branch to return to"
))
}
return checkout(stamp.fromBranch, at: boardRoot, allowingPause: true)
}
// MARK: - Private plumbing
private static func open(_ boardRoot: URL) -> OpaquePointer? {
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
var repository: OpaquePointer?
guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
return repository
}
private static func gitDirectory(of repository: OpaquePointer) -> URL {
URL(fileURLWithPath: string(git_repository_path(repository)) ?? "", isDirectory: true)
}
private static func headCommit(of repository: OpaquePointer) -> OpaquePointer? {
var reference: OpaquePointer?
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
defer { git_reference_free(reference) }
var object: OpaquePointer?
guard git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0 else { return nil }
return object
}
private static func string(_ pointer: UnsafePointer<CChar>?) -> String? {
pointer.map { String(cString: $0) }
}
private static func lastErrorMessage() -> String {
guard let error = git_error_last(), let message = error.pointee.message else {
return "libgit2 reported no reason"
}
return String(cString: message)
}
/// Turns a libgit2 status into the outcome 06 gives it contention apart from failure, exactly as
/// `GitCommitOperation.classify` does for a commit.
private static func classify(_ status: Int32, at boardRoot: URL) -> GitBranchOutcome {
if status == GIT_ELOCKED.rawValue { return .locked(path: indexLockPath(at: boardRoot)) }
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
}
+486
View File
@@ -0,0 +1,486 @@
import Foundation
import os
// MARK: - GitBranchSwitcher
/// **The branch switch, in the order 06-history-undo.md Branch switching fixes it** settle the
/// editors, flush the pending commit, stamp the intent, check out, reseed undo with every step that
/// needs a window, a store, or a banner arriving as a seam.
///
/// ### Why the sequence is an object rather than a method
///
/// Because five of its six steps belong to somebody else. Settling editors is the card windows'
/// (`SessionSettleGate`), flushing is the committer's, bracketing is the store's, reseeding is the
/// undo provider's, and the in-progress row is the banner strip's and 06 fixes the *order* they run
/// in, which is the one thing none of them can hold. `GitHistoryProvider` is the same shape for the
/// same reason, and its seams are wired from the same place (`AppModel.wireGitUndo`).
///
/// A `nil` seam is always the honest degenerate case rather than a disabled feature: a board with no
/// card windows has nothing to settle, a repository-level test has no store to bracket with, and a
/// board whose popover is closed has no spinner to update. The sequence runs the same way through all
/// of them.
///
/// ### What it deliberately does not do
///
/// Nothing remote. Tracking, ahead/behind, Pull, Push and push-on-commit follow the current branch
/// (06 Branch switching) and are 07-sync-collab.md's own card; this object moves HEAD and tells the
/// undo stack, and the remote half will join by reading the same `didSwitch` seam.
@MainActor
@Observable
public final class GitBranchSwitcher {
/// The board this switches branches on in git mode, the repository's working-tree root.
public let boardRoot: URL
// MARK: - Seams
/// **The save-or-discard step, over every open session** (06 Branch switching: "if any open card
/// window has one the switch presents a save-or-discard step").
///
/// Unlike the undo restore's, this gate is **not** narrowed by a diff. A restore materializes only
/// the paths it changes, so a session the diff never touches is genuinely unaffected; a branch
/// switch moves the whole tree out from under every session at once, and the raw-source hazard 06
/// names "its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new
/// branch's card" does not care whether the checkout touched that card at all. So the seam takes
/// no paths, and `SessionSettleGate.settleAll()` is what production passes.
@ObservationIgnored
public var settleSessions: (@MainActor () async -> SessionSettleOutcome)?
/// The pending auto-commit, flushed once the sessions are settled "with sessions settled, the
/// pending auto-commit flushes (flush-before-overwrite) and checkout runs on a truly settled tree:
/// it cannot fail dirty".
@ObservationIgnored
public var flushPendingCommit: (@MainActor () async -> Void)?
/// Stops and restarts the auto-commit debounce around the checkout, so a timer cannot fire
/// mid-materialization. `GitHistoryProvider`'s pair, for its reason.
@ObservationIgnored
public var suspendCommitting: (@MainActor () -> Void)?
@ObservationIgnored
public var resumeCommitting: (@MainActor () -> Void)?
/// **The undo/redo reseed** (06 Branch switching: "The undo/redo stack does not survive a
/// switch. It is discarded and reseeded from the new HEAD's first-parent ancestry redo starts
/// empty") `GitHistoryProvider.reseed`, which is already exactly that.
@ObservationIgnored
public var reseedUndo: (@MainActor () async -> Void)?
/// The store's wholesale bracket: watcher suspended, one full reload at the end, the board locked
/// read-only if that reload fails (02-architecture.md; `BoardStore.performWholesale(announcing:awaiting:)`).
@ObservationIgnored
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
/// The in-progress banner row: begin, relabel (the lock's waiting state), end.
///
/// Three seams rather than one object because the banner is the *store's*, and this type is
/// composed on boards that have none. Relabelling is its own call because a held lock must change
/// what the row says without replacing the row: "contention outlasting the brief retry surfaces as
/// a *waiting* state in the operation's in-progress banner row" the same operation, still
/// running, now explaining itself.
@ObservationIgnored
public var beginProgress: (@MainActor (String) -> UUID)?
@ObservationIgnored
public var updateProgress: (@MainActor (UUID, String) -> Void)?
@ObservationIgnored
public var endProgress: (@MainActor (UUID) -> Void)?
/// A clean failure "surfaces as a one-shot banner failure naming the operation and the error,
/// the tree left as it was" (06 Interaction with external writers).
///
/// The banner rather than an inline caption, deliberately, and 06 draws the line: the
/// form-anchored answer is for operations that answer *at the form* (add-git, verify-remote
/// forms that live in the board popover's Git tab; they moved to the settings sheet with the
/// 2026-07-31 popover/sheet split and came back with the 2026-08-07 reversal),
/// while "the banner enumeration stays the posture for board-wholesale brackets that outlive any
/// one surface" which a branch switch is by construction, since its bracket locks the board and
/// its completion is announced.
///
/// **Which row that is, settled 2026-07-31** (02-architecture.md The banner surface): the
/// one-shot failure class's message-carrying git shape error tone, failure rank, dismissable
/// and untimed. What travels is the operation and the underlying message; the sentence
/// ("Couldn't switch branches ") is `BannerCenter`'s, which is why nothing here composes one.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
/// The own-leftovers recovery's banner (`GitOperationStamp.interruptionMessage`).
///
/// **A warning-tone loss row, not a failure** (02 The banner surface, settled 2026-07-31):
/// "recovery notices report a success, not a failure, and stay warning-tone" the abort put the
/// previous state back, and the row exists so the user learns that it happened.
@ObservationIgnored
public var reportRecovery: (@MainActor (String) -> Void)?
/// The per-board registry's stamp read at open, written before the repository is touched, and
/// cleared when the operation is over (`GitOperationStamp`).
@ObservationIgnored
public var readStamp: (@MainActor () -> GitOperationStamp?)?
@ObservationIgnored
public var writeStamp: (@MainActor (GitOperationStamp?) -> Void)?
/// Whether the git surface is held (`GitAutoCommitter.pause != nil`). The controls disable on it,
/// and this is the pre-flight that keeps a click that raced the render from presenting a modal
/// step for an operation the repository is about to refuse.
@ObservationIgnored
public var isHeld: (@MainActor () -> Bool)?
/// HEAD moved what refreshes the popover's branch line (`HistoryStore.refreshBranch`). Called
/// after a successful switch and after a successful abort, and by nothing else.
@ObservationIgnored
public var didSwitch: (@MainActor () async -> Void)?
// MARK: - Observable state
/// Every local branch, as of the last refresh the picker's contents.
public private(set) var branches: [String] = []
/// Whether a switch is in flight: the controls' disabled state, and the guard that keeps a second
/// click from starting a second checkout.
public private(set) var isSwitching = false
/// The last clean failure, or `nil`. Held beside the banner it is also posted to, so the popover
/// can show what happened while it was open without the banner having to be its only witness.
public private(set) var lastFailure: GitOperationFailure?
/// Folders whose card session the settle step's **Discard** branch just abandoned reverted to
/// HEAD before anything else happens (see `perform`). Filled through `noteDiscarded(cardFolderName:)`,
/// which is how `AppModel`'s gate reports each one.
@ObservationIgnored
private var discardedFolders: Set<String> = []
/// **A settle step discarded this card's session.** "Discard reverts buffers and uncommitted saves
/// to HEAD" the window reverted the buffer, and this is the switch remembering to revert the
/// saves.
public func noteDiscarded(cardFolderName: String) {
discardedFolders.insert(cardFolderName)
}
// MARK: - Tunables
/// The brief, silent backoff: "pull, push, branch switch, and undo restore meeting a held lock
/// wait and retry briefly, silently" (06 Interaction with external writers). The committer's own
/// numbers, for the committer's reason.
@ObservationIgnored
public var lockRetryDelay: Duration = .milliseconds(120)
@ObservationIgnored
public var lockRetryAttempts = 3
/// The cadence the waiting state retries on, once the brief backoff is spent.
@ObservationIgnored
public var lockWaitInterval: Duration = .seconds(1)
/// How long a wait runs before the row names the lock path "a wait that persists implausibly
/// long names the lock path (a crashed writer's leftover is the user's to clear)".
@ObservationIgnored
public var lockPathNamingDelay: Duration = .seconds(5)
/// **The bound on the wait, recorded as a judgment call.** 06 describes a waiting state that
/// retries on its cadence and never becomes an error dialog; it does not say when or whether
/// it gives up. An unbounded wait would hold the board's wholesale bracket, and with it the
/// read-only lock, for as long as a crashed writer's `index.lock` sits on disk, with no way out
/// but quitting. So the wait ends, generously, at a clean failure that names the lock path the
/// tree untouched, the branch unchanged, the banner explaining exactly what to clear. Never a
/// dialog, never a hammer, and never a board wedged by another process's litter.
@ObservationIgnored
public var lockWaitLimit: Duration = .seconds(30)
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
public init(boardRoot: URL) {
self.boardRoot = boardRoot
}
// MARK: - Phrases
/// The in-progress row while the checkout runs (02-architecture.md The banner surface:
/// "Switching to 'main'").
public static func progressLabel(target: String) -> String {
"Switching to '\(target)'…"
}
/// The bracket's completion announcement (10-accessibility.md Live board announcements:
/// "bracketed operations announce once, at completion").
public static func completionAnnouncement(target: String) -> String {
"Switched to branch '\(target)'"
}
/// The waiting state, and the same sentence once the wait is long enough to name what is holding
/// the lock.
public static let waitingLabel = "Waiting for another writer's git lock"
public static func waitingLabel(path: String) -> String {
"\(waitingLabel) (\(path))"
}
// MARK: - Reads
/// Reloads the branch list what the popover's `.task` calls when it appears, and what every
/// completed operation calls for itself.
public func refreshBranches() async {
let root = boardRoot
branches = await Task.detached(priority: .userInitiated) {
GitBranchOperation.localBranches(at: root)
}.value
}
// MARK: - The two operations
/// **Switches to an existing local branch.** Answers whether HEAD actually moved.
@discardableResult
public func switchTo(_ branch: String) async -> Bool {
await perform(target: branch, creating: false)
}
/// **Creates a branch at the current HEAD and switches to it.**
///
/// The full sequence runs settle step included and that is a judgment call, recorded. The card
/// this was built for allows skipping the settle "only if you can prove the tree cannot change",
/// and the proof does not hold: the new branch is created at whatever HEAD is *at that moment*,
/// and this app shares its repository with self-committing agents by design (06 "Two writers,
/// one repository"), so a commit landing between the flush and the create leaves a working tree
/// that the new branch does not describe. Two smaller reasons point the same way the flush puts
/// pending work on the branch it was made on rather than on the branch that did not exist when it
/// was made, and one sequence is one thing to reason about. The step costs nothing when nothing is
/// dirty: the gate never appears unless a session is actually holding unsaved state.
@discardableResult
public func createAndSwitch(to branch: String) async -> Bool {
await perform(target: branch.trimmingCharacters(in: .whitespacesAndNewlines), creating: true)
}
private func perform(target: String, creating: Bool) async -> Bool {
guard !isSwitching, !target.isEmpty else { return false }
// A held repository disables the controls; this is the click that raced the render.
guard isHeld?() != true else { return false }
isSwitching = true
defer { isSwitching = false }
lastFailure = nil
discardedFolders = []
// **a. Settle the editors first explicitly, never silently** (06 Branch switching). Before
// the bracket, because the step is modal and a modal inside a suspended watcher would hold the
// board read-only for as long as the user took to read it.
if let settleSessions {
switch await settleSessions() {
case .cancelled, .failed:
// "Cancel keeps the current branch and the sessions", and a raw buffer that will not
// validate "cancels the whole switch with focus on the offending window, nothing
// half-switched".
discardedFolders = []
return false
case .proceed:
break
}
}
let root = boardRoot
// **a. Discard's second half**: the windows reverted their buffers, and the *uncommitted
// saves* those sessions left on disk go back to HEAD here before the flush, which would
// otherwise commit them the instant the ended session stopped being staged around
// (`GitRestoreOperation.revertToHead`).
let discarded = discardedFolders
discardedFolders = []
if !discarded.isEmpty {
let reverted = await Task.detached(priority: .userInitiated) {
GitRestoreOperation.revertToHead(folderNames: discarded, at: root)
}.value
guard reverted else {
fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: "this board's repository could not be read"
))
return false
}
}
// **b. Flush the pending auto-commit** the tree is settled from here on.
await flushPendingCommit?()
// **c. Stamp the intent, before the repository is touched** (06 Rules Abnormal repo
// states). Everything above this line is app-side; everything below can be interrupted.
let head = await Task.detached(priority: .userInitiated) {
GitHistoryWalk.headOID(at: root)
}.value
let current = await Task.detached(priority: .userInitiated) {
GitRepository.branchName(at: root)
}.value
writeStamp?(GitOperationStamp(
fromBranch: current ?? "",
toBranch: target,
headOID: head
))
// **d. The checkout, bracketed** watcher suspended, one full reload at the end, the board
// locked read-only if that reload fails.
let progress = beginProgress?(Self.progressLabel(target: target))
var landed = false
let work: @MainActor () async -> Void = { [weak self] in
guard let self else { return }
self.suspendCommitting?()
defer { self.resumeCommitting?() }
switch await self.runWaitingOutLocks(target: target, creating: creating, progress: progress) {
case .switched:
landed = true
// **e. Reseed undo/redo from the new HEAD**, inside the bracket: the stack must never
// be readable in a state where it describes the branch that is no longer checked out.
await self.reseedUndo?()
case let .failed(failure):
self.fail(failure)
case let .held(pause):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: pause.explanation
))
case let .locked(path):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: "another program is still using this repository's index (\(path))"
))
}
}
if let runBracketed {
await runBracketed(Self.completionAnnouncement(target: target), work)
} else {
await work()
}
// The operation is over, whichever way it went: a clean failure left the tree exactly as it
// was, so there is nothing for a later open to abort.
writeStamp?(nil)
if let progress { endProgress?(progress) }
await didSwitch?()
await refreshBranches()
return landed
}
/// The checkout, with 06's lock posture around it: brief silent retries, then a waiting state in
/// the operation's own row, then at `lockWaitLimit` a clean failure naming the lock path.
private func runWaitingOutLocks(
target: String,
creating: Bool,
progress: UUID?
) async -> GitBranchOutcome {
let root = boardRoot
let startedWaiting = ContinuousClock.now
var attempt = 0
var announced = false
var named = false
while true {
let outcome = await Task.detached(priority: .userInitiated) {
creating
? GitBranchOperation.createAndSwitch(target, at: root)
: GitBranchOperation.checkout(target, at: root)
}.value
guard case let .locked(path) = outcome else { return outcome }
attempt += 1
if attempt <= max(0, lockRetryAttempts) {
// Brief and silent: "a held lock is another writer doing its job".
try? await Task.sleep(for: lockRetryDelay)
continue
}
let waited = ContinuousClock.now - startedWaiting
guard waited < lockWaitLimit else {
return .locked(path: path)
}
if !announced, let progress {
updateProgress?(progress, Self.waitingLabel)
announced = true
}
if !named, waited >= lockPathNamingDelay, let progress {
updateProgress?(progress, Self.waitingLabel(path: path))
named = true
}
try? await Task.sleep(for: lockWaitInterval)
}
}
// MARK: - The app's own leftovers
/// **Recovers an interrupted app-run switch, at board open** (06 Rules Abnormal repo states).
///
/// Called once per session, beside the committer's start which is where the pause it is looking
/// for is first knowable, and before any of it reaches a user. Three outcomes, all of
/// `GitOperationRecovery`'s: nothing to do, a stale stamp dropped silently, or the app's own
/// leftover aborted with a banner.
///
/// **A failed abort keeps the stamp**, which is this file's second judgment call. 06 says the app
/// "aborts its own unfinished operation then clears the stamp"; that sentence describes the
/// abort that worked. An abort refused by a conflicting working tree has restored nothing, and
/// clearing the stamp would demote the leftover to somebody else's on the next open the app
/// would then defer forever to an operation only it ever started. So the stamp stands, the failure
/// is surfaced, and the next open tries again.
public func recoverInterruptedOperation() async {
guard let stamp = readStamp?() else { return }
let root = boardRoot
let pause = await Task.detached(priority: .userInitiated) {
GitCommitOperation.reading(at: root).pause
}.value
switch GitOperationRecovery.decide(stamp: stamp, pause: pause) {
case .nothingToDo:
return
case .clearStamp:
writeStamp?(nil)
case let .abort(stamp):
Self.logger.notice("aborting this app's own interrupted branch switch")
var restored = false
let work: @MainActor () async -> Void = { [weak self] in
guard let self else { return }
self.suspendCommitting?()
defer { self.resumeCommitting?() }
let outcome = await Task.detached(priority: .userInitiated) {
GitBranchOperation.abort(stamp, at: root)
}.value
switch outcome {
case .switched:
restored = true
await self.reseedUndo?()
case let .failed(failure):
self.fail(failure)
case let .held(pause):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: pause.explanation
))
case let .locked(path):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: "another program is using this repository's index (\(path))"
))
}
}
if let runBracketed {
await runBracketed(GitOperationStamp.interruptionMessage, work)
} else {
await work()
}
guard restored else { return }
writeStamp?(nil)
reportRecovery?(GitOperationStamp.interruptionMessage)
await didSwitch?()
}
await refreshBranches()
}
// MARK: - Failure
private func fail(_ failure: GitOperationFailure) {
lastFailure = failure
Self.logger.error("branch operation failed: \(failure.description, privacy: .public)")
reportFailure?(failure)
}
}
+828
View File
@@ -0,0 +1,828 @@
import Foundation
import libgit2
import os
// MARK: - Repository state
/// **A repo state the auto-committer holds for** (06-history-undo.md Rules Abnormal repo
/// states): "a detached HEAD, or an in-progress merge/rebase/cherry-pick left by outside-the-app
/// git pauses the git surface honestly auto-commit holds".
///
/// **Unborn HEAD is deliberately absent.** It is *normal* git mode "the first auto-commit creates
/// the root commit on the branch HEAD names, and the undo trail simply starts empty" so it is a
/// fact about how the next commit is shaped (`GitRepository.initialCommitSubject`), never a reason
/// to stop.
///
/// Seven of the cases are libgit2's own `git_repository_state`, which reads exactly the marker files
/// 06 names (`MERGE_HEAD`, `rebase-merge/`, `rebase-apply/`, `CHERRY_PICK_HEAD`) plus the two this
/// version has no story for but must not commit over either (`REVERT_HEAD`, `BISECT_LOG`).
///
/// **The eighth is the app's own reading, and it is a pause by ruling** (06 Rules, "A `.git` that
/// isn't a valid repository still reads as git mode and fails loudly", ruled 2026-07-31): a `.git`
/// libgit2 cannot open at all is not a repository *state* there is no repository to be in one
/// but the posture it calls for is this one, verbatim: "the whole git surface paused (the
/// abnormal-states posture below)". Putting it in this vocabulary is what makes that true
/// structurally rather than by a rule somebody has to keep: every consumer of a pause already holds
/// the auto-commit debounce (`GitAutoCommitter.execute`), disables Undo/Redo and the branch controls
/// (`GitHistoryProvider.isHeld`, `GitBranchSwitcher.perform`), skips housekeeping
/// (`GitHousekeeper.runNow`), and names the state in the popover so `.unreadable` inherits all of
/// it by construction, including the standing pause's own 15 s re-read, which is what heals it
/// mid-session (`GitAutoCommitter.holdRecheckInterval`).
public enum GitRepositoryPause: String, Sendable, Equatable, CaseIterable {
case detachedHead
case merge
case revert
case cherryPick
case bisect
case rebase
case applyMailbox
/// **The repository could not be opened** a corrupt `.git`, a worktree pointer aimed at
/// nothing, or a repository this engine has no support for (a SHA-256 one, 06 Repository
/// hygiene: "an adopted SHA-256 repo the engine cannot open takes the corrupt-repo loud-failure
/// path"). Never a fall to mode none: detection is presence-shaped, so the board stays in git
/// mode and this is what git mode *reads* like while the repository is unreadable.
case unreadable
/// What the popover will say **the branch-switching card's surface, phrased here** so the
/// engine-side hold and the sentence that explains it cannot drift apart (06 Rules Abnormal
/// repo states: "the popover's git section names the state plainly and says resolving it
/// belongs to the tool that created it").
public var explanation: String {
switch self {
case .detachedHead: "HEAD is detached — commits would belong to no branch"
case .merge: "a merge is in progress"
case .revert: "a revert is in progress"
case .cherryPick: "a cherry-pick is in progress"
case .bisect: "a bisect is in progress"
case .rebase: "a rebase is in progress"
case .applyMailbox: "a patch application is in progress"
// The clause the failure family reads with ("Adding git to this board failed: ",
// `GitBranchOperation`'s held case), in the same voice as its siblings. The *popover's*
// sentence for this state is its own and says more (`BoardGitBranchSurface.unreadableNote`):
// unlike every pause above it, nothing is in progress and no tool is coming to finish it.
case .unreadable: "this board's git repository can't be read"
}
}
}
/// What one look at the repository found, before any staging is attempted.
public struct GitRepositoryReading: Sendable, Equatable {
/// The pause 06 holds for, or `nil` when the repository is in a state the committer may write in.
public let pause: GitRepositoryPause?
/// Whether HEAD names a branch that has no commits yet normal git mode, and the one thing that
/// makes the next commit a root commit.
public let isUnborn: Bool
/// Whether `index.lock` is held right now. Read as a file rather than inferred from a failure so
/// the committer can back off *before* it has written anything (06 Interaction with external
/// writers: "`index.lock` contention is never an error").
public let isIndexLocked: Bool
public init(pause: GitRepositoryPause?, isUnborn: Bool, isIndexLocked: Bool) {
self.pause = pause
self.isUnborn = isUnborn
self.isIndexLocked = isIndexLocked
}
}
// MARK: - Changed paths
/// One path `git status` reports as differing between HEAD and the working tree.
///
/// Board-root-relative and file-granular, which is the unit both consumers want: staging adds or
/// removes exactly these, and attribution asks a question per *file* (06 Interaction with external
/// writers: "classify every observed change, per file").
public struct GitChangedPath: Sendable, Equatable, Hashable {
/// The path, relative to the board root, in git's own spelling (`/` separators, no leading dot).
public let path: String
/// Whether the file is **gone** from the working tree.
///
/// The `modified-by` rule turns on this bit "any true deletion in the window falls back to
/// `Lanework External` a deletion leaves no file to stamp" which is why the rename half
/// below is a separate fact rather than folded in here.
public let isDeletion: Bool
/// Whether this path is one end of a **rename** libgit2 paired up.
///
/// "**A folder move is not a deletion**: items match by id across the whole board so a moved
/// card attributes by its stamp like any changed file" (06). A paired departure is therefore a
/// deletion on disk that the window must not be demoted by.
public let isRename: Bool
/// Whether the path is **new in this commit** git's own `GIT_DELTA_ADDED` (and a rename's
/// arriving end), surfaced rather than inferred.
///
/// It exists for the comment verb family (01-storage-format.md § Enhanced schema): comments are
/// window-scoped and the board snapshot never carries them, so "Comment on 'X'" and "Edit comment
/// on 'X'" cannot be told apart by a diff of two snapshots the only thing that distinguishes a
/// comment folder arriving from one being rewritten is whether HEAD already had it, which is
/// exactly the question this diff already answered.
public let isArrival: Bool
public init(path: String, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) {
self.path = path
self.isDeletion = isDeletion
self.isRename = isRename
self.isArrival = isArrival
}
}
// MARK: - A planned commit
/// **Which of 06's classes a planned commit belongs to** carried through the libgit2 work so a
/// landed commit can be recognized by the class that planned it.
///
/// It exists for one consumer: the undo provider's **heal transparency** (06-history-undo.md Rules
/// Heal commits are transparent to undo, in-session: "heal-class commits their paths known by the
/// Writer's heal-marked receipts never become undo steps"). Receipts live on the main actor and are
/// cleared the moment a window commits, so the only way the stack can ever learn *which commit* was
/// the heal is to be told at the moment it lands.
///
/// A tag rather than a re-derivation, deliberately: a plan whose staging produced HEAD's tree is
/// skipped and lands no commit at all, so the oids that come back are not positionally alignable with
/// the plans that were submitted.
public enum PlannedCommitKind: String, Sendable, Equatable, CaseIterable {
/// A repository's first commit "Initial board state", never split (06 Rules Abnormal repo
/// states).
case root
case foreign
case heal
case user
}
/// One commit that actually landed: its oid, and the class of the plan that made it.
public struct GitLandedCommit: Sendable, Equatable {
public let oid: String
public let kind: PlannedCommitKind
public init(oid: String, kind: PlannedCommitKind) {
self.oid = oid
self.kind = kind
}
}
/// One commit a flush intends to make: which paths it stages, what it says, and who it is by.
///
/// A value rather than a call, because the flush's whole decision the three-way split, the
/// ordering, the authorship is made on the main actor from state the committer holds, and the
/// libgit2 work is then a pure function of these (06 Interaction with external writers: the
/// two-commit split; ruled 2026-07-29: the heal's third class).
public struct PlannedCommit: Sendable, Equatable {
/// Board-root-relative paths, exactly as `GitChangedPath.path` spells them.
public let paths: [String]
public let message: String
/// **Who the change is by** the user, `Lanework External`, or a `modified-by` agent.
public let author: GitIdentity
/// **Who made the commit** always this machine's user identity.
///
/// A judgment call, recorded: 06 pins the *author* ("foreign changes are committed under the
/// pinned synthetic author so any git client can filter, log, and blame by origin" and both
/// `git log --author` and `git blame` read the author field) and says nothing about the
/// committer. Git's own convention for recording somebody else's change `git am`, cherry-pick,
/// every forge's merge button keeps the author as the originator and names the actor who
/// created the commit as committer, which is honestly what happened here: Lanework, running as
/// this user, wrote it. Setting both to the synthetic identity would claim the repository made
/// itself.
public let committer: GitIdentity
/// Which of 06's classes planned this carried so the landed commit can be recognized by it.
/// See `PlannedCommitKind`; defaulted so a caller with only one class to make (add-git's root
/// commit, the undo provider's restore) says nothing about a split it is not part of.
public let kind: PlannedCommitKind
public init(
paths: [String],
message: String,
author: GitIdentity,
committer: GitIdentity,
kind: PlannedCommitKind = .user
) {
self.paths = paths
self.message = message
self.author = author
self.committer = committer
self.kind = kind
}
}
/// How a flush ended the four outcomes 06 gives the committer, and no fifth.
public enum GitCommitOutcome: Sendable, Equatable {
/// One commit per planned commit that had anything in it, oldest first each carrying the class
/// of the plan that made it (`PlannedCommitKind`), which is how heal transparency reaches the
/// undo stack.
case committed([GitLandedCommit])
/// **The happy path, not a malfunction** (06 Interaction with external writers): the tree had
/// nothing to commit an agent already committed its own work, or the window held only paths
/// staged around.
case nothingToCommit
/// **Never an error** (06): `index.lock` was held and stayed held through the brief retry. The
/// caller re-debounces; nothing is surfaced.
case locked
/// The repository is in a state the app does not write in (`GitRepositoryPause`). Edits keep
/// landing on disk and commit as one settled batch when it clears.
case held(GitRepositoryPause)
/// A genuine failure disk full, corruption. Surfaced per 02-architecture.md Write-failure
/// surfacing and retried on the next debounce.
case failed(GitOperationFailure)
}
// MARK: - GitCommitOperation
/// **The signature-capable commit path** (06-history-undo.md Interaction with external writers:
/// "Commit attribution is structural, not just a message convention"), written against the vendored
/// libgit2 C API directly.
///
/// ### Why it is not SwiftGitX
///
/// SwiftGitX 0.4.0's `Repository.commit(message:)` takes no signature: its `CommitOptions` leaves
/// `author` and `committer` null, so libgit2 falls back to `git_signature_default`, which resolves
/// through the merged config ladder unreadable in the sandbox, and the wrong question anyway
/// (06 rules `~/.gitconfig` out of the identity story entirely). Per-commit authorship is this
/// card's whole point: the user's identity on user-driven commits, `Lanework External` on foreign
/// ones, a `modified-by` agent's on stamped ones. None of that is reachable through the wrapper, and
/// `Repository.pointer` is `internal`, so there is no seam to borrow either.
///
/// The module underneath *is* reachable SwiftGitX vendors `libgit2` as a package product, and
/// `project.yml` names the same pin SwiftGitX pins, so this adds an import rather than a second copy
/// of the library. Everything SwiftGitX does well (`GitRepository`'s reads) still goes through it.
///
/// ### Isolation
///
/// `GitRepository`'s rule, unchanged and for its reason: every function here is `nonisolated`, opens
/// its own `git_repository`, and frees it in the same synchronous scope. No handle crosses an
/// `await`, a `Task`, or a stored property, so libgit2 never sees two threads on one handle.
enum GitCommitOperation {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
/// libgit2's global state, brought up exactly once per process.
///
/// SwiftGitX calls `git_libgit2_init` from `Repository.init`/`open` and pairs it with a shutdown
/// in `deinit`, which is a refcount this file must not ride on: a flush can run when no
/// `Repository` is alive. A `static let` is Swift's own run-once, and the matching shutdown is
/// deliberately never called the library stays up for the life of the process, which is what
/// every consumer here wants.
private static let startUp: Bool = {
git_libgit2_init() >= 0
}()
// MARK: - Reading
/// The three facts a flush checks **before every attempt** (06 Rules Abnormal repo states:
/// "the check runs at open and again before every flush, so finishing the operation in a
/// terminal resumes the pipeline without ceremony").
///
/// **A repository that cannot be opened at all is `.unreadable`** a pause, not a shrug (06
/// Rules, the corrupt-`.git` loud failure, ruled 2026-07-31). This line used to answer "no pause,
/// not unborn, not locked" and let the commit attempt that followed fail with libgit2's own
/// message; under the ruling that is exactly backwards the failure must be loud *before* a
/// write is attempted, and nothing may be attempted against a repository the app cannot open
/// ("Lanework leaves the repository untouched").
///
/// Because every caller of this function already branches on `pause`, that one word is the whole
/// of the pause wiring: the flush holds, housekeeping skips, the interrupted-operation recovery
/// defers, and the popover's `refreshPause` learns it.
/// **Presence-shaped, exactly as detection is**: `.unreadable` is what a root `.git` that will
/// not open reads like, and a board with no `.git` at all is not in git mode in the first place
/// it keeps the old no-pause answer, so a caller outside git mode (`GitHousekeeping.run`'s own
/// `.noRepository` reading, a storeless test) is not told a repository it does not have is
/// paused.
nonisolated static func reading(at boardRoot: URL) -> GitRepositoryReading {
_ = startUp
guard let repository = open(boardRoot) else {
return GitRepositoryReading(
pause: BoardGitMode.hasGitEntry(at: boardRoot) ? .unreadable : nil,
isUnborn: false,
isIndexLocked: false
)
}
defer { git_repository_free(repository) }
let locked = isIndexLocked(gitDirectory: gitDirectory(of: repository))
let unborn = git_repository_head_unborn(repository) == 1
// Detached HEAD is asked first because it is the state an unborn repo cannot be in and the
// one `git_repository_state` does not model: libgit2 keeps "what operation is in progress"
// and "where HEAD points" as separate questions.
if !unborn, git_repository_head_detached(repository) == 1 {
return GitRepositoryReading(pause: .detachedHead, isUnborn: false, isIndexLocked: locked)
}
return GitRepositoryReading(pause: pause(of: repository), isUnborn: unborn, isIndexLocked: locked)
}
/// libgit2's `git_repository_state`, mapped to the pauses 06 names.
///
/// It reads the marker files the design lists (`rebase-merge/`, `rebase-apply/`, `MERGE_HEAD`,
/// `REVERT_HEAD`, `CHERRY_PICK_HEAD`, `BISECT_LOG`) which is why a test can plant one file and
/// get the real answer rather than a mocked one.
private static func pause(of repository: OpaquePointer) -> GitRepositoryPause? {
switch git_repository_state(repository) {
case Int32(GIT_REPOSITORY_STATE_NONE.rawValue): nil
case Int32(GIT_REPOSITORY_STATE_MERGE.rawValue): .merge
case Int32(GIT_REPOSITORY_STATE_REVERT.rawValue),
Int32(GIT_REPOSITORY_STATE_REVERT_SEQUENCE.rawValue): .revert
case Int32(GIT_REPOSITORY_STATE_CHERRYPICK.rawValue),
Int32(GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE.rawValue): .cherryPick
case Int32(GIT_REPOSITORY_STATE_BISECT.rawValue): .bisect
case Int32(GIT_REPOSITORY_STATE_APPLY_MAILBOX.rawValue),
Int32(GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE.rawValue): .applyMailbox
// Every rebase flavour reads as one pause: the popover says "a rebase is in progress" and the
// engine holds, and no consumer of either is finer-grained than that.
default: .rebase
}
}
/// **Which paths differ between HEAD and the working tree**, `.gitignore` respected.
///
/// This is the commit's *condition* "its commit condition is the *tree*, not the snapshot
/// diff, so a stray-only window commits rather than leaving the tree dirty" (06 Commit
/// messages Non-snapshot files commit too) and it is also the composer's second input, which
/// is why it comes back as values rather than as a count.
///
/// ### Why it stages into the index rather than reading `git_status`
///
/// Because of one clause: "**A folder move is not a deletion**: items match by id across the
/// whole board so a moved card attributes by its stamp like any changed file" (06). Rename
/// detection is a *similarity* pass over a diff, and libgit2 only runs it where both ends are in
/// one diff `git_status`' `RENAMES_INDEX_TO_WORKDIR` finds a rename made **after** staging, and
/// a plain `mv` in a working tree nobody has staged is simply a delete beside an add. Measured,
/// not assumed: the first cut of this function used status with every rename flag set, and a
/// re-stamped agent move still demoted to `Lanework External`.
///
/// So the diff is taken where the pairing can be seen: everything the working tree says is staged
/// into the **in-memory** index (`git_index_add_all` full `git add -A` semantics, ignores
/// respected, deletions dropped), HEAD's tree is diffed against it, and `git_diff_find_similar`
/// pairs the ends. **Nothing is written**: the index file on disk is untouched, which is what
/// keeps this a read, and the index object is reset to HEAD on the way out so a caller that goes
/// on to stage a *subset* starts from a known base rather than from everything.
/// **`nil` means the survey could not be taken**, which is emphatically not the same answer as
/// "nothing changed" and must never be flattened into it.
///
/// Staging writes blobs into the object store, so a repository whose `.git/objects` has become
/// unwritable fails *here* rather than at the commit and a version of this that shrugged and
/// returned no paths would report a clean tree, no-op silently, and let history stop advancing
/// with nothing on the banner strip. That is precisely the case 06 separates from contention:
/// "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."
/// (Found by test rather than by reading: the failure suite went green-by-silence when discovery
/// moved from `git_status` to staging.)
nonisolated static func surveyChangedPaths(at boardRoot: URL) -> [GitChangedPath]? {
_ = startUp
guard let repository = open(boardRoot) else { return nil }
defer { git_repository_free(repository) }
var index: OpaquePointer?
guard git_repository_index(&index, repository) == 0, let index else { return nil }
defer { git_index_free(index) }
return changedPaths(in: repository, index: index)
}
/// The survey, with "could not look" folded into "nothing to do" for the callers that have no
/// failure channel and want the safe answer: `GitRepository.create`'s branch line, and the tests'
/// clean-tree assertions.
nonisolated static func changedPaths(at boardRoot: URL) -> [GitChangedPath] {
surveyChangedPaths(at: boardRoot) ?? []
}
private static func changedPaths(in repository: OpaquePointer, index: OpaquePointer) -> [GitChangedPath]? {
var pathspec = git_strarray()
guard git_index_add_all(index, &pathspec, GIT_INDEX_ADD_DEFAULT.rawValue, nil, nil) == 0 else {
return nil
}
defer { resetIndexToHead(index, in: repository) }
let parent = headCommit(of: repository)
defer { parent.map(git_commit_free) }
var headTree: OpaquePointer?
if let parent { git_commit_tree(&headTree, parent) }
defer { headTree.map(git_tree_free) }
var diff: OpaquePointer?
var options = git_diff_options()
guard git_diff_options_init(&options, UInt32(GIT_DIFF_OPTIONS_VERSION)) == 0,
git_diff_tree_to_index(&diff, repository, headTree, index, &options) == 0,
let diff else { return nil }
defer { git_diff_free(diff) }
var findOptions = git_diff_find_options()
if git_diff_find_options_init(&findOptions, UInt32(GIT_DIFF_FIND_OPTIONS_VERSION)) == 0 {
findOptions.flags = GIT_DIFF_FIND_RENAMES.rawValue
// Best-effort: a diff too large for the similarity pass simply reports the unpaired
// shape, which demotes the window to the generic external author the safe direction,
// and the one the guide's re-stamping advice already covers.
_ = git_diff_find_similar(diff, &findOptions)
}
var found: [String: GitChangedPath] = [:]
func record(_ path: String?, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) {
guard let path, !path.isEmpty else { return }
let existing = found[path]
found[path] = GitChangedPath(
path: path,
// Present wins where two deltas disagree: staging asks "is it there now", and the
// `modified-by` demotion must not fire for a file the window ends with.
isDeletion: (existing?.isDeletion ?? true) && isDeletion,
isRename: (existing?.isRename ?? false) || isRename,
// New wins, for the mirror of that reason: one delta calling a path an addition is
// enough to know HEAD did not have it, which is the whole content of the bit.
isArrival: (existing?.isArrival ?? false) || isArrival
)
}
for position in 0..<git_diff_num_deltas(diff) {
guard let delta = git_diff_get_delta(diff, position)?.pointee else { continue }
switch delta.status {
case GIT_DELTA_DELETED:
record(string(delta.old_file.path), isDeletion: true, isRename: false)
case GIT_DELTA_RENAMED:
// Both ends, and neither is a deletion the window may be demoted by: the departure
// has to leave the index and the arrival has to enter it. The arriving end is new at
// its path, which is what the comment family reads a post by.
record(string(delta.old_file.path), isDeletion: true, isRename: true)
record(string(delta.new_file.path), isDeletion: false, isRename: true, isArrival: true)
case GIT_DELTA_ADDED, GIT_DELTA_COPIED, GIT_DELTA_UNTRACKED:
record(string(delta.new_file.path), isDeletion: false, isRename: false, isArrival: true)
default:
record(string(delta.new_file.path), isDeletion: false, isRename: false)
}
}
return found.values.sorted { $0.path < $1.path }
}
/// Puts the index object back to exactly HEAD's tree the base every per-class staging works up
/// from, and what makes the discovery pass above a read.
///
/// On an unborn HEAD that is an empty index, which is the same statement with no tree to say it
/// with. Note this is the *in-memory* index: nothing here calls `git_index_write`, so a caller
/// that abandons the flush leaves the file on disk exactly as it found it, staged changes of
/// another writer's included.
private static func resetIndexToHead(_ index: OpaquePointer, in repository: OpaquePointer) {
guard let parent = headCommit(of: repository) else {
git_index_clear(index)
return
}
defer { git_commit_free(parent) }
var tree: OpaquePointer?
guard git_commit_tree(&tree, parent) == 0, let tree else { return }
defer { git_tree_free(tree) }
git_index_read_tree(index, tree)
}
// MARK: - Committing
/// **Stages and commits each plan in turn, with explicit signatures.**
///
/// The plans are committed **in the order given** and each is a whole commit of its own which
/// is how the two-commit split ("A debounce window containing both kinds is split into two
/// commits, never mixed") and the heal's third class (ruled 2026-07-29) become one mechanism
/// rather than three code paths.
///
/// A plan whose staging produces the tree HEAD already has is **skipped, not committed**: an
/// empty commit says nothing and would make `git log` a record of the debounce timer rather than
/// of the board. That is also the clean-tree no-op, arrived at without a special case.
///
/// - Parameter allowRootCommit: whether an unborn HEAD may take its root commit here. The caller
/// passes `true`; it exists so the "first settled change on an adopted unborn repo commits the
/// whole tree as *Initial board state*" rule stays a decision the *planner* made and is not
/// re-derived down here.
nonisolated static func perform(
at boardRoot: URL,
commits: [PlannedCommit],
allowRootCommit: Bool = true
) -> GitCommitOutcome {
_ = startUp
guard !commits.isEmpty else { return .nothingToCommit }
guard let repository = open(boardRoot) else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
defer { git_repository_free(repository) }
// Re-checked here, inside the same handle that is about to write, rather than trusted from
// the caller's earlier `reading(at:)`: between the two a terminal can have started a rebase,
// and 06's rule is that the check runs "again before every flush".
if git_repository_head_unborn(repository) == 1 {
guard allowRootCommit else { return .nothingToCommit }
} else if git_repository_head_detached(repository) == 1 {
return .held(.detachedHead)
}
if let pause = pause(of: repository) { return .held(pause) }
if isIndexLocked(gitDirectory: gitDirectory(of: repository)) { return .locked }
var index: OpaquePointer?
guard git_repository_index(&index, repository) == 0, let index else {
return failure(lastErrorMessage())
}
defer { git_index_free(index) }
// **Every split starts from HEAD, not from whatever the index happened to hold.** Each plan
// below writes the *whole* index as a tree, so a change another writer had staged but not
// committed would otherwise ride into whichever commit came first silently attributing it
// to that class. Resetting makes each commit exactly HEAD plus the paths its own class
// staged, which is what "split into two commits, never mixed" has to mean. The staged change
// is not lost: it is a changed path like any other and is classified and committed on its
// own terms.
resetIndexToHead(index, in: repository)
var landed: [GitLandedCommit] = []
for plan in commits {
switch commit(plan, in: repository, index: index) {
case let .landed(oid):
landed.append(GitLandedCommit(oid: oid, kind: plan.kind))
case .skipped:
continue
case let .stopped(outcome):
// Whatever landed before the failure stays landed those commits are real, and
// reporting them is what lets the caller clear the suspension for the half that
// worked while retrying the rest on the next debounce.
if case let .failed(reason) = outcome, !landed.isEmpty {
logger.error("commit split failed partway: \(reason.message, privacy: .public)")
}
return outcome
}
}
return landed.isEmpty ? .nothingToCommit : .committed(landed)
}
/// What one plan did.
private enum CommitStep {
case landed(String)
/// Its staging produced the tree HEAD already has an empty commit, deliberately not made.
case skipped
case stopped(GitCommitOutcome)
}
/// One plan: stage its paths, write the tree, and create the commit if the tree is new.
private static func commit(
_ plan: PlannedCommit,
in repository: OpaquePointer,
index: OpaquePointer
) -> CommitStep {
// **Path by path, never a pathspec.** `git_index_add_all` would take a glob, and a card
// titled with a `[` in its folder name is a real board; exact `add`/`remove` calls also make
// the stage-around exact an excluded folder is one this loop never mentions, rather than
// one a matcher has to be trusted to miss.
for path in plan.paths {
let exists = FileManager.default.fileExists(
atPath: workdir(of: repository).appendingPathComponent(path).path
)
let status = exists
? git_index_add_bypath(index, path)
: git_index_remove_bypath(index, path)
// `GIT_ENOTFOUND` on a removal is a path the index never had an untracked file that
// vanished inside the window. Nothing to stage and nothing wrong.
guard status == 0 || (!exists && status == GIT_ENOTFOUND.rawValue) else {
return .stopped(classify(status))
}
}
var treeOID = git_oid()
guard git_index_write_tree(&treeOID, index) == 0 else { return .stopped(classify(lastErrorCode())) }
let parent = headCommit(of: repository)
defer { parent.map(git_commit_free) }
if let parent, let headTree = treeIdentity(of: parent), equal(headTree, treeOID) {
return .skipped
}
// The index is persisted **before** the commit, deliberately: this is the call `index.lock`
// bites on, and failing here leaves an unreferenced tree object (garbage libgit2 collects)
// rather than a commit whose index nobody can see.
guard git_index_write(index) == 0 else { return .stopped(classify(lastErrorCode())) }
var tree: OpaquePointer?
guard git_tree_lookup(&tree, repository, &treeOID) == 0, let tree else {
return .stopped(classify(lastErrorCode()))
}
defer { git_tree_free(tree) }
guard let author = signature(plan.author), let committer = signature(plan.committer) else {
return .stopped(failure(lastErrorMessage()))
}
defer {
git_signature_free(author)
git_signature_free(committer)
}
var commitOID = git_oid()
var parents: [OpaquePointer?] = parent.map { [$0] } ?? []
let status = parents.withUnsafeMutableBufferPointer { buffer in
git_commit_create(
&commitOID,
repository,
// "HEAD" rather than a branch name: on an unborn HEAD this creates the branch the
// symbolic ref names, and on a born one it advances whatever branch is checked out
// one call for the root commit and every commit after it.
"HEAD",
author,
committer,
nil,
plan.message,
tree,
buffer.count,
buffer.baseAddress
)
}
guard status == 0 else { return .stopped(classify(status)) }
return .landed(hex(commitOID))
}
// MARK: - Identity
/// **Where the user's identity comes from, resolved at commit time** (06 Interaction with
/// external writers "Where the user's git identity comes from") repo-local `.git/config`
/// when present, the derived default otherwise.
///
/// **The one place that order lives.** Until this card, `GitRepository.applyIdentity` also
/// encoded it, by *materializing* the resolved identity into the new repository's config so that
/// libgit2's signature-less commit would find something; that was an explicit interim and it is
/// gone. Nothing writes `user.name`/`user.email` any more: the popover's identity fields (a
/// later card) will, because there "the setting *is* the file", and an app that wrote the file
/// on its own could never tell its own default from the user's choice.
///
/// The `.git` directory is libgit2's answer rather than `boardRoot/.git`, so a board whose
/// `.git` is a *file* (a linked worktree `BoardGitMode` counts those as git mode) resolves its
/// real config instead of trying to parse a pointer.
nonisolated static func userIdentity(at boardRoot: URL) -> GitIdentity {
_ = startUp
guard let repository = open(boardRoot) else {
return GitIdentity.resolve(repoLocal: (nil, nil), derived: .derivedDefault())
}
defer { git_repository_free(repository) }
return GitIdentity.resolve(
repoLocal: GitConfigFile.identity(inGitDirectory: gitDirectory(of: repository)),
derived: .derivedDefault()
)
}
/// **What repo-local config actually says** the two values behind the popover's identity fields,
/// each `nil` when the file does not name it (06 Interaction with external writers).
///
/// Deliberately *not* `userIdentity(at:)`: that answers "who will this commit be by", derived
/// default included, and a field pre-filled with a derived value would turn a placeholder into a
/// value the moment the user typed anywhere else in the popover. The fields show what the file
/// says and nothing more; the derived default is their placeholder.
nonisolated static func repoLocalIdentity(at boardRoot: URL) -> (name: String?, email: String?) {
_ = startUp
guard let repository = open(boardRoot) else { return (nil, nil) }
defer { git_repository_free(repository) }
return GitConfigFile.identity(inGitDirectory: gitDirectory(of: repository))
}
/// **Writes the popover's identity fields into repo-local config** the one write of those keys
/// in the app (`GitConfigFile.writeIdentity`, where the file-format rules live).
///
/// The `.git` directory comes from libgit2 rather than from `boardRoot/.git`, for
/// `userIdentity(at:)`'s reason: a board whose `.git` is a *file* (a linked worktree) has its real
/// config somewhere else, and writing beside the pointer would be writing to nothing.
nonisolated static func writeRepoLocalIdentity(
name: String?,
email: String?,
at boardRoot: URL
) -> Result<Void, GitOperationFailure> {
_ = startUp
let operation = "Saving this board's commit identity"
guard let repository = open(boardRoot) else {
return .failure(GitOperationFailure(
operation: operation,
message: "this board's repository could not be opened"
))
}
defer { git_repository_free(repository) }
do {
try GitConfigFile.writeIdentity(
name: name,
email: email,
inGitDirectory: gitDirectory(of: repository)
)
return .success(())
} catch {
return .failure(GitOperationFailure(
operation: operation,
message: (error as NSError).localizedDescription
))
}
}
private static func signature(_ identity: GitIdentity) -> UnsafeMutablePointer<git_signature>? {
var signature: UnsafeMutablePointer<git_signature>?
let now = Date()
let status = git_signature_new(
&signature,
identity.name,
identity.email,
git_time_t(now.timeIntervalSince1970),
Int32(TimeZone.current.secondsFromGMT(for: now) / 60)
)
return status == 0 ? signature : nil
}
// MARK: - index.lock
/// Whether `.git/index.lock` is there right now.
///
/// **Never removed, whatever its age** (06 Interaction with external writers): "a crashed
/// writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's
/// own leftovers". The pathfinder deleted locks older than ten minutes; that heuristic is
/// deliberately not carried over it is precisely a mutation of repo state the app did not
/// create.
nonisolated static func isIndexLocked(at boardRoot: URL) -> Bool {
_ = startUp
guard let repository = open(boardRoot) else { return false }
defer { git_repository_free(repository) }
return isIndexLocked(gitDirectory: gitDirectory(of: repository))
}
private static func isIndexLocked(gitDirectory: URL) -> Bool {
FileManager.default.fileExists(atPath: gitDirectory.appendingPathComponent("index.lock").path)
}
// MARK: - Private plumbing
private static let operationName = "Recording this board's history"
private static func open(_ boardRoot: URL) -> OpaquePointer? {
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
var repository: OpaquePointer?
guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
return repository
}
private static func gitDirectory(of repository: OpaquePointer) -> URL {
URL(fileURLWithPath: string(git_repository_path(repository)) ?? "", isDirectory: true)
}
private static func workdir(of repository: OpaquePointer) -> URL {
URL(fileURLWithPath: string(git_repository_workdir(repository)) ?? "", isDirectory: true)
}
private static func headCommit(of repository: OpaquePointer) -> OpaquePointer? {
var reference: OpaquePointer?
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
defer { git_reference_free(reference) }
var object: OpaquePointer?
guard git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0 else { return nil }
return object
}
private static func treeIdentity(of commit: OpaquePointer) -> git_oid? {
guard let tree = git_commit_tree_id(commit) else { return nil }
return tree.pointee
}
private static func equal(_ lhs: git_oid, _ rhs: git_oid) -> Bool {
var left = lhs
var right = rhs
return git_oid_cmp(&left, &right) == 0
}
private static func hex(_ oid: git_oid) -> String {
var value = oid
var buffer = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1)
git_oid_fmt(&buffer, &value)
return String(cString: buffer)
}
private static func string(_ pointer: UnsafePointer<CChar>?) -> String? {
pointer.map { String(cString: $0) }
}
/// libgit2's message for whatever just failed, or a shrug when it set none.
private static func lastErrorMessage() -> String {
guard let error = git_error_last(), let message = error.pointee.message else {
return "libgit2 reported no reason"
}
return String(cString: message)
}
private static func lastErrorCode() -> Int32 {
git_error_last() != nil ? GIT_ERROR.rawValue : GIT_ERROR.rawValue
}
/// Turns a libgit2 status into the outcome 06 gives it.
///
/// **`GIT_ELOCKED` is the whole reason this exists**: contention is "never an error", so it must
/// not travel the same road as a disk failure. Everything else is a genuine failure carrying
/// libgit2's own message.
private static func classify(_ status: Int32) -> GitCommitOutcome {
status == GIT_ELOCKED.rawValue ? .locked : failure(lastErrorMessage())
}
private static func failure(_ message: String) -> GitCommitOutcome {
.failed(GitOperationFailure(operation: operationName, message: message))
}
}
+158
View File
@@ -0,0 +1,158 @@
import Foundation
import libgit2
import os
// MARK: - GitHeadSnapshot
/// **The last-committed half of the composer's diff** (06-history-undo.md Commit messages: "a
/// structural diff of two board snapshots last-committed vs. current").
///
/// ### Why HEAD's tree, and not a snapshot carried forward
///
/// The engine could remember the board it committed last time and hand that back as "previous". It
/// deliberately does not, for four reasons, each of which is a case the carried value would get
/// wrong:
///
/// - **Launch catch-up has no previous to carry.** "Changes found pending at board open diff HEAD's
/// tree against the working tree through the same composer" (06) at open the app's only snapshot
/// is the one it just loaded, which already *contains* the pending changes. The previous state
/// exists nowhere but in the repository.
/// - **The app is not the only writer.** An agent that commits its own work moves HEAD without the
/// app writing anything; a carried snapshot would diff against a state that is already history.
/// - **A failed or skipped commit does not advance history.** A carried value would advance anyway
/// and silently under-describe the next window.
/// - **It is checkable.** "Last committed" is a fact the repository answers; a carried value is a
/// claim the engine makes about itself, and nothing would ever catch it drifting.
///
/// The cost is this file: HEAD's tree is materialized into a temporary directory and read back
/// through the one `BoardLoader`, so the previous snapshot is produced by exactly the machinery that
/// produced the current one. Re-parsing rather than re-deriving is the point two loaders would be
/// two definitions of what a board is.
///
/// ### What it writes
///
/// **`index.md` blobs in full; every other blob as a zero-byte placeholder.** The snapshot models
/// frontmatter, bodies and *attachment names* never attachment bytes so materializing a board's
/// images would copy megabytes per commit to answer a question about file names. Directories are
/// created so the shape the loader walks is the shape HEAD has.
///
/// ### Isolation
///
/// `GitCommitOperation`'s rule restated: `nonisolated`, opens its own `git_repository`, frees it in
/// the same synchronous scope, and no handle crosses an `await`. Called from inside the flush's
/// detached task, never from the main actor.
enum GitHeadSnapshot {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
/// libgit2's global state `GitCommitOperation.startUp`'s twin and for its reason (a flush can
/// run when no `Repository` is alive, so this file cannot ride on SwiftGitX's refcount).
private static let startUp: Bool = {
git_libgit2_init() >= 0
}()
/// **The board as HEAD has it**, or `nil` when there is nothing to read one from: an unborn HEAD,
/// an unopenable repository, a tree with no board `index.md` in it.
///
/// `nil` is a *shrug*, not an error the composer that receives it simply has no previous half
/// and falls back to describing the commit by its paths. Nothing here can fail a commit.
nonisolated static func load(at boardRoot: URL) -> BoardModel? {
_ = startUp
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
var repository: OpaquePointer?
guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return nil }
defer { git_repository_free(repository) }
guard git_repository_head_unborn(repository) != 1 else { return nil }
var reference: OpaquePointer?
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
defer { git_reference_free(reference) }
var object: OpaquePointer?
guard git_reference_peel(&object, reference, GIT_OBJECT_TREE) == 0, let tree = object else {
return nil
}
defer { git_tree_free(tree) }
let scratch = FileManager.default.temporaryDirectory
.appendingPathComponent("LaneworkHeadSnapshot-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: scratch) }
guard (try? FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)) != nil
else { return nil }
materialize(tree: tree, in: repository, into: scratch, depth: 0)
do {
return try BoardLoader.load(boardRoot: scratch).model
} catch {
// A HEAD whose tree the loader refuses a board committed before `index.md` existed, a
// schema from the future is simply not a previous snapshot. The window still commits;
// its message is composed from the paths alone.
logger.debug("HEAD's tree did not load as a board: \(error.description, privacy: .public)")
return nil
}
}
/// One tree level, recursively. Total and silent: a blob that cannot be read is skipped, because
/// a partial previous snapshot degrades one event's wording while a thrown error would cost the
/// commit its message entirely.
///
/// The depth cap is a guard against a pathological repository, not a statement about boards a
/// board is three levels deep, four counting `attachments/`.
private static func materialize(
tree: OpaquePointer,
in repository: OpaquePointer,
into directory: URL,
depth: Int
) {
guard depth < 8 else { return }
let manager = FileManager.default
for position in 0..<git_tree_entrycount(tree) {
guard let entry = git_tree_entry_byindex(tree, position),
let rawName = git_tree_entry_name(entry) else { continue }
let name = String(cString: rawName)
guard !name.isEmpty, name != ".", name != ".." else { continue }
// A `/` in a tree entry name is impossible in a well-formed tree and would be a path
// escape if it were not: refuse rather than interpret.
guard !name.contains("/") else { continue }
switch git_tree_entry_type(entry) {
case GIT_OBJECT_TREE:
var child: OpaquePointer?
guard let id = git_tree_entry_id(entry),
git_tree_lookup(&child, repository, id) == 0,
let child else { continue }
defer { git_tree_free(child) }
let folder = directory.appendingPathComponent(name, isDirectory: true)
guard (try? manager.createDirectory(at: folder, withIntermediateDirectories: true)) != nil
else { continue }
materialize(tree: child, in: repository, into: folder, depth: depth + 1)
case GIT_OBJECT_BLOB:
let file = directory.appendingPathComponent(name)
// **Content for `index.md`, a placeholder for everything else.** The loader reads
// frontmatter and bodies out of the first and only the *names* of the rest.
guard name == IntegrityRules.indexFileName else {
try? Data().write(to: file)
continue
}
var blob: OpaquePointer?
guard let id = git_tree_entry_id(entry),
git_blob_lookup(&blob, repository, id) == 0,
let blob else { continue }
defer { git_blob_free(blob) }
let size = Int(git_blob_rawsize(blob))
let bytes = git_blob_rawcontent(blob)
let data = (bytes != nil && size > 0)
? Data(bytes: bytes!, count: size)
: Data()
try? data.write(to: file)
default:
// Submodules and symlinks: neither is a board, and neither is followed anywhere else
// in this app either (`BoardLoader.directoryCandidates` excludes links).
continue
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More