# Lanework

A native macOS kanban app whose boards are plain folders and Markdown files. Every board, lane, and card is a folder with an `index.md` (YAML frontmatter + Markdown body); the filesystem is the source of truth and the app is a live view over it.

The defining consequence: anything that can read and write files is a first-class user of a board — text editors, Finder, shell scripts, git, and above all AI agents. Lanework's job is to be the best *human* surface over that substrate.

## Key features

Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/).

- **Validation and healing** — one integrity service owns every rule about what a valid object is (the identity predicate and its canonical form, the per-kind field tables, the reserved-name tables, the trash's `kind` discriminator) and one engine runs every repair that writes. Defects classify into one five-verdict taxonomy — refuse, refuse writes, tolerate, coerce, heal — and the verdict decides everything downstream, so no mechanism re-reasons its posture on its own. Heals are *inline* when a gesture cannot proceed without them, *on-touch* when the defect is latent (a missing `kind` key backfills on any write that was rewriting that file anyway — never a sweep), and *scheduled* when the defect degrades the board while it stands: loose card files, legacy `deleted:` keys, a squatter on a claimed name, and a stale agent guide all run on one engine that defers under the read-only lock, re-verifies against disk before writing, never retries a failure in a loop, and fires at board open and at every reload alike.
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and a reserved `.trash/` container read by the very same card parse the lanes use — below the board root `order` and `schema` are optional (order-less items read append-at-end until a write stamps them), and the board-root `.gitignore`, seeded on every board, is the one noise gate the loose-file relocation heal obeys. One walk reports **every** fail-fast defect at once rather than stopping at the first — the root's own, then each lane and card in walk order, a broken lane's subtree left for the repair's re-check — so a refusal is one aggregate instead of a chain of them, and a per-open set of skipped paths (never persisted, never accepted at the root) loads the board without exactly the items the user chose to leave out. Pinned by a golden fixture suite of 17 on-disk boards.
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes move a card's folder into the board's reserved `.trash/` at a caller-minted top rank, restore is the ordinary move back out, and purge is physical; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy. The one other scope on that promise is the handful of board-root names the app claims: a file or symlink squatting `.trash/`, a folder squatting `CLAUDE.md`, is moved aside by the same Finder-style ladder rather than deleted or worked around, since deletion is broken while it stands.
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. Every open passes through a real loading window — it appears immediately at its saved frame under the registry's cached name, its content a centered spinner behind a ~200 ms grace, while the tree walk runs off the main actor (one walk per board however many windows ask at once), and ⌘W during the walk genuinely cancels it. When an **attended** open refuses, that same content area transforms in place into the **decision surface** — never a sheet, never a chained dialog: the walk's defects grouped by class, each class stating the problem once and listing the affected files with Reveal in Finder and Open in Editor, one class-level choice preselected to its default and a per-file override behind a disclosure. Only honest choices are offered — unparseable frontmatter gets Open in Editor and Re-check (or Skip below the root), a file from a newer Lanework gets Skip alone (and blocks the whole board at the root), a board root with no `index.md` gets a minted index, a root with no `schema` gets a `schema: 1` stamp. Repair and Open applies every chosen fix in one write bracket — ordinary app writes, and on git boards a separate heal-authored repair commit — then re-runs the whole walk, opening on a clean result and re-aggregating into the *same* surface otherwise; Re-check re-walks without writing; Cancel (and ⌘W) retires to welcome's row. Skips are per-open consent that rides the session and is never persisted, and the opened board carries a warning-tone notice naming what was left out, each with its own Reveal in Finder. Restoration failures keep the retire-to-welcome-row landing — repair is an attended act, and launch never chains dialogs.
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a collapse chevron — the whole title bar doubling as the drag surface, a plain click selecting the lane (a second click unselecting) and movement carrying it away. A collapsed lane draws as a slim vertical strip outside the width division — icon, count badge, and a book-spine title reading upward — expanding on a click, taking drops at its end, and carrying its fold when dragged.
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), quiet chips for attachments and comments when it has them, and — when a `hero:` key names one of its attachments — a full-width banner image above the title; otherwise title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, the colour written either way as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex — `background` as a mapping, `{color: fern}`, which is the one shape that field takes. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the chips are the face's whole attachment-and-comment story, and viewing the files themselves is the card window's job.
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.

- **Drag & drop** — cards, in either container, and lanes all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane carries exactly its cards either way, since the trash is board-level and there is nothing lane-nested to strip. Dragging a trash card onto a lane restores it at the drop position — an ordinary move — while dropping it on another board follows the same copy default every cross-board drag does, ⌘ forcing the true restore-move. The same gesture runs the other way: dropping a live card on the shown trash deletes it, exactly as ⌫ would, with the shadow always taking the topmost row — which the rank minting makes honest rather than arbitrary: every arrival really does land above the current top. A lane drag proposes the same delete over the column, and a trashed lane row drags back out to a strip slot the way a trash card drags back into a lane; a foreign board's item isn't deliverable there and neither is ⌥, since copying into the trash isn't a thing. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. The trash column and its cards are inert to them, and a read-only board or an open inline editor refuses them outright.

- **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — remembering your row across lanes (a walk through a three-card lane and back returns to the eighth card, not the third) — with ⇧-arrows extending the range from the anchor and stopping dead at the board/trash and card/lane boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't.

- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a lane carries exactly its cards, copied or moved, because the trash is board-level and there is nothing lane-nested to strip; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The clipboard works on trash cards like on any card — ⌘C yields a live copy wherever you paste it, and ⌘X in the trash followed by ⌘V is the keyboard-native restore, a card into a lane and a trashed lane row after the anchor lane — while paste never targets the trash itself, and the read-only lock blocks cut without ever blocking copy, because copying out is a read. Copy Link on a card's context menu writes the card folder's file URL and plain path in one pasteboard write — a link to the files themselves, ready for notes, scripts, or Terminal. An image on the pasteboard pastes too: ⌘V lands it as a named attachment on the selected card through the ordinary import path, the attachment row's Set as Hero / Remove Hero (with File-menu twins) crowns one as the face's banner, and the board popover's Paste as Board Background gives the same bytes their third home — file-shaped flavors travel byte-for-byte, TIFF re-encodes to PNG, and PNG wins among several.

- **Styling** — one style editor serves every anchor: a background grid of the sixteen palette wells behind a leading None well that *removes* the key (an Other… affordance opens the system Colors panel — a palette-exact pick stores the name, anything else the hex), and a curated symbol grid — boards, lanes, and cards each drawing from their own kanban-relevant shelf — behind a leading level-default well that does the same, with a matching pair of compact color/symbol combo fields and a standalone searchable, categorized symbol browser derived from the OS's own catalog one click deeper. A board can also take any picture as its background via Choose Image… (copied into the app-claimed `.backgrounds/` folder, where replaced images tidy themselves). It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched. **A board can also wear a picture.** `background` is a mapping at every level — `{color: fern}` on a card or a lane — and a board's may name an image beside its colour: `background: {color: "#112233", image: art/sunset.jpg}` paints that image over that colour, scaled to fill, across the **whole window**: the content runs under a transparent title bar with a frosted strip keeping the toolbar and the board-name widget legible on top of it. The path is relative to the board folder, so the picture travels with the document when it is copied, zipped or synced (an absolute path, or one climbing out of the folder, simply paints nothing). Either half may stand alone, the colour shows through while a large photograph decodes off the main thread, and replacing the file in Finder swaps the backdrop live. The board popover's Theme tab dresses the board from presets: a Solid color / Pattern choice over one eight-hue carousel with chevron paging. Solid color filters by tone (defaulting to the current appearance) and saturation, and clicking a flat swatch writes that color and clears the image; Pattern adds color-count (mono, complementary duo, contrasting trio) and mesh-density filters over live previews of a faceted triangle-mesh recipe, with a Reroll button re-minting the geometry — clicking a swatch renders it at full resolution into the board folder as `facets.png` and points `background` at it, with the recipe's primary color written beside it as the fallback underlay. Preset or dragged in, a background is always static pixels — the raw file stays the escape hatch — and a board with no background of its own keeps the standard window chrome exactly as before.

- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. **Lanes delete into the trash too**: the folder travels subtree-intact and shows as one distinct dimmed row carrying its title and held-card count — an opaque unit that never expands, whose cards aren't individually addressable, and which restores whole or purges whole (its confirmation counting the cards it would take with it). The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.

- **Live search** — the board window's toolbar carries one item, a search field (Edit ▸ Find, ⌘F), and typing in it filters the board as you type: a card stays when its title *or* its body contains the query, case- and diacritic-insensitively (so `resume` finds "Résumé"), and everything else animates out under one gentle spring while the survivors reflow. Scope is title and body only — attachment filenames are deliberately not searched. The filter is the single source of truth for what's on the board rather than a highlight over it: the masonry, each lane's count badge, drop zones, the rubber band, ⇧-ranges, Select All, arrow navigation and the ⌥-jumps all read it, and and the shown trash filters like any other lane. Nothing invisible stays selected — a card the query hides leaves the selection the moment it goes, and so does one an agent edits out of the match while you search. The field is a control, not an editor: board commands stay live and act on the selection while you type (⌘N included, which clears the search first so a new card is never born invisible), only ⌘←/⌘→ and ⌥⌘←/⌥⌘→ stand down so they stay caret chords, plain ⌫ edits the query while ⌘⌫ still deletes the selection, and Return is swallowed because a live filter has nothing to submit. Tab hands the keyboard to the board with the query intact. Escape steps out one layer per press — a non-empty field clears, an empty one returns focus to the board, and a board-focused Escape under an active search clears it before it means deselect. A rename is deliberately not a carve-out: rename a card out of the match during a search and it animates away exactly as an agent's edit would.

- **The welcome screen** — branding and two actions on the left, recents on the right: board icon, name, containing folder, and the lane/card counts stamped at last close, newest first. The list never opens a board to build itself, so a huge board or an offline volume costs nothing. Single click selects, double click or Return opens, and a context menu carries Open, Reveal in Finder, and Forget. A board that failed to open or restore says so **on its own row**, in the warning tint, carrying the loader's specifics rather than a modal at launch; a board whose bookmark no longer resolves dims to Unavailable with Open and Reveal off and Forget still live; and a failure naming no known board keeps a list of its own rather than vanishing. File ▸ Open Recent lists the same boards — unavailable ones disabled — with Clear Menu at the bottom, which forgets every record because here the registry *is* the menu.

- **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel seeded with the template's own name; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. **A template is itself a board** — an ordinary schema-valid folder read by the ordinary loader, so its display name, icon, blurb and lanes all come off its own `index.md`, and authoring one is adding a folder rather than writing code. Creating from one copies that folder: fresh GUIDs for every lane and card, `created`/`modified` stamped today (born, not forked from the template), the blurb becoming the new board's description, attachments and card bodies byte for byte, strays and symlinks carried verbatim — and `.git` and `.trash/` deliberately left behind, so a new board is never silently in git mode and never born with trash. Nothing half-made is ever left where you pointed: a create that fails or is cancelled removes its own partial, and a name already taken is refused rather than replaced. The chooser lists **your own templates** after the bundled ten — keyed ones in the order they carry, then keyless boards by name — and a button beside its heading reveals the templates folder in the Finder, creating it if you've never used one: they're plain board folders in there, so dropping a board in makes it a template, no key required, and the app never edits what it didn't write. A folder it can't read is **still listed**, marked and carrying the loader's own sentence, because one bad template must never take the chooser down with it. **File ▸ Save as Template** copies the frontmost board into that folder — pending work flushed first so it misses no keystroke, `.git` and `.trash/` left behind (a template is content, not history, and not a fork), everything else including `CLAUDE.user.md` and every GUID and timestamp carried verbatim, and a chooser position appended after your existing templates. A name already in the folder auto-renames Finder-style ("Roadmap 2") rather than overwriting or refusing, the copy is cancellable from its progress row (cancelling removes the partial), and a quiet line tells you which template you just made. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — `.trash/` carried along so the copy matches its own copied history, strays and timestamps untouched.

- **The card window** — ⌘↩ or a double-click opens a card in its own window: three componentized, independently scrolling panes — a wide body pane, the comments pane (below) when it's shown, and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ › ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar leads with the Attachments section (below) and its remaining sections are stacked headers awaiting their content. Reopening a card focuses the window it already has, and the window closes itself the moment its card stops being on the board — moved to the trash (entering the trash counts as deleted), gone with its deleted lane, purged, or moved to another board; a dirty Edit buffer flushes into the card's new location first, so the keystrokes survive a later restore.

- **Attachments** — the sidebar's first section is the card's complete file inventory: every top-level file of its `attachments/` in Finder order, body-embedded ones included, as compact rows carrying a small QuickLook thumbnail (the file's Finder icon until one is generated, and for anything QuickLook won't preview) beside a middle-truncated filename. The whole window is the drop surface — drag files anywhere in it, Edit mode and raw source included, and they import as attachments with Finder-style renames on collision, because the text editor deliberately declines file drags while dragged *text* still lands at the caret exactly as it always did. Folders refuse at the cursor and a mixed drag imports its files and says how many folders it skipped. File ▸ Add Attachment… (⇧⌘A) and a quiet plus in the section header are the same act from the menu bar and the pointer, both opening a multi-select panel into the same import path the board's own file drops use. The section is keyboard-native: it takes focus, arrows walk the rows, Space QuickLooks the selected one in the system's own panel, Return opens it in its default app, and ⌫ moves it to the **system** Trash — never a hard delete, and deliberately distinct from the board's own trash, which is why a failure there says "Couldn't move 'shot.png' to the Trash". Rows drag out their file URL, so a file goes to Finder or another app with no export path of its own; a right-click offers Open, Reveal in Finder and Remove; and File ▸ Reveal in Finder points at the selected attachment while the section holds focus, the card's folder otherwise. Every write is an ordinary bracketed one — one reload, one commit, one banner on failure — and the read-only lock disables adding and removing in place.

- **The card body — Preview and Edit** — the body is read as a fully rendered Markdown preview and written as raw Markdown, never a WYSIWYG halfway house. Preview lays out headings, emphasis, code, quotes, lists, GFM tables, thematic breaks and images resolved against the card's own folder; HTML shows verbatim as code, remote images never load (Preview does no networking), links open in the browser or the file's default app, and task-list checkboxes are live — clicking one flips exactly that character in the file and touches no other byte. ⌘E toggles View ▸ Edit Body, Return in Preview enters it, Escape leaves it, and a card whose body is empty opens straight into the editor with the cursor ready. Edit is a monospaced editor with lightweight syntax highlighting — headings emphasized, bold and italic styled, code tinted, link targets and structural markers dimmed — that is presentation only: the text stays the raw Markdown character for character, smart quotes and dashes off. It saves ~700 ms after you stop typing, and flushes the moment you leave Edit or close the window, so neither the preview nor the disk ever lags what you typed. ⌘Z is the editor's own undo, scoped to the session; ⌘F is find-in-text over whichever surface is showing. Three write rules keep the file honest: a body nobody touched is never re-serialized (byte-identical on disk, modification date included), an edit typed and then undone is not written, and the app's own save echoing back through the watcher is not written again. If the file changes underneath you while the buffer has unsaved keystrokes, the buffer wins — the board, the preview and every other window take the new version while your text stays exactly where it is, and your save then lands over theirs. A close that cannot save stops and asks: try again, save a copy elsewhere, or discard.

- **Comments** — every card carries a thread of comments, each one a folder of its own beside the card's body, so a comment is a Markdown file an agent can write and a human can read in Finder. **View ▸ Show Comments** puts the pane in every card window and keeps it there — one app-wide setting that persists across launches, with no auto-hiding cleverness: a card with nothing said about it yet shows the empty thread and the composer, because the invitation is the point, and deleting the last comment never closes the pane. **View ▸ Comments Beside Body** chooses where it sits — beside the body by default, or stacked under it at a fixed three-to-two split for narrow displays — and the panes are identical either way; the window's minimum width grows only while the column is beside the body. The thread stays visible through Edit mode, and Raw Source still swaps the whole content area, comments included. Each comment reads as a quiet author line (the self-reported `author`, the timestamp, and "· edited" when it has been), the rendered Markdown body in the card body's own subset — tables, code, quotes, images resolved against the card's folder — and attachment chips with Quick Look. There are no avatars: there is no identity system behind the name, and a comment with no author renders without one rather than with a placeholder standing in. The header carries the count and a sort control that flips the thread between oldest-first and newest-first, app-wide and remembered.

- **The composer, and the draft behind it** — the always-visible text area at the thread's newest end is backed by a real file, `comments/.draft/`, so restore-on-reopen is just the app reading it again: close the window mid-sentence, come back tomorrow, and your half-written comment is where you left it — and it rides git and syncs across machines like anything else in the board. Its saves are deliberately **slow**: on blur, on window close, on quit, and on a lazy half-minute tick — not the body editor's 700 ms — so typing never becomes a stream of commits. Escape moves focus out and touches nothing; the draft is a durable file, so emptying it is the discard gesture, and a draft emptied of text with no files deletes its own folder rather than leaving litter. ⌘↩ posts (a Comment button twins it), which renames the draft to a fresh identity and restamps it in one write — chronology is when you posted, not when you started drafting. File ▸ Add Comment turns the pane on if it's off and puts the cursor in the composer, in one gesture.

- **Editing, deleting, and attaching to comments** — a comment's context menu carries Edit, Delete and Reveal in Finder. **Edit is the body editor in miniature**: an inline session with syntax highlighting and debounced saves straight into the comment's own file (so a crash costs nothing), Save or ⌘↩ as its commit point, and Cancel — or Escape, its keyboard twin — reverting to the bytes the session opened on; closing the window flushes it exactly as the body's does, because a dismissal is not an abandon. **Delete is immediate and undoable, with no confirmation**: the comment moves into a `comments/.trash/` beside the draft, ⌘Z is the move back, and the folder is emptied when the window closes — with any residue from a session that died swept at the next open. **Comment attachments author in place**: a file dropped inside the composer lands in the draft's `attachments/`, one dropped inside an open inline edit lands in that comment's, and everywhere else in the window the ordinary card-wide import still applies. A quiet paperclip on both authoring surfaces covers the no-drag path, chips on a surface you're authoring carry Remove (to the **system** Trash, never a hard delete), and a posted comment's chips are read-only — Edit the comment to change its files.

- **Raw source** — View ▸ Raw Source (⌥⌘E) swaps the card window's whole content area — title, body and sidebar — for the literal `index.md` in a monospaced editor with Cancel and Apply. It's the escape hatch that keeps everything reachable in-app: unknown keys an agent added, hand-written comments, exotic YAML the app has no control for. Entering flushes whatever you were typing and then reads the file fresh off disk, never an in-memory copy. Apply validates through the very same fail-fast parse the loader uses — a broken proposal stops with a detailed alert naming the line, source mode stays open with your text, and the file on disk is untouched — and a valid one is written byte for byte, the only write in the app that neither stamps `modified` nor clears a `modified-by` you typed or kept, because you wrote those bytes and nothing may quietly edit them. The reload then refreshes every window. Escape is Cancel, ⌘↩ is Apply, ⌥⌘E toggled off applies too, Return just types; Cancel and closing the window discard without ceremony, and a card deleted out from under an open buffer discards it rather than letting a stale Apply undelete the card. ⌘E stands down while source mode is up, ⌘F still finds, and ⌘Z is the editor's own undo. A file that isn't valid UTF-8 declines to open as source rather than showing you a lossy guess of it.

- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Beside the name field sits the board's symbol: a well that opens a curated 36-glyph grid with a search into the OS's full SF Symbols inventory (its leading well restores the board default by removing the `icon` key), and below the glyphs a 4×2 colour row — a leading None plus seven palette tints — writes the symbol's `iconColor` the same way; the chosen glyph, in its tint, also shows in the title bar beside the board's name, so the identity pair the popover header edits is the one the window wears. Below it the popover is tabbed — Info with the board's vital statistics, Theme with the solid-color and pattern background presets, and Git, which follows the board's mode: a board with no repository offers **Add Git**, a board that lives inside somebody else's repository gets a short honest explanation rather than a hidden or greyed-out action, and a git board carries the branch surface: the current branch with switching and create-and-switch beside it, the plain-language explanation when an outside-the-app merge or rebase has the git surface paused, and the commit-identity name and email fields that write the repository's own `.git/config`. The read-only lock disables the surface without closing it.

- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Zoom In, Zoom Out, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.

- **Import and export** — a board leaves as one file and comes back as one: File ▸ Export writes the frontmost board as Obsidian-Kanban-plugin Markdown, a plain Markdown outline, or CSV (lossy exports post a quiet notice naming what was omitted), and File ▸ Import Board… turns any of those into a fresh board with the format auto-detected, never user-picked — headings become lanes, rows become cards, document position becomes the order. Convert-once by design, no live sync; and because a board folder is already nearly a valid Obsidian vault, plain interop needs no converter at all.
- **Labels** — the `labels` card key is first-party now: a YAML list of strings managed from the card window's Labels sidebar section (removable rows, autocomplete over the board's used-label universe, free-text creation) and the context menu's labels submenu — twelve frequency-then-recency ranked checkmark toggles plus a More… dialog over every used label. Read leniently, written canonically, case-insensitively unique per card, the key removed with its last label; face rendering and search integration deliberately await their own passes.
- **Sharing and Quick Look** — File ▸ Share… stages the board in front as a zip — `.git` excluded, the staging cancellable and cleaned up either way — and hands it to the system share sheet, so a board travels by Mail, Messages, or AirDrop as one file. A Quick Look extension answers Space in Finder with a lane-by-lane outline of any `.kanban` package, rendered for light and dark, built on a capped never-throwing outline walk rather than the full loader — a preview never fails on a board the app could open.
- **Printing** — File ▸ Print (⌘P) prints the board in front as a flowing linear document — lanes left to right, each lane's cards top to bottom, never a screenshot of the strip — or, from a card window, that one card. A print-panel accessory with the system's live preview picks the components (title, icon and labels, rendered body, comments in either reading order), the page breaks (continuous, per lane, per card — real sheet boundaries), one base face and size every other size derives from, and a toggleable header and footer; option sets persist as named profiles, with Last Used auto-captured from the panel. Scope resolves at invocation — the board in front, the card window's card, or a polite refusal where neither exists — Finder-initiated prints of a board work too, and the trash is unreachable by construction.
- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. This stack is the app's only history substrate, and the provider seam it binds through is kept deliberately open — a future provider (the deferred foreign-change journal) re-binds without re-plumbing.

- **The agent guide** — every board root carries a `CLAUDE.md` — and a byte-identical, vendor-neutral `AGENTS.md` twin — the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is moved aside — Finder-style, never destroyed, with a quiet row naming where it went — because the app owns that name; and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.

- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent. Which is which is decided **per file by the write-provenance ledger**, never by which kind of reload delivered it: a reconciling sweep on wake or reactivation announces whatever changed in the blind window (the app never vouches for changes it didn't witness), and a foreign edit that lands on a file the app had just written is still announced. A card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. **View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0) is that scaling's control**, since macOS ships no text-size setting of its own: eight rungs from 75% to 200%, moving the type, the card chrome and the lane chrome together off one ruler, remembered across launches and shared by every open board window. It is a zoom, not a magnification — the lanes still divide the window's width, because every lane being on screen is the rule the board is built around; what changes is how large the cards are and how many of them fit. Actual Size draws exactly what the board drew before zoom existed, down to the pixel, and each rung announces itself to VoiceOver. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. **A coloured board computes its own text colour.** The board background is the one surface the app lets a colour sit behind text, so the ink is chosen rather than assumed: WCAG relative luminance against the ≥ 4.5:1 threshold, with an `#RRGGBBAA` value composited over the window background of the appearance you are actually in — so lane and trash headers take light or dark glyphs on their own and re-decide the moment you switch to Dark Mode. One path serves both halves of the styling vocabulary: the twelve palette wells are pinned by a test that checks the ink the app *picks* for each of them in both appearances (a dark palette board is now readable in Light Mode, which it was not), and a hand-written hex — which stays fully honoured from disk — gets the identical computation as it renders. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled.

- **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away.

- **One version** — one app, one download, one on-disk format, everything free: nothing on the board, in the format, or in agent legibility is gated, and no surface in the app names or sells a paid tier. A Pro tier returns later — alongside the iPhone companion and the ops-based sync service, as its own design pass — and it will mean the board beyond the Mac, never a Mac feature. (The subscription machinery once built ahead of that split was excised 2026-08-08 rather than left dormant; DESIGN/12-editions.md records its mechanics for the reintroduction.)

- **Git-friendly by construction** — the app never runs git, and the format never needs it to be versionable: one folder per item and one file per card, stable UUID folder names as permanent identity, byte-faithful rewrites that never churn what you didn't change, and a seeded one-line `.gitignore` keeping Finder droppings out of diffs — so a board versions cleanly under any tooling you already use (`git init` it, clone it, review board changes in pull requests) while the app reads along live and stays strictly out of `.git`. The app-managed git stack that once shipped here — auto-commit, undo as forward commits, branches, a card's History — was built and then excised before launch (strategy/01-git-excision.md, ruled 2026-08-08; the pre-excision state is tagged `pre-git-excision`).

- **Lanework for iPhone (in development)** — a companion iOS app, second product rather than second edition (`dev.rzen.indie.KanbanMobile`), whose boards are the same `.kanban` folders in an iCloud Drive container (`iCloud.dev.rzen.indie.Kanban` — a folder the Mac's Finder shows as "Lanework", so the Mac app opens the same boards through the ordinary open panel today and can adopt the container natively later with no migration). It compiles the Mac app's storage engine *verbatim* — same loader, same writer, same byte-fidelity contract, so the two apps cannot drift on the format — and drives it from an `NSMetadataQuery` observer instead of FSEvents. The MVP surface: a Boards tab (boards → lanes → cards → card editor, with per-item download/materialization states honestly rendered), swipe actions to move a card between lanes or send it to `.trash/`, a card editor for title, body, and the typed style attributes (icon, icon color, background), the card's comment thread on its read view — rendered read-only, with posting and editing behind transactional sheets that share the Mac's draft-then-post write path — and a Settings tab with an About section for the app's own changelog and license (IndieAbout). No git on the phone — the change signal is the container itself.

## Development

The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`:

```sh
xcodegen generate
xcodebuild build -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS'
xcodebuild test  -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS,arch=arm64' -only-testing:KanbanTests
```

`-only-testing:KanbanTests` is the everyday run: the unit suite needs nothing but a compiler. Dropping it also runs `KanbanUITests`, which launches the real app and drives its menu bar — that needs a real, unlocked display and Accessibility automation permission, and on a machine without them every test in it fails on its first click for a reason that is not the app's. On such a machine, check the UI suites compile instead:

```sh
xcodebuild build-for-testing -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS,arch=arm64'
```

macOS 26+, Swift 6 (strict concurrency), SwiftUI, sandboxed. Internal codename `Kanban` (target, scheme, bundle id `dev.rzen.indie.Kanban`); the app ships under the display name **Lanework**.

The iPhone app builds from the same project — `KanbanMobile` (iOS 26+, iPhone-only for now), sharing `Kanban/Storage` as source files:

```sh
xcodebuild build -project Kanban.xcodeproj -scheme KanbanMobile -destination 'generic/platform=iOS Simulator'
```

Simulator work without an iCloud account: launch with `LANEWORK_LOCAL_ROOT=<path>` in the environment (DEBUG builds only) and the app uses that folder in place of the ubiquity container.

**One *Mac* app target** (DESIGN/12-editions.md ▸ The target). `project.yml` declares the app `Kanban`, the unit bundle `KanbanTests` hosted by it, the UI bundle `KanbanUITests`, and the iPhone app `KanbanMobile` — a separate product, not a second Mac edition; one scheme per app. The 2026-07-27 two-target edition split — a second `KanbanPro` application, its `LaneworkPro` scheme, a `KanbanProTests` bundle bound to it by `-module-alias`, and `scripts/verify-editions.sh` — was retired on 2026-07-30 in favour of one app with Lanework Pro as a subscription inside it. The `dev.rzen.indie.kanban-board` UTI and the three pasteboard types are exported once, by the one app that owns them.

**StoreKit runs locally in development.** `Configuration.storekit` at the repo root declares the "Lanework Pro" subscription group and its monthly product (`dev.rzen.indie.kanban.pro.monthly`), and `project.yml` attaches it to the scheme's *run* action, so a ⌘R build shows a real price and completes a real purchase with no App Store Connect product and no sandbox account. It is a run-action setting only — builds, tests and `scripts/release.sh` are untouched, and the file is `buildPhase: none`, so it never reaches the shipped bundle. The same three strings live in App Store Connect (created by hand — RELEASE.md) and in `Kanban/Tier/ProProducts.swift`; a mismatch shows up in the app as "Can't reach the App Store right now."

**Accessibility is verified, not assumed** (DESIGN/10-accessibility.md § Verification). `KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over all eight surfaces the design names — the board with the trash shown and hidden, the card window in Preview, Edit and raw source, welcome, the template chooser, the board popover — and every violation is a test failure with nothing waived. The manual half — the per-release VoiceOver smoke script and the consolidated accessibility checklist — is `KanbanUITests/AccessibilityVerification.md`.

**The golden paths are verified end to end too.** `KanbanUITests/EndToEndFlowTests.swift` drives create card, create lane, inline rename, a pointer drag across lanes, cut/paste across lanes, undo and redo of a move, and the whole current trash grammar — delete into `.trash/` with no confirmation, View ▸ Show Trash, restore by ⌘X/⌘V back out, and Empty Trash… with its confirmation. `FailFastLaunchTests.swift` launches onto a board with one unparseable `index.md` and asserts the loud failure: no board window, welcome carrying the loader's own sentence naming the offending file, and nothing on disk repaired. `LargeBoardPerformanceTests.swift` measures launch and one interaction against an 8 × 40 board under explicit wall-clock budgets (XCTest baselines do not travel between machines, so the gate is an assertion rather than a baseline). The three flows that stay manual — instantiating a template, Duplicate's save-panel fallback, and File ▸ Open… — are the sandbox's Powerbox panels, which live in another process; they are written down as manual steps in `KanbanUITests/EndToEndVerification.md` rather than automated flakily.

Every UI test launches the app with `--ui-test-fixture-board` plus a variant flag (`--ui-test-fixture-standard`, `--ui-test-fixture-large`, `--ui-test-fixture-malformed`), a test-only argument pair that makes the app build a known board through its own `BoardWriter` inside its sandbox container, with its own scratch registry, so a run never touches real boards or real recents (`Kanban/App/UITestLaunch.swift`). The fixture builders are themselves unit-tested, so a fixture that came out wrong fails somewhere that runs everywhere. All of the UI suites need a real, unlocked display and Accessibility automation permission; run commands and prerequisites are in `KanbanUITests/EndToEndVerification.md`.
