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
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/.
-
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
kinddiscriminator) 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 missingkindkey 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, legacydeleted: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 — pinned by a golden fixture suite of 18 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
.kanbanpackages; 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 stampsmodified, clearsmodified-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'sindex.mdbelongs in that card'sattachments/, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touchingindex.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 squattingCLAUDE.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.
-
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 new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
-
Card faces — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill:
backgroundpaints a stripe down the card's left edge andiconColortints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a#RRGGBB[AA]hex. 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 paperclip chip is the face's whole attachment 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
titlekey 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 — 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 emptyattachments/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 keepcreatedand 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. -
Styling — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that removes the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. 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
backgroundas 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. -
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
resumefinds "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/modifiedstamped 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.gitand.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,.gitand.trash/left behind (a template is content, not history, and not a fork), everything else includingCLAUDE.user.mdand 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'sattachments/, 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.mdin 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 stampsmodifiednor clears amodified-byyou 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
titleand 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. Below it sits the same style editor every other anchor uses, aimed permanently at the board; on an ordinary free-tier board the popover ends there, and only a board carrying a.gitgets a closing note — "This board has a git history. Lanework Pro works with it." Under a Lanework Pro subscription that slot becomes the board's git section instead, and it 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 shows its current branch, read-only. 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, 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.
-
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. Every tier ships it: the free tier runs the native stack, and a Lanework Pro subscription binds git behind the same seam without changing a keystroke.
-
The agent guide — every board root carries a
CLAUDE.mdthe 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-byself-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. ACLAUDE.mdthat isn't the app's is never clobbered — it moves toCLAUDE.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
orderrather 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. 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#RRGGBBAAvalue 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, plusBuildDateandBuildHashin the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12). -
Tiers and the Lanework Pro subscription — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; Lanework Pro is an auto-renewable subscription inside the app, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription unlocks is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). The first of that is built — see "Git integration" below — and the rest is pro-m1 and pro-m2's remaining work; until it ships both tiers run the same native undo stack, the free one over the same inert-
.gitposture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a local read, never a network call — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are one state, with nothing anywhere distinguishing them. The tier binds per board session at composition and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else. -
Git integration (Lanework Pro) — git is opt-in per board and never silent. Under a subscription, a board's mode is detected freshly at every open, nearest-
.git-wins: a.gitat the board root means git mode, a.gitonly further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — agit initrun in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. Adoption is not initialization: a board whose folder already holds a repository (you cloned it, or you rangit inityourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence is the opt-in, which is how a second machine joins a shared board. Add Git in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. Commits are authored from the repository's own.git/configwhen it names an identity, and otherwise from your macOS account name and machine (the popover's identity fields land with a later card). A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is bundled (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins. Still ahead in pro-m1/m2: auto-commit with semantic messages, git-backed undo/redo, branch switching,.gitignoreseeding, and remotes.
Development
The Xcode project is generated — project.yml is the source of truth, not the .xcodeproj:
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:
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.
One app target (DESIGN/12-editions.md ▸ The target). project.yml declares exactly three: the app Kanban, the unit bundle KanbanTests hosted by it, and the UI bundle KanbanUITests; one scheme, Kanban, builds and tests all of them. 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.