Commit Graph
127 Commits
Author SHA1 Message Date
rzen eb950ee881 Remove the lane header's "+" new-card control
The lane header no longer draws a new-card button. Every other
creation path is untouched: File > New Card (Cmd-N), Return on a
selected lane, and double-click on lane empty space.

- LaneView: drop the button and its overlay slot, leaving the
  collapse chevron as the header's one piece of trailing chrome.
- NewCardTarget: drop the button's click-names-target carve-out over
  the Cmd-N target rule (Return on a selected lane is now the only
  direct-target path left).
- AccessibilityPhrases: drop the button's spoken label.
- BoardMetrics: drop its width reserve; laneHeaderTrailingReserve now
  reduces to the collapse chevron's own reserve.
- DESIGN/03, 04, 10, 11: update the lane title-bar inventory, the
  Cmd-N override clause and search-clearing mechanism list, the
  accessibility tree-shape sentence, and the pointer-grammar row that
  described the button.
- Tests: drop the two pinned accessibility-label tests and the
  newCardButtonReserve assertions; retarget the truncation-headroom
  test at the now-solo laneHeaderTrailingReserve; fix stale doc-quote
  comments and the manual accessibility-verification checklist.

Full KanbanTests suite: 2807 tests, 482 suites, all passing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 23:28:05 -04:00
rzen b09c4bd5c0 Copy Link — a card context-menu row that puts the card folder on the pasteboard
Card 737a949f: "Add an option to card context menu to copy a link to
the card folder." Implements the design ruling verbatim.

- New context-menu row "Copy Link" (CardFaceView.boardMenu, board
  side only — trash cards are excluded, matching "sole selected live
  card"). Writes the clicked card's folder in one pasteboard item
  carrying two representations: the file:// URL under .fileURL and
  the plain absolute path under .string (FolderLinkPasteboard.swift).
  Enabled on a sole selected live card; disabled on a multi-selection
  and wherever edit-shaped actions already disable, per the ruling.
  Also exposed as a VoiceOver custom action alongside its siblings.
- Menu-bar twin: Board ▸ Copy Link (BoardCommands.swift,
  CopyLinkCommand), no default chord — the every-function-a-menu-item
  contract in DESIGN/11-command-nexus.md is still current, so this is
  the twin that contract calls for, homed the way Open Card/Rename/
  Style… already are.
- DESIGN/11-command-nexus.md: new Board-menu row and an updated Card
  context-menu row.
- Tests (KanbanTests/CopyLinkTests.swift): the target predicate's
  enablement (sole card / multi-selection / lane / trash / empty /
  inline-editing), the pasteboard write's exact bytes via a fake
  pasteboard (both representations, exact folder URL), and a
  disabled-target no-op.

Caught and fixed during self-review: an early version read the
context menu's widened-selection helper (targetIDs) inside the
Copy Link row's .disabled(...), which reads store.selection. Since
.contextMenu's content closure is evaluated on every ordinary body
pass (not only when the menu opens), that resubscribed every card
face on the board to every selection change — the exact O(board)
regression RENDER-INSTRUMENTATION.md's isSelected/selectedCount
split exists to prevent, caught by BoardRenderPerformanceTests and
MarqueeRenderCostTests. Fixed by reading the already-hoisted,
non-Observable `selectedCount` parameter instead, which answers the
same "how many ride along" question at zero extra subscription cost.

xcodegen generate, the Kanban scheme build, and the full KanbanTests
suite (2816 tests) are clean.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 23:28:05 -04:00
rzen bab456c08d Collapsible lanes — frontmatter-backed slim strips outside the width division
A lane folds to a fixed slim vertical strip carrying its glyph, its card-count
badge and its title turned on its side, and the strip is deliberately not part
of the window's division: the expanded lanes' units divide what is left once
each folded strip's fixed width has come off the top, so folding a lane is a
re-divide trigger of the Show/Hide Trash family — the window never moves and
the siblings grow into what the lane gave up.

The state is a first-class lane frontmatter key, `collapsed: true`, and
document state exactly as `width` is: the files are the board, so an agent
folds a lane by writing one key. Absent means expanded, expanding removes the
key rather than writing `false` (the remove-at-default family beside a
one-unit `width`, the empty rename's `title` and the None well's
`background`), and the lane's `width` rides along untouched so expanding
restores the lane the user had. The read is `width`'s leniency one type over —
a boolean scalar or a quoted boolean word reads as itself, everything else has
no reading at all and renders as expanded, bytes preserved either way.

Toggling is the header's always-visible collapse chevron, the lane context
menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on
the strip; a modified click on the strip stays the ordinary selection grammar,
so a folded lane is still selectable by pointer. The title reads bottom-up and
is justified to the top of the room below the strip's chrome (owner ruling
2026-08-08), truncating against the strip's own height.

While folded the lane draws no cards at all, which is what makes every
exclusion true by construction rather than by a guard per gesture: no card
face means no marquee target and no navigation frame, and no registered grid
means the masonry's drop zones have nothing to resolve against. What did need
code is the half that names absolute destinations — the option-arrow jumps and
the arrow seed scan past a folded lane, the lane domain's down-arrow is inert
on one, and New Card skips it (a selection inside one falls through to the
last-active lane, the stale selection's rule). A drop on the strip appends at
the lane's end, cards and Finder files alike, with an accent edge standing in
for the shadow the strip has no masonry to open; there is no hover-to-auto-
expand yet. Lane reorder works on the strip, and a dragged folded lane carries
its fold, so its shadow and its replica are the strip rather than its units.

The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket,
the same stamp behaviour, the same three do-nothing paths — with two new
`WriteOperation` cases and two new undo verbs rather than one of each, because
a banner or an Edit-menu row that said "resize" after Collapse Lane would name
a control the user never touched.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 22:53:10 -04:00
rzen 7651e40318 Print boards and cards with configurable components and named print profiles
⌘P had no story: KanbanApp removed the platform's Print row outright on
11-command-nexus.md's "No Print story in v1 (⌘P unused)" line. That line
retires. File ▸ Print… now prints the board in front — lanes left to right,
each lane's cards top to bottom, as a linear document rather than a picture
of the strip — or, from a card window, that card. The trash is unreachable
by construction: it is a sibling container of `lanes`, not a lane.

The rules live in a pure layer nothing AppKit can reach. `PrintOptions` is
one Codable value carrying the printing card's five bullets — which
components (title, icon+labels line, rendered body, comments off by default
with either reading order), page breaks, one base face and size every other
size derives from, and a toggleable running head and foot. `PrintSource` is
what is being printed, frozen at ⌘P so the panel's repeated relayouts and a
board reloading underneath cannot disagree. `PrintDocumentBuilder` turns the
pair into a block list, which is where every decision a rendered page hides
becomes something a test can hold: component order, comment ordering, and
page-break markers that are markers rather than whitespace. Empty is empty
all the way up — a card with nothing to print consumes no page break, and a
lane whose cards all dropped out takes its heading with it.

A page break is a pagination fact, not a spacing one. TextKit has no
page-break character, so `PrintDocumentView` splits the document into
sections at its breaks and flows each into as many page-sized text
containers as it needs: a container boundary *is* a sheet boundary, at any
paper size with any margins. Bodies come from the app's one Markdown pass —
`BodyMarkup.parse` into `BodyMarkupRenderer` — re-faced run by run so the
chosen family reaches the text and fixed-pitch code keeps its own, and drawn
under a forced light appearance so the card window's dynamic label colours
do not print white.

Options ride in the print panel's own accessory rather than a pre-flight
sheet of ours, which buys the system's live preview of the real paginated
document; the preview refreshes through one KVO revision counter rather than
thirteen mirrored properties. Profiles persist app-side in UserDefaults,
never in board files — a print profile is how this user likes to read, not
what a board is (`BoardZoomStore`'s argument). A name is a profile's
identity, folded case-insensitively; "Last Used" is reserved in every
spelling, kept out of the stored list, and captured when an operation
actually ran, so a cancelled print rewrites nothing. Both decoders are
total: one unrecognized key must not cost a user every profile they saved.

DESIGN/11-command-nexus.md gains the Print row and loses the sentence
saying it would never have one.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 22:42:11 -04:00
rzen 05ae703989 replace symbol grid in card window sidebar with a compact symbol picker
The card window sidebar's Style section embedded StyleEditorView symbols-only
(showsBackground: false, showsSymbols: true), drawing the curated grid whole
inside the sidebar's own scroll view — a permanently open 5-7 row grid ahead
of every other section. Replace it with the reusable compact SymbolPicker
(already used by BoardInfoPopover for the board's glyph): a single well at
rest, the grid only inside its own popover.

- CardStyleSection no longer instantiates StyleEditorView at all. A new
  "Symbol" row mirrors the existing "Background" row's inspector shape
  (caption leading, control trailing), wiring SymbolPicker's onSelect/
  onSelectColor to icon/iconColor through StyleCommand.apply(...on: undo),
  the exact funnel the background combo already rides — so the card window's
  undo session semantics (13-native-undo.md) are unchanged.
- The picker is fed CuratedSymbols.available rather than its own smaller
  general-purpose default, so a card's curated vocabulary doesn't shrink.
- StyleEditorLayout.sidebar and showsBackground stay in StyleEditor.swift
  (still correct, still tested) rather than being cut as dead code — a
  separate, larger cleanup this card doesn't make (recorded on the card).
- DESIGN/03-board-ui.md and DESIGN/05-card-window.md: updated the sentences
  describing the sidebar hosting the style editor's symbol grid to describe
  the compact picker instead.
- Tests: CardSessionUndoTests gains a symbol/tint analogue of the existing
  background-combo session-routing test; CardSidebarTests gains a curated-set
  tripwire and an updated file-header note.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 22:14:40 -04:00
rzen 9a52b795b2 The drag learns the stepper's trick — past the screen's edge, lane growth re-divides instead of stopping
The right-edge drag's growth was capped at the screen's visible frame,
because each snap tick grows the window; on a window near the screen edge
that left a lane stuck at a tick or two of headroom. Settled 2026-08-08
(03-board-ui.md § Lane, superseding the pathfinder's hard stop): at the
screen the window pins and each further tick re-divides the fixed strip
width across one more unit — siblings compress, the stepper's mechanism
arriving under the drag's fingers. The regimes meet with no pixel jump
(the re-divided standard at the fit IS the frozen standard, by the
exact-fill identity), shrinking mirrors the way back, the rubber band
moves to the strip's own capacity, and a window with no headroom at all —
full screen included — re-divides from the very first snap.

New pure arithmetic in LaneLayoutMath (pinnedStripWidth, resizeStandard,
resizeMaxUnits, resizeWindowDelta, snappedUnits over per-count slots);
LaneResizeSession splits the tick across the regimes and derives its
standard from the live count; the handle and BoardView hand the session
the strip's whole divide. 2709 unit tests green (+11).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 21:36:56 -04:00
rzen 1fd19dfb12 The undo command surface rebuilds — app-owned rows and explicit toolbar targets over FocusedValues
Edit ▸ Undo/Redo become the app's own replaced rows and the board toolbar
pair takes explicit targets, both reading the focused session's
BoardUndoManager through FocusedValues.undoStack (board windows publish the
session's manager, card windows their own) — the nil-target route died with
the SwiftUI window latch, 13-native-undo.md ▸ Rules ▸ command surface,
re-ruled 2026-08-08. The rows enact the routing predicate themselves: text
focus routes ⌘Z to the first responder's own manager, title and enablement
included, re-derived at fire time with a beep for the stale window.
NativeHistoryProvider turns @Observable so both surfaces re-derive on stack
changes; a checkpoint-notification ticker covers plain text managers.
.responderAction leaves ToolbarItemSpec with its only user;
windowWillReturnUndoManager stays wired for AppKit's own asks.

Live-probed on the fixture board (21/21): the row retitles to "Undo Add
Lane" and crosses via real ⌘Z key events, ⇧⌘Z redoes via a window-server
chord, the toolbar pair validates and fires, search-field and body-editor
⌘Z stay text undo with board stacks untouched, and a card window crosses
its own stack with no fall-through. 2698 unit tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 18:55:34 -04:00
rzen 2d919b8131 The prose catches up with the one-version app — tier qualifiers retired, the entitlement comment carried over
The excision's follow-up sweep: present-tense prose that still implied an edition axis now reads
correctly under one version ("in every tier" clauses dropped or turned substrate-shaped, the
announcer's "every free-tier bracket today" is "every bracket today" — nothing passes a phrase),
and forward-looking promises pinned to the mooted pro-m1/pro-m2 milestones now name the thing
itself (the change narrator in Kanban/Changes/, the foreign-change journal successor) or fall to
past tense. Kanban.entitlements' network-client comment sheds its "dormant until Pro ships"
framing for the pivot's own reasoning: the key stays because the sync capability to come needs
it regardless. Untouched on purpose: the storage layer's coerce/tolerate/refuse tiers, the
chooser's bundled/user/keyless tiers, verbatim design-doc quotations, and genuine past-tense
record. One dangling reference repaired en route: EndToEndVerification.md cited the long-renamed
InertGitTests. Comment-only throughout; 2,686 unit tests green, unchanged.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 13:40:13 -04:00
rzen 445d035a83 The paper agrees with the code — guide v11, README, DESIGN re-rulings, and the adjudicated sweep
Step 7 of strategy/01-git-excision.md, the companions. The agent guide bumps to v11: the Git section teaches repo-resident etiquette alone (stage only your own paths, commit your own changes, leave app-maintained files to the app) — existing boards heal to the new text on next open. README re-anchors: the four git feature bullets out, tiers say the complete Mac experience is free, and one bullet states the format's git-friendliness promise. The changelog drops the never-shipped git entries. DESIGN re-rules: 06 retired with Undo routing migrated to 13 (now the sole substrate's doc, seam kept open), 07 retired as written pending the ops-service workstream, 14 retired as superseded record, 12 carries the second pivot note, the index reflects all of it; the charter gets a pointer note (the anchors' full re-ruling stays with the user). InertGitTests renames to GitAgnosticStorageTests — the excision restores its original claim app-wide. And the sweep: ~70 comment sites across 36 files adjudicated against the keeper list, every present-tense description of the excised machinery made past tense or repointed, keepers untouched. 2,707 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 12:31:27 -04:00
rzen cdba512512 The seams unbind — the provider is always native, and the git stack compiles dead
Step 4 of strategy/01-git-excision.md, the entangled one: AppModel's makeHistoryProvider collapses to the native provider (the seam stays injectable per the reversibility posture), the session's git state and its wiring go (wireGitUndo, wireBranchSwitching, the card-session staging threading), BoardStore sheds commitSeam and the identity-history ranker (the loader's nil-safe rung now tops out at birth date — today's no-git behavior), SessionSettleGate keeps the gate and inherits the path utility it borrowed, BoardRegistry drops the persisted operation stamp (decode-safe), and the git banner family leaves BannerCenter with its announcer and accessibility phrases. One missed harvest tie severed (the narrator's root subject is its own now). Nothing outside Kanban/Git/ references the stack — proven by sweep. 2,855 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 11:25:41 -04:00
rzen 1d97a2931c The git surfaces leave the glass — tab, trail, branch line, and the remote pair, peeled
Step 3 of strategy/01-git-excision.md: the popover strip is Info/Theme/Sync, the titlebar widget says the name alone, the card window's History section and its slot go, Board ▸ Pull/Push comes out with the RemoteCommands scaffold, and View ▸ History re-tags from the commit trail to the deferred foreign-change journal. The Sync placeholder re-annotates to the future ops-based sync service. Four git test suites leave with the surfaces they pinned (BoardGitSetup, BoardInfoPopover, BranchSwitch, GitUndo). The git engine still compiles underneath, unreferenced by UI. 2,890 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 10:52:57 -04:00
rzen c67c1037f1 The strip grows a Sync tab — a standing placeholder, nothing in it yet
BoardInfoTab gains .sync last in the strip, rendering BoardSyncTabView's one
honest caption. The tab claims the position where 07's remote half will
live without ruling where its setup surfaces land — that stays the open
Redesign card's question.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 22:00:19 -04:00
rzen 7414fc8400 The popover/sheet split reverses — settings fold into the Git tab, the widget stacks name over branch
The titlebar widget becomes a two-line identity block: the board glyph at
22pt spanning both lines, the title over the branch (git-mode only, smaller
and secondary), the em-dash retired. New Branch… returns to the switch menu
behind a divider, revealing an inline name field — the pre-split shape. The
board settings sheet retires whole: add-git and commit identity render
inline in the Git tab's postures (BoardGitSetup.swift), the availability
rule collapses into BoardGitSetupSection.resolve, and Board ▸ Board
Settings… leaves the menu bar. Where 07's remote/credential setup surfaces
land is deliberately left open — filed on the Redesign board.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 21:48:02 -04:00
rzen 798a8bac73 The tier axis comes out of the git stack — compose unconditional, postures mode-driven
HistoryStore.compose(boardRoot📒) returns non-optional and runs for
every session — the nil the gate produced was the only nil it ever had.
makeHistoryProvider is a one-axis decision: git-mode boards bind the git
provider, everything else native, in every tier; Session.tier stays
recorded, dormant. BoardGitSection shrinks to the four mode postures
(.absent and .proPointer die, BoardGitNote and the .git probe with them);
every board carries all three popover tabs (BoardInfoTab.available
retired); the titlebar branch shows on any git-mode board; the settings
sheet and card History section stop reading tier. InertGitTests is
repurposed as UntouchedGitTests — the file layer still never opens .git,
now load-bearing for mode-none boards. The accessibility audit reaches the
settings sheet at last: the fixture board hosts it in every tier, so the
free-fixture disabled-row test becomes an open-and-audit test.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 20:21:28 -04:00
rzen 93a3423e6b Give the single-branch switch picker its disabled explanatory row
With branch creation relocated to the settings sheet, a single-branch
board's switch menu opened onto nothing and read as broken (ruled
2026-08-06, built with the Git tab): a bare Text — AppKit's standard
disabled item, read by VoiceOver as disabled text — now says "No other
branches" where the switch entries would be. The filter behind it becomes
the pure switchTargets(branches:current:) seam, pinned by
BranchSwitchTargetTests: current excluded, nil current passes all through,
repository order preserved.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 19:12:01 -04:00
rzen 7d7e892617 The Git tab fills in — the parked postures rehome whole, and the strip's membership is the posture
The popover's third tab settles (03 ▸ Git tab, 2026-08-07): BoardGitTabView
takes the parked section — the six-posture matrix, the Pro pointer, the
nested/unverifiable prose, the branch controls, the Board Settings… door —
with the tab label doing the old "Git" header's naming work, and one new
caption for the no-repository posture. BoardInfoTab.available drops the Git
tab exactly where BoardGitSection resolves absent, carrying 12's
absent-no-placeholder rule up to the tab strip; selection resets to Info per
open, now a ruling. Daily face only — the remote block still joins with
pro-m2.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 19:11:55 -04:00
rzen 7ac34651a2 The board's symbol takes a tint — a 4×2 colour row under the picker's glyphs, and the glyph itself moves into the titlebar
iconColor stops being hand-written-only (user-ruled, superseding 03's
"schema yes, control no"): it rides StyleCommand.apply → applyStyle as
the third styled dimension — per-dimension no-op skip, one bracket, one
history step, ExpectedField.iconColor for staleness. The SymbolPicker
grows an opt-in colour row (leading None plus seven Palette.foregrounds
hues, None removes the key); the board popover is its one caller. The
window-title widget now draws the board's resolved glyph in that tint
beside the name. Doc realignment filed on the Redesign board (Minor).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 18:36:49 -04:00
rzen 3d231d6454 The Background tab becomes Theme — solid colors or patterns, presets only, chevron-paged
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 18:03:06 -04:00
rzen fb96e30df0 The Background tab fills in — facets rendered to order, eight hues in a carousel
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 16:08:29 -04:00
rzen 9c857ae0cc Selected-ness rides down as a compared parameter — a marquee crossing repaints its faces, not the board
A selection change re-ran every CardFaceView on the board (180 bodies ≈ 85 ms on
the 6×30 fixture, 515 ≈ 233 ms on a real 515-card board, debug): the face's body
read store.selection in three places — isSelected, the drag replica's count, and
the context menu's styleTarget — and Observation invalidates every reader of the
property, past the equatable gate entirely. The band overlay stayed cheap, which
is why the marquee tracked the cursor while the highlight lagged ~0.4 s behind.

Now LaneView and TrashLaneView hoist one selection read per body and hand each
face isSelected/selectedCount as compared parameters; StyleMenuItems takes its
target as a deferred closure; TrashLaneRowView gains the same treatment plus the
Equatable gate it never needed before. Select-one-card: 180 bodies → 1. A
growing band costs the selection's own running size; the real board's crossing
fell 233 → 112 ms — the remainder is lane bodies re-measuring their masonry, a
separate lane-level finding recorded in RENDER-INSTRUMENTATION.md.

Also: select() gains defaultsSoleMember — the marquee's explicit nils never
avoided the sole-member default, so a one-card band acquired a selectionHead and
could scroll the lane out from under its own drag.

MarqueeRenderCostTests pins the shape: redundant samples cost zero bodies, a
growing band pays per crossing, and selectionStillRepaints holds a ≤8 budget.
2026-08-07 15:10:35 -04:00
rzen 5779da2b6c The Info tab fills in — the board's vital statistics in two honest registers
Model facts off the live snapshot (Lanes, Cards with the trash's freight as a
quiet tail, Attachments — the welcome-count live-only rule) and disk facts off
one background whole-folder walk (File, Files, Size, Created, Modified — .git
and .trash included, so the rows agree with Finder's Get Info), with a Reveal
in Finder link as the door to the folder the rows describe.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 13:58:15 -04:00
rzen c21c53be9c The board popover goes tabbed — Info, Background, Git, three empty rooms awaiting their own sessions
The symbol/name header keeps the top; below it a segmented tab bar replaces
the styling embed and the git section, both parked in-file — their seams are
settled, test-pinned design, and the tab sessions rehome surfaces, not rulings.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 13:34:23 -04:00
rzen 8aefaf23ce The empty provider was never load-bearing — the dragless layer frees the rubber band
Measured on real events 2026-08-07, correcting the 2026-08-06 hosted
finding: a bare count-1 tap on LaneView's empty-space layer fires in
~1-3 ms with no drag source at all — the hold that made the empty
.onDrag look necessary was the sterile NSApp.postEvent stream
over-disambiguating. And the provider was actively harmful: even an
empty drag source claims the mouse-drag at threshold, starving the
marquee's simultaneous DragGesture after one sample — the band froze
and the mouseUp never arrived. The layer goes dragless; drags from
empty space belong wholly to MarqueeControl. PointerClick's and the
layer's comments retell the corrected story. Alongside: openCard is
typed @MainActor throughout, which makes the closure Sendable and
lets CardFaceRole carry it under CardFaceView's nonisolated ==.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 12:55:54 -04:00
rzen b0ffff1aa1 The app learns Appearance — Auto, Light, Dark from the View menu and a toolbar pull-down
View ▸ Appearance (11-command-nexus.md): three radio-exclusive rows,
app-wide, persisted, needing no window in front — the View menu's new
last group. AppearanceStore owns the override's rules (absent key =
Auto, lenient reads degrade to Auto, remove-at-default) with an
injectable apply seam so test hosts never touch NSApp; the one real
apply hands NSApp.appearance its answer in applicationDidFinishLaunching,
the global side effect KanbanApp.init must not carry. The board toolbar
gains its first .picker item — an NSMenuToolbarItem whose rows re-fetch
their spec fresh, checkmark read at menu-open like every other menu row —
and Appearance joins the search field as the second default item,
centered beside it (03-board-ui.md ▸ Toolbar, ratified 2026-08-07).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 12:55:45 -04:00
rzen 1e21d8cd60 The combo's swatch takes a berth — a bar sitting in the field, not a fill
The swatch's padding goes asymmetric and user-tuned — a wider berth at the
sides than above and below, out of the swatch rather than the control's
size — so the colour reads as a bar in the field instead of wall-to-wall.
The field's fill moves to controlColor, the push-button neutral grey, whose
ring stayed legible where controlBackgroundColor's near-black dark-mode
reading drowned it; and the trigger square keeps its own two-point inset,
decoupled from the swatch's padding so the indicator stays a legible
square through any padding tweak the swatch takes.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:23:54 -04:00
rzen 51cf994cb9 Lane selection sheds its slab — a hairline edge with a soft halo, intensity inverse to area
The whole-lane wash-and-solid-ring scaled its emphasis with the lane's
area: what read as a ring on a card read as a slab on a column. Resettled
as the system focus ring's vocabulary at selection strength — a hairline
accent edge with a soft accent glow bleeding outward, riding the stroke
shape rather than the translucent plate (a shadow behind it would bleed
through as murk), and no wash at all: nothing tints the cards' backdrop,
and lane colour stays the accent band's alone. Under Increase Contrast the
halo yields to the solid full-alpha ring — "strengthens borders" means
crisper, and a blur is the one thing a border cannot become — so the
setting sees exactly the indicator it always has; cards and trash rows are
card-scale and keep the solid ring at every contrast. One selection
vocabulary, pinned by Accommodations.drawsSelectionHalo and its test.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:22:36 -04:00
rzen f126614b56 The pickup seeds its own slot — the lift and the shadow become one transaction
"At drag start it replaces the item's original space" is now true from the
very first frame: begin takes the run's own resting slot as a seed, set in
the same transaction that lifts the run out. Without it the vacated gap
closed un-animated and sprang back open at the first dropUpdated — a
shuffle carrying no information. The seed bypasses propose deliberately (a
pickup is not a new landing spot, so no alignment tick), and the first real
sample's re-propose of the same slot is the early-out's ordinary silence. A
⌥-pickup seeds nothing — a copy's resting layout keeps the originals in
place, so there is no vacated space to hold. Card pickups compute the seed
through the same LaneView.rendered rules the retargets count in; lane
pickups seed their strip slot the same way. DRAG-REORDER.md's own-slot
paragraph records the rule, and SeededPickupTests pins all four corners.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:22:26 -04:00
rzen eef0a4539f Clicks land the instant they happen — the empty-space gestures move behind the masonry
The measured defect: LaneView's empty-space double-click was a second
sequential .onTapGesture(count: 2) stacked over the single tap, and that
recogniser held every click in the lane — its own empty space and every
card face alike — hostage to the system double-click interval while it
disambiguated (~475 ms click-to-selection on a hosted board). The fix is
structural: the empty-space surfaces live on a background layer behind the
masonry, so a card's click never shares a gesture path with a lane
recogniser; one .onTapGesture branches on PointerClick.count (AppKit's own
clickCount, read the way ClickModifier reads the keyboard) — first click
selects, second creates, Finder's cadence with nothing to disambiguate; and
the layer carries a load-bearing empty .onDrag, because without a drag
source macOS holds primary clicks pending multi-click disambiguation
(measured: never fires alone, ~90 ms with one present). A measured viewport
floor makes each lane's blank space actually belong to the layer — a
ScrollView proposes nothing along its scroll axis, so only an explicit
minimum stretches the content — with the trash column as its twin, less the
padding that sits inside its scroll content. The template chooser's stacked
pair collapses to the same one-recogniser branch, and the attachment rows
move their double-click to a simultaneous gesture (instant there, because
the row's real .onDrag forces immediate delivery). PointerLatencyTests pins
the recovery with synthetic pointer events on a hosted board.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:22:16 -04:00
rzen cfee4a4b41 The board learns to zoom — eight rungs on one ruler, and Actual Size is the untouched board
View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight
rungs, app-wide and persisted (the Show Comments precedent) — a viewing
comfort, not a property of any one board. The level travels as
BoardZoomContext in the environment, injected on BoardView alone so the
banner strip, search bar, sheets and popovers stay at the system size; the
environment is also what carries it through CardFaceView's equality gate,
which compares nothing that moves with the level. Every BoardMetrics figure
follows zoom.bodyPointSize — card and lane chrome, drag replicas and the
count badge, the resize handle, the trash column — and the drop registry
carries the ruler for event-time reads, with the autoscroller's three
reaches turning font-derived (reachSide named as the stripGap it always
equalled). Lanes still divide the window; zoom never moves the window or
its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the
menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung
announces itself to VoiceOver, and the render suite pins both invariants:
a rung repaints every face, a no-op Actual Size repaints nothing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:22:02 -04:00
rzen d5ad21c3da The board wears a picture — background becomes a mapping, and the window chrome follows it under a thin frost
background is {color:, image:} and only a mapping at every level; the board's image paints the full window under a transparent title bar, with a thin-material frost strip keeping the chrome legible and the standard accommodations intact.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 10:15:03 -04:00
rzen 190a8e36f1 Search takes the center — the titlebar placement grammar ratified
Redesign card 6d5a97fd resolved off the 2026-08-01 live try-out: leading is
board identity, center is view controls, trailing stays the user's catalog.
centeredItemIdentifiers, not a flexible-space sandwich — centers against the
window, holds as catalog items install, reaches machines with a saved
arrangement; defaultItems and its pinned tests untouched. 03 records the
grammar and the accepted tensions (widget-vs-field budget in narrow windows,
HIG's trailing convention yielding).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 22:13:55 -04:00
rzen 9766e1f61c The Colors panel joins the palette — the combo ratified, and each anchor composes the halves it needs
Four rulings close Redesign Contradiction 3452893f (2026-08-06): the in-app
escape hatch is ratified in full, reversing 2026-07-29's palette-only rule —
the combo's Other… opens the system Colors panel, a pick landing on a palette
color stores the name, anything else the hex. Free-picked colors change no
contrast story: they land on the same runtime ink computation hand-written hex
always got (10 amended to say so; no warning surface is owed). Anchor
ownership: the card sidebar's background story is the combo alone — the well
grid's background half stays with the other anchors (StyleEditorView gains
showsBackground beside showsSymbols; the popover's symbol half already went to
its inline SymbolPicker). Quick-style recents stay palette-vocabulary — a
panel pick never enters them.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 22:05:48 -04:00
rzen 73698cd77b A reusable symbol picker — the board's glyph joins its name in the info popover
SymbolPicker: one well at rest, a 6x6 curated grid in a popover (leading
well = the level default, clearing the key), and an optional search over
the OS's full symbol inventory read from CoreGlyphs metadata. Geometry
font-derived off StyleEditorLayout's base, grid enlarged by a deliberate
1.3x. Wired inline with the rename field in the board info popover
through the StyleCommand funnel; the Title header retires. Curated set
and the search's standing vs 03's full-browser refusal await ratification.
2026-08-06 21:18:35 -04:00
rzen d9b6232dea The lane plate's strays return to their anchors — the replica's wash leaves the height arithmetic it broke
The pathfinder surface stack landed in e1ffe71 entangled with staged-index
placement drift: the replica's wash block sat inside replicaHeight (a CGFloat
has no .background — HEAD did not build), the body plate rode outside the cut
dim, the lanePlate property and the DESIGN bullet sat away from their kin.
Pure relocation, no new lines: every block moves to the anchor it was written
for. Verified by an isolated build of this tree.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-06 20:54:08 -04:00
rzen e1ffe71e5b A refused trash hover withdraws the trash's own promise — the fall-through holds only keepable ones
Gap #12 ruled into 04 ▸ the trash-drop bullet: the refusal fall-through's
honest reading (the column declines to be a target, it does not cancel
the held drag) covers only promises the refusal leaves keepable — lane
shadows keep drawing and an ⌥-copy lands where they are. A standing
proposal naming the trash itself is the promise the refusal just broke,
so it withdraws: the drag goes proposal-less over the column, release
with no proposal cancels in agreement with the empty picture, and
lifting ⌥ re-asks at the same address. Implementation filed on the
Implementation board.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 20:48:45 -04:00
rzen 0d8ecdb78b An unopenable repository fails loudly — the standing row, the paused surface, the honest heal
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 18:44:12 -04:00
rzen 26239200ea Denial is not absence — detection learns the unverifiable answer 06 ruled for it
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 17:53:23 -04:00
rzen c60553f17f An alignment tick when the drop proposal lands somewhere new
NSHapticFeedbackManager .alignment at propose()'s change edge — the
one funnel every retarget commits through, whose existing guards
(value-equal refusal, hold freeze) already make firings per-landing-
spot rather than per-pixel; withdrawal stays silent (losing a target
is not an alignment), and the external-file mode gets nothing. Force
Touch hardware only, silent no-op elsewhere; performanceTime .default
syncs the tick to the reflow the change triggers. The performer rides
an injectable seam beside holdTimeout for the same reason it has one.

Drag-perf card 11b85111 — the cluster's last.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-03 13:41:06 -04:00
rzen 43e6ad229e The drag replicas cast the shadow the pickup lift already promised
DragReplicaStyle spells the pathfinder's constant ambient shadow once
(black 16%, radius 2.5, y 1.5) and all three replica faces — card,
lane, trashed lane — wear it via one modifier; the ghost fans compose
from the same faces so every copy inherits it, dimmed by its own
opacity. 03 § Motion's "pickup lift (scale + shadow)" supplies the
scale from the system session but no shadow on the preview image — the
replica is the only place it can come from. BoardMetrics.replicaPadding
was already written to keep exactly this shadow from clipping and was
simply unused until now.

Drag-perf card 24397310.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-03 13:33:52 -04:00
rzen 1ffb64913b A stationary modifier flip answers at the keystroke — the third client of the one shared retarget
Modifiers were sampled only inside resolveOperation, reached from
dropUpdated and the commit — and drop callbacks arrive only while the
mouse moves, so ⌥ pressed against a still pointer changed nothing
until the next twitch. DragSession now arms a local .flagsChanged
watch for exactly the session's lifetime (begin arms, end stops, the
watchdog guarantees end; ⇧/⌃/caps don't count, a settled hold
swallows) and publishes modifierGeneration; the three retargets record
which window's surface resolved the proposal (RetargetOrigin — weak
registry identity, never board root, so cross-board and two-windows-
one-board both answer correctly), and the hovered window replays that
same retarget with the operation re-resolved FIRST, since the index
space is a function of it. The monitor is injectable — the real one
needs a live event stream no test bundle has.

Drag-perf card d491e7d3.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-03 13:24:53 -04:00
rzen 1fc8aaa249 Autoscroll ticks on the display's own clock — the 16ms sleeping loop retires
DragAutoScroller.run() consumes an AsyncStream of CADisplayLink
targetTimestamps from NSView.displayLink(target:selector:) on the
lane's existing anchor view (which was already the right NSView — no
new plumbing). The stream is the lifetime: cancellation ends the
iteration, termination invalidates the link, the link releases its
target. DragAutoScrollClock keeps the retired loop's two non-trivial
rules — first tick scrolls nothing, no tick integrates past 50ms (a
paused link resumes with the whole gap as its delta) — and the math is
frame-rate independent by test: one second at the edge moves 800.0
points at 120Hz and at 60Hz alike. Run-loop mode .common is
load-bearing: a drag runs AppKit's event-tracking mode.

Drag-perf card 2e08fd31.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 20:22:42 -04:00
rzen 0218ae4c21 Drag instrumentation — render counters, hot-path signposts, and the invariants that prove the gates
BoardRenderMetrics (DEBUG-only, the pathfinder's counter bag plus a
strip-body discriminator that tells a failed gate from a direct
Observation invalidation) counted at BoardView/LaneView/CardFaceView/
TrashLaneView bodies and MasonryLayout's callbacks. DragSignposts wraps
dropUpdated, retargetCards, commitDrop, and the commit-to-covering-
snapshot release pause; input latency reports honestly against
NSApp.currentEvent's mach base or labels itself base=none — no event
timestamp rides the drop path. BoardRenderPerformanceTests hosts the
real BoardView off-screen: a value-equal reload runs zero lane and zero
card bodies, a one-card edit repaints one card of 180. The lane-body
budget is <= laneCount with the headerInk chain documented and a
two-way tripwire that fails when the fix lands. Methodology in
RENDER-INSTRUMENTATION.md. Release build proves it all compiles out.

Drag-perf card a450ad09.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 20:04:01 -04:00
rzen 409f430813 The lane-resize release gets its own hold — the strip stops two-stepping through the stale snapshot
LaneWidthHold: after the release the session's frozen standard and the
WRITTEN unit count keep governing the strip until a landed snapshot
carries that width back — dividing the already-grown window by the
stale unit total in between was the visible two-step. A deliberately
separate type from CommittedHold: that hold stands in for an
arrangement and any landing retires it; this one stands in for a value
and only a landing that carries it will do. Echo reads through
LaneLayoutMath.displayUnits so the width-1 key-removal case compares
right; the watch rides landedReloads so a value-equal echo still
answers; width writes now return whether bytes reached disk so a
refused or no-op write dissolves the hold instead of arming it; the
1500ms timeout family covers the rest.

Drag-perf card 231e3693.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 19:33:33 -04:00
rzen f6105d4389 The marquee registry stops being observable — reflow writes cost a dictionary store and nothing else
Drops @Observable from MarqueeTargetRegistry (MarqueeSession keeps it —
its rect genuinely renders the band). The audit found no body read
anywhere: the begin guard and sample loop read from inside the drag
gesture, the arrows from inside a key handler, so nothing ever needed
invalidating when a frame moved — while every make-room reflow had each
sliding face re-firing onGeometryChange per display frame, each write
paying Observation registrar bookkeeping on top of the reflow's own
render work (the confirmed A/B culprit of 2026-07-31). Write-gating on
drag-active was rejected: a suppressed write never replays, leaving the
band and arrows navigating stale rectangles. A tripwire test pins the
registry against anyone re-adding the macro.

Drag-perf confirmed culprit, card eb7b75ce.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 19:14:11 -04:00
rzen a51ad750ad The resting layout is built once per snapshot — retargets stop rebuilding it per mouse sample
RestingLayoutCache (session-scoped, @ObservationIgnored on DragSession,
cleared at begin and end) holds each lane's resting layout as ids +
heights — never [Card] — keyed on the hovered board's applied
snapshotGeneration, the registry's new heightsGeneration, the lane, and
the operation-following hidden set. The grid stays event-time on purpose
(autoscroll moves a lane's origin with the snapshot standing still), so
re-grounding rule 1 holds exactly: nothing survives a reload, only the
per-sample repetition goes. The steady-state cost of a hover is now the
containment scan plus four stores and a divide.

Drag-perf suspect #3, card b9f48fd1.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 18:56:08 -04:00
rzen 84f909a720 Proposal changes stop rebuilding the whole board — lanes and card faces gate on their values
LaneView and CardFaceView become Equatable and are instantiated through
.equatable(): the strip's body re-runs on every drop-proposal change, and
without the gates that rebuilt every lane and every card face on every
cursor move of a drag. The == compares value inputs and window-lived
collaborator identities; the closures BoardView rebuilds each pass are
deliberately excluded (BoardDropContext.isEquivalent / MarqueeControl.
isEquivalent / CardFaceRole.isEquivalent own that judgment). Observation
reads inside the bodies still self-invalidate — the lane under the drag
keeps re-running; the other lanes stop.

Ports the pathfinder's CardView/ColumnView gating pattern (drag-perf
suspect #2, card cbb6e476).

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 18:28:33 -04:00
rzen ef423bb9d2 Every board root keys once at its store — the hover path stops walking the filesystem
`DragLocality.isSameBoard` resolved symlinks on both URLs, which stats every
path component. It ran five-plus times per `dropUpdated` and again per lane per
body evaluation through `renderedCards` → `hiddenMembers` — order of 50–100
stat calls per mouse-move, and worse on iCloud-backed paths. A pickup stall
sample had it on ~47 of 943 stacks.

`BoardRootKey` mints that canonical spelling once, where the root is, and every
locality comparison downstream is an `==` on two strings. The drag carriers hold
keys rather than URLs, so re-deriving under the cursor is no longer expressible.
`rootURL` keeps the user's spelling — the folder name is the display-name
fallback, and canonicalizing there would visibly rename a board opened through
a pin. The key follows the folder through `relocate(to:)`: a key frozen at open
would collide with a new board opened at the vacated path, and two boards
comparing as one is a cross-board drag silently behaving as a move.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 17:55:48 -04:00
rzen 8345378972 The board settings sheet — setup leaves the popover for a home a stray click can't dismiss
The popover/sheet split lands: a board-scoped, titled, sectioned sheet on
the board window hosts everything setup-shaped, opened from the popover's
Board Settings… row and the new Board ▸ Board Settings… menu row. The three
existing setup controls relocate — add-git (whose noteFormVisible lines now
make the sheet the form-anchored failure surface), branch creation (a
standing field; create-and-switch runs the identical settle sequence), and
the commit-identity fields (the 2s visibility-scoped poll rides with them).
The popover keeps the daily face and its postures; its Pro/mode-none
section becomes header + door (.addGit renamed .noRepository). Availability
is derived from the section inventory (Pro + mode none or git), so pro-m2's
sections can't drift from the doors; the sheet's fields join the
caret-chord disable set. The audit suite pins what the free fixture can
reach; the sheet's own audit is manual until a tier override is ruled.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 12:07:12 -04:00
rzen 988a7245a3 Memoize the reload parse and short-circuit value-equal snapshots
The loader gains a ParseMemo — the previous walk's parsed documents keyed
by root-relative path, trusted on the git-index heuristic (mtime + size,
no hashing) and passed as an input so the loader stays stateless. A hit
skips exactly one file read; schema, order, coercions, dedupe, and every
directory listing run fresh, so memoized and cold walks are output-
identical (golden-corpus equivalence suite). Entries record only past the
schema gate, so a defect can never be answered from the memo.

The store skips the snapshot assignment wholesale when the fresh model is
value-equal — no @Observable churn, no render pass, no snapshotGeneration
bump — and a new landedReloads counter carries walk-completion for the
three consumers whose subject is the walk, not the snapshot: the card
window's comment thread, the comment search index, and the auto-committer's
covering gate (which now counts a completed walk as covering even when
nothing changed). Warnings and defects move on their own equality; failed
reloads bump neither counter. An injectable ParseCounter makes the
single-file-echo claim a test.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 11:38:25 -04:00
rzen 5e6417e749 The board search item becomes the stock NSSearchToolbarItem — the field grows on focus
The toolbar's search field now lives in AppKit's own NSSearchToolbarItem:
em(17) is the focused width (preferredWidthForSearchField), the resting
width is the item's own, and ⌘F becomes beginSearchInteraction — one call
that expands and focuses. Escape's empty-field exit collapses the field as
the keyboard leaves. resignsFirstResponderWithCancel is off so the staged
Escape survives the cancel button. The item ships its own live overflow row
and an above-.high visibility priority, so both custom-view workarounds
retire. The palette copy stays inert through an enablement round-trip the
item's searchField setter would otherwise overwrite.

The centering half of the card is blocked on a Design ruling (03 settles
"trailing") and is not built; the two-homes width question is filed.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 11:16:58 -04:00