diff --git a/DESIGN/01-storage-format.md b/DESIGN/01-storage-format.md index a470b39..47f1635 100644 --- a/DESIGN/01-storage-format.md +++ b/DESIGN/01-storage-format.md @@ -74,6 +74,7 @@ Body: board description (free Markdown). |---|---|---|---| | `order` | number | no | Rank among lanes, ascending = left-to-right. Optional (re-ruled 2026-07-31) — missing or unusable reads as append-at-end (Ordering below) | | `width` | integer | no | Width multiplier ≥ 1 (default 1); no cap | +| `collapsed` | boolean | no | `true` folds the lane to a slim strip (03-board-ui.md § Lane ▸ Collapsed lanes). Absent = expanded, and the app **removes** the key to expand rather than writing `false` (the remove-at-default family); a hand-written `false` reads as expanded and is preserved. Lenient: a value with no boolean reading falls back to expanded. `width` is preserved untouched while collapsed | Body: lane description / WIP policy / notes. diff --git a/DESIGN/03-board-ui.md b/DESIGN/03-board-ui.md index 89671bd..43ababe 100644 --- a/DESIGN/03-board-ui.md +++ b/DESIGN/03-board-ui.md @@ -21,6 +21,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i - Title bar: leading SF Symbol (the lane's `icon`), title, **card-count badge** (quiet, secondary styling), new-card button. The whole title bar is the lane's drag surface — no separate grip; a plain click (no movement) on it selects the lane (04-interactions.md ▸ Selection). **The lane has one context menu** (settled), invoked on the header or on lane empty space alike — Rename, Style…, the quick-style recents row, the Width stepper, Delete (inventory normative in 11-command-nexus.md ▸ Context menus); a full lane still has its header, so the menu is always reachable. The count reads the search filter like every other surface (04-interactions.md): during a search it shows the visible count, not the total. - Body: vertical card stack (masonry grid when wide — settled, the pathfinder's masonry works), scrolls vertically. - Right-edge **drag-to-resize** between integer widths (1×, 2×, 3×, … — no cap): shadow snaps at the inter-column gap with 10pt release hysteresis; the window grows/shrinks by one standard width per snap so other lanes keep their exact size. **At the screen's visible frame the window stops and the drag degrades to the re-divide** (settled 2026-08-08, superseding the pathfinder's hard stop): further snaps re-divide the now-fixed width across one more unit each — siblings compress, exactly the stepper's mechanism — so a lane keeps growing at the siblings' expense once the screen is spent; a window with no headroom to begin with (already at the edge, or full screen) re-divides from the first snap. The two regimes meet without a pixel jump — the re-divided standard at the screen fit *is* the frozen standard. Shrinking mirrors it: re-divide back down to the screen fit, then window shrinking resumes (and shrinking is always allowed). The rubber-band feedback (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound) moves to the true end of travel — the strip's own capacity — and the window still never overflows the screen. The header context menu's Width control (stepper, uncapped) remains the precise control: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress); the drag now reaches those widths too, but the stepper stays the exact, accessible path. The **Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘← — 11-command-nexus.md) are this stepper's keyboard face** — same re-divide semantics, never the window's size; window-growing behavior belongs to the drag alone — and they **batch over a multi-lane selection** (settled, the styling precedent): each selected lane steps one unit, one gesture, one commit; the context-menu stepper itself stays single-lane by nature. **A width write landing on 1 removes the `width` key** (settled — the remove-at-default family: the empty rename removes `title`, the None well removes `background`): a default lane's frontmatter stays clean, drag, stepper, and menu items alike; a hand-written `width: 1` is legal and preserved until the app itself next edits width. **A failed width commit at drag release rolls the window back** (settled): the failure surfaces as the ordinary one-shot banner and the window animates back by the uncommitted delta — 02-architecture.md's write-failure honesty (the action visibly doesn't happen) applied to the one control that moves the window. +- **Collapsed lanes** (settled 2026-08-08): a lane folds to a **fixed slim vertical strip** — its `icon`, its card-count badge, its title rotated a quarter turn **counterclockwise so it reads bottom-up** (the book-spine orientation) and **justified to the top** of the room below that chrome (ruled by the owner 2026-08-08, reversing the top-down reading of the first pass), and its accent band, at a fixed width (~44pt, an em multiple like every other figure) — and **the strip is not part of the width re-division**: the window's width divides across the *expanded* lanes' units alone, with each collapsed strip's fixed width taken off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family (the window never moves and the siblings grow into what the lane gave up). Persistence is a first-class lane frontmatter key, `collapsed: true` — **document state exactly like `width`**, because the files are the board and an agent must be able to fold a lane by editing frontmatter; absent means expanded, expanding **removes** the key (never writes `false` — the remove-at-default family), and the lane's `width` is preserved untouched while collapsed so expanding restores the lane the user had. **Toggling**: the header's trailing collapse chevron (always visible beside the new-card button — the header has no hover chrome to match), the lane context menu's single **Collapse Lane** / **Expand Lane** row (single-lane like the width stepper, not batched), and a plain click anywhere on the collapsed strip, which expands it; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. Rename and the expanded header's double-click behaviours are unchanged. **While collapsed**: no resize handle (the width stepper and ⌥⌘→/⌥⌘← still edit the preserved key), and the lane's cards are **not rendered at all** — which is what excludes them from the marquee sweep, from spatial keyboard navigation, and from the ⌥-arrow jumps (↓ from a folded lane is inert, like an empty one), and what makes ⌘N skip the lane for creation (a selection inside a folded lane falls through to the last-active lane rather than refusing). A drop on the strip **appends at the lane's end**, like an end-of-lane drop, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto-expand in v1 (future work). Lane reorder drag works on the strip, and a folded lane's replica and drop shadow are the strip rather than its units. **Search still matches cards in collapsed lanes** — the badge and the flatten lists count them, since collapse is a presentation fold rather than a filter — but the lane stays folded and shows no match: pointing into a folded lane is not something the current search presentation can do, and the honest minimum is that the query does not fold or unfold anything. The **trash quasi-lane is not collapsible**; multi-select styling and the Style editor are unaffected. ## Card face diff --git a/DESIGN/06-history-undo.md b/DESIGN/06-history-undo.md index f8bfd8c..7413833 100644 --- a/DESIGN/06-history-undo.md +++ b/DESIGN/06-history-undo.md @@ -34,7 +34,7 @@ Migrated to 13-native-undo.md (2026-08-08, at this doc\'s retirement) — the ru The pathfinder's message engine carries over as the model — it is what earns the "semantic" in semantic commit messages, and it stays a pure, testable function: - **Pure snapshot diff, no write-site tagging.** Messages compose at commit time from a structural diff of two board snapshots (last-committed vs. current) — never by intercepting operations. Items match by id across the *whole* board, so a lane change is distinguishable from delete+add and a cross-lane move reads as a move. Bookkeeping — `order` changes that preserve sibling sequence (a renumber's rescale — 01-storage-format.md), `modified`/`created`, and an on-touch heal's backfilled `kind` (01-storage-format.md ▸ Validation and healing) — produces no events: a diff touching only those composes nothing. Sequence is what the diff compares, not raw `order` values: an order change that *repositions* an item among its siblings composes Reorder, so a foreign writer's single-file reorder still reads as one. A midpoint-exhaustion renumber batches with the insert or move that triggered it, so its commit reads as that event. -- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus Attach / Remove / **Replace** for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"; Replace added 2026-07-31 — a changed file under a card's `attachments/` with an unchanged listing is a content replacement, named from the path alone: "Replace attachment 'photo.png' — card 'X'", never the anonymous path generic), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's silent scheduled heal, re-ruled 2026-07-29 from its former banner gate; app-mediated and heal-marked, so its separate heal commit names the remint directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: a move into `.trash/` is Delete, a move out of it is Restore ("Restore card 'X'" — drag-to-restore, cut+paste), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — the trash's Delete, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free); the inverse oddity — a card *arriving from outside the tree directly in `.trash/`* — composes "Add card 'X'" with a to-the-trash detail (blessed 2026-07-31: the arrival shape is an Add, the detail names the destination). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows **say so in the subject** (re-ruled 2026-07-31, retiring the bare "Update board" fallback): **"Mixed update — N changes"**, always with a bulleted body naming every event, so the oneline log stays scannable and never dresses a grab-bag as one thing; when every event in the window shares one item — the card-window session flush's usual shape — the subject keeps the name: **"Mixed update — N changes to card '⟨title⟩'"**. +- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus **Collapse / Expand** for the lane fold (added 2026-08-08 with collapsed lanes — 03-board-ui.md § Lane ▸ Collapsed lanes: two verbs rather than one, the trash pair's split, because a row has to read back the gesture it undoes), plus Attach / Remove / **Replace** for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"; Replace added 2026-07-31 — a changed file under a card's `attachments/` with an unchanged listing is a content replacement, named from the path alone: "Replace attachment 'photo.png' — card 'X'", never the anonymous path generic), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's silent scheduled heal, re-ruled 2026-07-29 from its former banner gate; app-mediated and heal-marked, so its separate heal commit names the remint directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: a move into `.trash/` is Delete, a move out of it is Restore ("Restore card 'X'" — drag-to-restore, cut+paste), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — the trash's Delete, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free); the inverse oddity — a card *arriving from outside the tree directly in `.trash/`* — composes "Add card 'X'" with a to-the-trash detail (blessed 2026-07-31: the arrival shape is an Add, the detail names the destination). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows **say so in the subject** (re-ruled 2026-07-31, retiring the bare "Update board" fallback): **"Mixed update — N changes"**, always with a bulleted body naming every event, so the oneline log stays scannable and never dresses a grab-bag as one thing; when every event in the window shares one item — the card-window session flush's usual shape — the subject keeps the name: **"Mixed update — N changes to card '⟨title⟩'"**. - **Implied events don't steal the subject**: deleting a lane with five cards reads "Delete lane 'X'" with the card deletions as body bullets — not "Update board". **One level further down, a card's own event swallows its thread entirely** (blessed 2026-07-31): a card moved, deleted, restored, purged, or reminted carries its `comments/` paths silently — the card's event already explains every file under it, and comment bullets trailing "Delete card 'X'" would be the burying this rule exists to prevent. - **Non-snapshot files commit too** (settled — the repo tracks more than the model: the agent guide, `CLAUDE.user.md`, the seeded `.gitignore`, and strays at every level): the committer **stages the whole board root** — whatever `git status` shows, `.gitignore` respected, **open Edit sessions still staged around** (settled): the session card's folder stays excluded exactly as in Rules ▸ Auto-commit, whole-root staging widening *what* commits, never overriding the exclusion — and its commit condition is the *tree*, not the snapshot diff, so a stray-only window commits rather than leaving the tree dirty (firing mid-session, it commits the strays and leaves the session folder untouched) (a permanently dirty stray would break branch switch's cannot-fail-dirty guarantee and void flush-before-overwrite for every file the model can't see). The composer's input extends accordingly: beside the snapshot diff it receives the changed-path list, and non-snapshot paths compose **path-shaped events** — `CLAUDE.md` composes "Update agent guide (vN)", the version read from the guide's marker first line (a pure function of file content, *not* write-site tagging — the no-interception rule stands); any other non-snapshot path composes "Update '⟨path⟩'", folding plural ("Update 3 files"). Model events keep the subject when present; non-snapshot changes then ride as body bullets — recorded, never silently absorbed under an unrelated subject. Attribution needs no new rule: the EchoLedger (02-architecture.md) is path-keyed, so the app's guide write classifies app-mediated by its receipt (the mechanism behind the guide-attribution exception below) and a stray edit classifies foreign, the two-commit split applying per file as everywhere else. - **Healing mutations commit separately** (ruled 2026-07-29 — 01-storage-format.md ▸ Validation and healing): a debounce window holding a scheduled heal's changes alongside anyone else's splits the heal's paths into their own commit — the two-commit split's mechanism with a third class, keyed by the Writer's heal-marked receipts in the EchoLedger; a three-way window commits **foreign → heal → user** (blessed 2026-07-31 — causal: what was found, the response to it, the newest overwrite); split staging starts by **resetting the shared index to HEAD** (blessed 2026-07-31 — another writer's staged-but-uncommitted picks are discarded, their *content* still committing on the app's classified terms; the exposed race is only the gap between a foreign `add` and its `commit`, since a held `index.lock` already backs the app off, and a private in-memory index beneath the wrapper is the named upgrade if cohabitation friction ever shows) (attribution machinery, like the author split; the composer stays a pure diff reader and names the heal commit from its own diff shape — a loose-file relocation reads as its Attach-shaped event, a legacy migration's move into `.trash/` as Delete, the guide as "Update agent guide (vN)"; that the shape vocabulary doesn't say "healed" is accepted, the banner already told the user). Mostly redundant — each scheduled healer runs its own bracket at the reload tail, normally its own window — but the split makes separation a guarantee rather than a timing accident. **Heal commits are authored `Lanework Integrity `** (ruled 2026-07-31 — the third pinned synthetic, joining Lanework External and the agent-slug family; strings are API): a heal is a third origin — not the user's gesture, not a foreign writer — and the separation exists for audit, so the trail filters by author like every origin; the committer stays the user (the recorded-by convention above). On-touch and inline heals are structurally exempt: each lives inside a host write or its triggering gesture and rides that commit, the backfilled `kind` composing no event per the bookkeeping rule above. diff --git a/Kanban/Changes/ChangeNarrator.swift b/Kanban/Changes/ChangeNarrator.swift index c0c6ed7..d0f306b 100644 --- a/Kanban/Changes/ChangeNarrator.swift +++ b/Kanban/Changes/ChangeNarrator.swift @@ -397,6 +397,23 @@ enum ChangeNarrator { paths: paths )) } + // **The fold, in the reading rather than the key** (03-board-ui.md § Lane ▸ Collapsed + // lanes): `LaneLayoutMath.isCollapsed` is what the strip draws off, so a `collapsed: + // false` appearing beside an absent key changes no lane on screen and composes + // nothing — the same "sequence, not raw `order`" discipline the reorder rule follows. + // + // Two kinds rather than one, on the trash pair's precedent (06 ▸ Commit messages): the + // directions are distinguished by diff shape alone, and each says its own word. + let collapsed = LaneLayoutMath.isCollapsed(newLane) + if LaneLayoutMath.isCollapsed(oldLane) != collapsed { + let verb = collapsed ? "Collapse" : "Expand" + events.append(Event( + kind: collapsed ? .collapseLane : .expandLane, + subject: "\(verb) lane \(quotedSubject(new.displayTitle))", + bullet: "\(verb) lane \(quoted(new.displayTitle))", + paths: paths + )) + } } events += metadataEvents( previous: old.document, @@ -1166,6 +1183,9 @@ enum ChangeNarrator { case attachFile, removeFile, replaceFile, reorderCards, repairDuplicate case addLane, deleteLane, restoreLane, purgeLane, renameLane, editLane, restyleLane, resizeLane + /// The lane fold's two directions (03-board-ui.md § Lane ▸ Collapsed lanes) — two kinds for + /// the trash pair's reason: they are told apart by diff shape alone and each says its own word. + case collapseLane, expandLane case relabelLane, assignLane, dueLane, changeKeyLane, reorderLanes case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, changeKeyBoard @@ -1235,6 +1255,8 @@ enum ChangeNarrator { case .editLane: return "Edit \(count) lanes" case .restyleLane: return "Restyle \(count) lanes" case .resizeLane: return "Resize \(count) lanes" + case .collapseLane: return "Collapse \(count) lanes" + case .expandLane: return "Expand \(count) lanes" case .relabelLane: return "Relabel \(count) lanes" case .assignLane: return "Assign \(count) lanes" case .dueLane: return "Set due date on \(count) lanes" diff --git a/Kanban/History/HistoryPhrase.swift b/Kanban/History/HistoryPhrase.swift index 537523c..a9531af 100644 --- a/Kanban/History/HistoryPhrase.swift +++ b/Kanban/History/HistoryPhrase.swift @@ -56,6 +56,14 @@ public enum HistoryPhrase { case rename = "Rename" case restyle = "Restyle" case resize = "Resize" + /// A lane folded to its slim strip (03-board-ui.md § Lane ▸ Collapsed lanes). + /// + /// **Two verbs rather than one**, unlike `resize`, which covers growing and shrinking alike: + /// the row has to read back the gesture it undoes, and "Undo Resize Lane" after *Collapse + /// Lane* would name a control the user never touched. The trash pair's Delete/Restore split is + /// the precedent one level up. + case collapse = "Collapse" + case expand = "Expand" /// An Edit session's body save — one step at the Edit→Preview flip (13 ▸ Rules). case edit = "Edit" } diff --git a/Kanban/History/HistoryStaleness.swift b/Kanban/History/HistoryStaleness.swift index 9e20256..9ca7f3c 100644 --- a/Kanban/History/HistoryStaleness.swift +++ b/Kanban/History/HistoryStaleness.swift @@ -27,6 +27,13 @@ public enum ExpectedField: Sendable, Equatable { /// `width` — the lane resize. `nil` is the one-unit default, whose key the write removes. case width(Int?) + /// `collapsed` — the lane fold (03-board-ui.md § Lane ▸ Collapsed lanes). `nil` is the expanded + /// default, whose key the write removes, so the pair of after-values a collapse and its inverse + /// declare is exactly `true` and `nil` — the app never writes `false` + /// (`FrontmatterKeys.collapsed`), and a hand-written one therefore stales the step, which is the + /// same reading a malformed value gets: somebody else edited the key. + case collapsed(Bool?) + /// `background` — the styling gesture's colour dimension. case background(String?) @@ -59,6 +66,7 @@ public enum ExpectedField: Sendable, Equatable { case .title: .title case .order: .order case .width: .width + case .collapsed: .collapsed case .background: .background case .backgroundImage: .backgroundImage case .icon: .icon @@ -73,6 +81,7 @@ public enum ExpectedField: Sendable, Equatable { case title case order case width + case collapsed case background case backgroundImage case icon @@ -336,6 +345,7 @@ public enum HistoryStaleness { case let .title(expected): equal(document.title, expected) case let .order(expected): document.order.value == expected case let .width(expected): equal(document.width, expected) + case let .collapsed(expected): equal(document.collapsed, expected) case let .background(expected): equal(document.background, expected) case let .backgroundImage(expected): equal(document.backgroundImage, expected) case let .icon(expected): equal(document.icon, expected) diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index 1407a1b..ebb6f17 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -897,6 +897,14 @@ public final class BannerCenter { "Couldn't generate this board's background" case let .resize(title): if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" } + case let .collapse(title): + // **The menu row's own word, in both directions** (03-board-ui.md § Lane ▸ Collapsed + // lanes): the user pressed Collapse Lane or Expand Lane, and a shared sentence would tell + // half of them about a gesture they did not make. It says "lane" rather than "the item" + // when untitled, which is the one place in this switch that can: only a lane has the key. + if let title { "Couldn't collapse '\(title)'" } else { "Couldn't collapse the lane" } + case let .expand(title): + if let title { "Couldn't expand '\(title)'" } else { "Couldn't expand the lane" } case let .rename(title): // The title here is the item's name *before* the edit — the one the user is still // looking at — which is what makes "Couldn't rename 'Fix login'" (02's own example diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index b5feace..e28a893 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1590,6 +1590,118 @@ public final class BoardStore: HealHost { } } + // MARK: - Lane collapse + + /// Folds a lane to its slim strip, or unfolds it — the one commit point every collapse gesture + /// shares (03-board-ui.md § Lane ▸ Collapsed lanes): the header chevron, the context menu's + /// Collapse/Expand Lane row, and a click on the collapsed strip's body all land here. + /// + /// **`width`'s write, one key over, deliberately.** Collapse is *document* state exactly as width + /// is — the files are the board, so an agent folds a lane by writing `collapsed: true` and the app + /// follows on the next reload — so this mirrors `writeLaneWidths` clause for clause: the same + /// `updateIndex` bracket, the same stamp behaviour (a collapse rewrites content, so `modified` is + /// stamped and `modified-by` cleared — `WriteOperation.rewritesOrderOnly` is `false` for both new + /// cases), the same three do-nothing paths, and the same remove-at-default rule. + /// + /// **Expanding removes the key; nothing ever writes `false`** (`FrontmatterKeys.collapsed` — the + /// remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None + /// well's `background`). A hand-written `collapsed: false` reads as expanded, so asking to expand + /// such a lane is an unchanged value and writes nothing at all — the legal hand edit survives + /// until the app itself next edits the key, which is `width: 1`'s rule verbatim. + /// + /// **The lane's `width` is untouched**, which is the whole of "expanding restores the lane the user + /// had": one key is written, the other is not read. + /// + /// Three ways this does nothing, all `setLaneWidth`'s: an id that is not in the snapshot is ignored + /// (the lane vanished under the gesture), a value the lane already reads as writes nothing (a + /// double-collapse must not stamp `modified`), and a refusal is the banner's — `performWrite` posts + /// every `BoardWriteError` before it rethrows, so the rethrow is swallowed and the lane stays as it + /// was, which is the truth. + /// + /// **A card selection inside the lane becomes the lane** (this milestone's call, one of the two + /// selection rules a fold needs): the cards stop being rendered, so a selection naming them would + /// leave the arrows with no frame to step from and the ⌫ target invisible. Re-selecting the lane is + /// the placeholder commit's own move ("Return commits and re-selects the lane") applied to the + /// gesture that takes the cards away, and it leaves the strip itself as the selected thing — which + /// is what the user just acted on. A *lane* selection is untouched, and expanding never touches the + /// selection at all. + /// + /// **Returns whether bytes reached disk**, `setLaneWidth`'s answer for its reason; discardable + /// because every caller here is a menu row or a click with nothing to do with it. + @discardableResult + public func setLaneCollapsed(_ id: ItemID, collapsed: Bool) -> Bool { + guard let lane = snapshot.lanes.first(where: { $0.id == id }), + LaneLayoutMath.isCollapsed(lane) != collapsed + else { return false } + + let folder = rootURL.appendingPathComponent(id.rawValue) + let prior = lane.collapsed + let title = lane.title.value + + // The closure's signature is spelled out for `writeLaneWidths`' reason — `try?` widens an + // inferred typed `throws` to `any Error`, which `performWrite` will not take. + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in + try Self.setCollapsed(collapsed, at: folder) + } + guard landed != nil else { return false } + + // collapse/expand → the prior reading (13-native-undo.md ▸ Rules). The validated field is + // `collapsed`, read as the app reads it: `nil` is the absent key, which is what an expand + // leaves and what the inverse of a collapse restores whenever the prior was anything but a + // real `true` — a missing key, a hand-written `false`, and a malformed value all inverse to + // "remove it", exactly as `restoreCollapsed` writes them. + registerStep( + HistoryPhrase.name(collapsed ? .collapse : .expand, kind: .lane), + subject: title, + undoExpects: [.present(folder, .collapsed(collapsed ? true : nil))], + redoExpects: [.present(folder, .collapsed(prior.value == true ? true : nil))] + ) { _ in + try BoardWriter.updateIndex( + inItemFolder: folder, + operation: prior.value == true ? .collapse(title: nil) : .expand(title: nil) + ) { document in + Self.restoreCollapsed(prior, in: &document) + } + } redo: { _ in + try Self.setCollapsed(collapsed, at: folder) + } + + if collapsed, selection.container == .board, + lane.cards.contains(where: { selection.ids.contains($0.id) }) { + select([id], in: .board, anchor: id, head: id) + } + return true + } + + /// The collapse write itself, spelled once so the gesture and its redo cannot drift apart on the + /// remove-at-default rule — `setWidth`'s shape, and the operation follows the *direction* so a + /// failure is announced in the word the user pressed. The title rides along from `updateIndex`'s + /// own enrichment, as it does for every other operation that carries one. + private static func setCollapsed(_ collapsed: Bool, at folder: URL) throws(BoardWriteError) { + let operation: WriteOperation = collapsed ? .collapse(title: nil) : .expand(title: nil) + try BoardWriter.updateIndex(inItemFolder: folder, operation: operation) { document in + if collapsed { + document.set(FrontmatterKeys.collapsed, to: .bool(true)) + } else { + document.remove(FrontmatterKeys.collapsed) + } + } + } + + /// The inverse's write: the key back as a real `true`, or gone. + /// + /// **A malformed or `false` prior inverses to the removed key**, never to the bytes that were + /// there: this app writes `collapsed` in exactly one shape, and reproducing somebody's + /// `collapsed: maybe` would be the undo inventing a value. Either way the reading is restored + /// exactly — both of those read as expanded — which is what an inverse owes the user. + private static func restoreCollapsed(_ prior: FieldValue, in document: inout FrontmatterDocument) { + if prior.value == true { + document.set(FrontmatterKeys.collapsed, to: .bool(true)) + } else { + document.remove(FrontmatterKeys.collapsed) + } + } + // MARK: - Styling /// One item a style gesture is about to act on: where its `index.md` is, and what the three diff --git a/Kanban/Storage/AgentGuide.swift b/Kanban/Storage/AgentGuide.swift index 24860f9..8376d05 100644 --- a/Kanban/Storage/AgentGuide.swift +++ b/Kanban/Storage/AgentGuide.swift @@ -91,8 +91,12 @@ enum AgentGuide { /// deliberately git-friendly, a board may live in a repository of the user's own, and there an /// agent stages only its own paths, commits its own changes with clear messages, and leaves the /// app-maintained files to the app. The stamping section drops its auto-commit clause the same - /// way. - static let version = 11 + /// way. **v12 names the lane's `collapsed` key** (03-board-ui.md § Lane ▸ Collapsed lanes): a lane + /// folded to a slim strip is document state exactly like `width`, so an agent can fold and unfold + /// lanes by editing frontmatter — and, more to the point, has to know that `collapsed: true` is why + /// a lane it wrote a card into is not showing it. One clause beside `width` in Frontmatter, with the + /// remove-to-expand rule stated because writing `false` is the mistake the key invites. + static let version = 12 // MARK: - The version marker @@ -399,7 +403,11 @@ enum AgentGuide { `iconColor` (color, tints `icon`). Lanes and cards may set `order` (a number; floats are fine) — **optional, and the way to control position**: an item without one goes last. Lanes may set `width` (integer ≥ 1, multiplier of the - standard lane width). + standard lane width) and `collapsed` (`true` folds the lane to a slim strip + in the app; its cards are still there, just not drawn). **To expand a lane, + remove the `collapsed` key** rather than writing `collapsed: false` — an + absent key is the default, and the app removes it too. A lane's `width` + rides along untouched while it is folded. **Quote any `title` containing a colon** — `title: Fix: the thing` is invalid YAML; write `title: "Fix: the thing"`. The same goes for any value diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 38c4dd0..515c4e2 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -1052,6 +1052,7 @@ public enum BoardLoader: Sendable { iconColor: document.iconColor, order: order, width: document.width, + collapsed: document.collapsed, cards: cards.filter { !withheld.contains(name + "/" + $0.id.rawValue) }, document: document ) diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index 80d2aa3..ed4b1f0 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -204,6 +204,15 @@ public struct Lane: Identifiable, Sendable, Equatable { /// failing the load (01-storage-format.md § Frontmatter). public let width: FieldValue + /// **Folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — `width`'s sibling in + /// every respect that matters here: lenient (a value with no boolean reading renders as expanded, + /// bytes untouched), document state rather than window state, and preserved verbatim beside it — + /// a collapsed lane keeps its `width` so expanding restores the lane the user had. + /// + /// The *reading* is `LaneLayoutMath.isCollapsed(_:)`, which is where the rest of the app asks; + /// this is the field, and it carries the shape so the coerce tier can report on it. + public let collapsed: FieldValue + /// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break). /// A card still carrying a legacy `deleted:` key rides along flagged (`Card.isDeleted`) until /// its migration relocates it into `BoardModel.trash` — see `BoardModel.lanes`. diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 40852a5..3919b79 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -2969,6 +2969,24 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { /// nobody could confuse for another. case setBoardBackground case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane) + + /// **A lane folded to its slim strip** — the `collapsed: true` write (03-board-ui.md § Lane ▸ + /// Collapsed lanes; `BoardStore.setLaneCollapsed`). + /// + /// **Its own case rather than a fold into `.resize`**, on the vocabulary's standing reasoning: the + /// two keys sit side by side in a lane's frontmatter and both change how wide the lane draws, but + /// the user pressed *Collapse Lane* — and a banner telling them the app "couldn't resize 'Todo'" + /// would name a gesture they never made and a control (the stepper, the edge drag) they never + /// touched. + /// + /// **And its own case rather than one shared with `.expand`**, which is `.delete`/`.restore`'s + /// split one rung down: the two directions are two menu rows with two words, and neither + /// direction's failure may be announced in the other's verb. + case collapse(title: String?) + + /// **A collapsed lane unfolded** — the write that *removes* `collapsed` (the remove-at-default + /// family; `FrontmatterKeys.collapsed`). See `.collapse` for why the pair is two cases. + case expand(title: String?) /// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸ /// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the /// surfaces" is settled (02-architecture.md § Write-failure surfacing, which names @@ -3223,6 +3241,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case .migrateTombstone: .migrateTombstone(title: title) case .style: .style(title: title) case .resize: .resize(title: title) + case .collapse: .collapse(title: title) + case .expand: .expand(title: title) case .rename: .rename(title: title) case .duplicateBoard: .duplicateBoard(title: title) case .saveAsTemplate: .saveAsTemplate(title: title) @@ -3269,7 +3289,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { // reading to be about — and `.deleteComment`'s move into `comments/.trash/` stamps for the // plain container reason its board-level twin does. case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone, - .style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment, + .style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .paste, + .importAttachment, .listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground, .displaceClaimedName, @@ -3300,6 +3321,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case let .style(title): Self.phrase("style", title) case .setBoardBackground: "set this board's background" case let .resize(title): Self.phrase("resize", title) + case let .collapse(title): Self.phrase("collapse", title) + case let .expand(title): Self.phrase("expand", title) case let .rename(title): Self.phrase("rename", title) case let .duplicateBoard(title): Self.phrase("duplicate board", title) case let .saveAsTemplate(title): Self.phrase("save as template", title) diff --git a/Kanban/Storage/FrontmatterDocument.swift b/Kanban/Storage/FrontmatterDocument.swift index 0231c08..c6c9df0 100644 --- a/Kanban/Storage/FrontmatterDocument.swift +++ b/Kanban/Storage/FrontmatterDocument.swift @@ -560,6 +560,20 @@ public enum FrontmatterKeys { public static let title = "title" public static let order = "order" public static let width = "width" + + /// **A lane folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes; 01-storage-format.md + /// § Frontmatter's lane table) — `collapsed: true`, and nothing else. + /// + /// Document state exactly like `width`, deliberately rather than window state: the files *are* the + /// board, so an agent folds a lane away by writing one key and the app follows on the next reload. + /// + /// **Only ever written as `true`, and removed to expand** (the remove-at-default family beside the + /// empty rename's `title`, the None well's `background`, and a one-unit `width`): an absent key is + /// the default reading, so a `collapsed: false` the app wrote would be noise a default lane has to + /// carry. A hand-written `false` is legal, reads as expanded, and is preserved until the app itself + /// next edits the key. + public static let collapsed = "collapsed" + public static let created = "created" public static let modified = "modified" public static let modifiedBy = "modified-by" @@ -619,7 +633,7 @@ public enum FrontmatterKeys { public static let author = "author" public static let schemaOwned: Set = [ - schema, title, order, width, created, modified, modifiedBy, deleted, background, icon, - iconColor, kind, + schema, title, order, width, collapsed, created, modified, modifiedBy, deleted, background, + icon, iconColor, kind, ] } diff --git a/Kanban/Storage/FrontmatterFields.swift b/Kanban/Storage/FrontmatterFields.swift index 70e694d..1b757e5 100644 --- a/Kanban/Storage/FrontmatterFields.swift +++ b/Kanban/Storage/FrontmatterFields.swift @@ -101,6 +101,7 @@ extension FrontmatterDocument { } record(FrontmatterKeys.title, title) record(FrontmatterKeys.width, width) + record(FrontmatterKeys.collapsed, collapsed) record(FrontmatterKeys.created, created) record(FrontmatterKeys.modified, modified) record(FrontmatterKeys.modifiedBy, modifiedBy) @@ -215,6 +216,31 @@ extension FrontmatterDocument { } } + /// **Whether the lane is folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — + /// `width`'s reading one type over, and lenient in exactly its way. + /// + /// A `true`/`false` scalar reads as itself, and so does any of YAML 1.1's other boolean spellings + /// (`yes`/`no`, `on`/`off`, `y`/`n`) written **quoted**: unquoted, the parser has already resolved + /// them to `.bool` for us, and a quoted one is the same word the author meant — the numeric string + /// `width: "2"` coercing to `2` is the precedent, and it is the one thing lenient reading means. + /// Everything else — a number, a sequence, a mapping, `collapsed: maybe` — has no boolean reading + /// at all and is malformed, **which renders as expanded**: the default is the absent key's, so an + /// unreadable value can only ever fold a lane by accident, and it never does. + /// + /// Read-side only, like every other coercion here: the bytes stay exactly as the author wrote them + /// until the user (or an agent) changes the key, and `collapsed: false` is a perfectly good way to + /// write "expanded" by hand even though the app removes the key instead + /// (`FrontmatterKeys.collapsed`). + public var collapsed: FieldValue { + read(FrontmatterKeys.collapsed) { value, _ in + switch value { + case let .bool(value): value + case let .string(text): Self.boolWord(text) + default: nil + } + } + } + public var created: FieldValue { read(FrontmatterKeys.created) { value, _ in Self.date(value) } } public var modified: FieldValue { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } } public var deleted: FieldValue { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } } @@ -300,6 +326,17 @@ extension FrontmatterDocument { return value >= 1 ? Int(value) : 1 } + /// YAML 1.1's boolean words, for a **quoted** scalar that spells one — the coercion `collapsed` + /// needs and no other field wants. Case-insensitive, because the grammar itself is (`True`, `ON`, + /// `Yes` are all booleans unquoted); anything else has no boolean reading. + private static func boolWord(_ text: String) -> Bool? { + switch text.lowercased() { + case "true", "yes", "on", "y": true + case "false", "no", "off", "n": false + default: nil + } + } + /// A quoted timestamp reads the same as an unquoted one — same YAML 1.1 timestamp grammar, /// a superset of ISO-8601. Anything that isn't a valid timestamp either way has no sensible /// date reading and is malformed; per the tombstone rule (`Lane`/`Card.isDeleted`), a diff --git a/Kanban/UI/AccessibilityPhrases.swift b/Kanban/UI/AccessibilityPhrases.swift index ba1d805..f38b470 100644 --- a/Kanban/UI/AccessibilityPhrases.swift +++ b/Kanban/UI/AccessibilityPhrases.swift @@ -87,8 +87,17 @@ enum AccessibilityPhrases { /// search filter like the visible badge", so `LaneView` hands the very collection its badge /// counts (`renderedCards`) and the two can no more disagree than the badge can disagree with /// the masonry. - static func laneLabel(title: String?, cards count: Int) -> String { - "\(displayTitle(title)), lane, \(cardCount(count))" + /// **A folded lane says so** (03-board-ui.md § Lane ▸ Collapsed lanes; 10-accessibility.md's + /// never-colour-alone family, read one rung out: a state a sighted user reads off the strip's + /// shape has to be a word for everyone else). It sits between the level and the count, which is + /// where the container's own qualifier belongs — "Doing, lane, collapsed, 5 cards" — and the count + /// stays the lane's *held* cards, since a collapsed lane renders none and "0 cards" would say the + /// lane was empty when it is merely shut. + /// + /// Defaulted, so every caller that has no fold to report reads exactly as it did before. + static func laneLabel(title: String?, cards count: Int, collapsed: Bool = false) -> String { + let state = collapsed ? "collapsed, " : "" + return "\(displayTitle(title)), lane, \(state)\(cardCount(count))" } /// The lane header's new-card button — "New card in ⟨lane⟩", the one labeled child @@ -97,6 +106,14 @@ enum AccessibilityPhrases { "New card in \(displayTitle(title))" } + /// The lane header's **collapse chevron** — "Collapse ⟨lane⟩" (03-board-ui.md § Lane ▸ Collapsed + /// lanes), the header's second labeled child and the new-card button's twin in every respect: a + /// glyph-only control needs a word, and the word names the lane so the label stands on its own out + /// of context. + static func collapseLaneLabel(lane title: String?) -> String { + "Collapse \(displayTitle(title))" + } + // MARK: - Cards /// A card's label: its title, or the untitled placeholder. Named rather than inlined so the diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index c393ac7..b49a8a1 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -308,6 +308,23 @@ struct BoardDropContext { /// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`). let standard: @MainActor () -> CGFloat + /// A collapsed lane's fixed strip width at this board's current zoom (03-board-ui.md § Lane ▸ + /// Collapsed lanes) — the one lane width that is not a multiple of `standard`, so every zone list + /// and every hit test below needs it to place the lanes at all. + /// + /// Read off the registry rather than captured, for `bodyPointSize`'s own reason: these delegates run + /// at event time, outside any body evaluation, and the level is a property of the window. + var collapsedWidth: CGFloat { + BoardMetrics.collapsedLaneWidth(bodyPointSize: registry.bodyPointSize) + } + + /// The lanes' **drawn** widths in board order — a slot width per expanded lane, the fixed strip per + /// folded one. The one geometry every strip-level answer here is built from. + func drawnWidths(of lanes: [Lane]) -> [CGFloat] { + LaneLayoutMath.drawnWidths( + of: lanes, standard: standard(), gap: gap, collapsedWidth: collapsedWidth) + } + /// Whether two of these would send a drop to the same place — **the whole of what this value /// contributes to `LaneView.==` and `CardFaceView.==`.** /// @@ -400,17 +417,22 @@ struct BoardDropContext { guard let cursor = stripCursor() else { return } let hidden = session.hiddenMembers(onBoardRooted: store.rootKey) let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) } - let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) } + // **Drawn widths, not unit counts**: a folded lane occupies one narrow zone on the strip rather + // than the slot its preserved `width` would buy, and a folded lane being dragged contributes its + // strip to the run's span cap (03-board-ui.md § Lane ▸ Collapsed lanes). Both readings come off + // the one function the strip itself lays out with, so the zones cannot disagree with the picture. + let restingWidths = drawnWidths(of: resting) let slot = DropSlotMath.laneSlot( cursorX: cursor.x, - restingUnits: restingUnits, - draggedUnits: session.laneUnits, - standard: standard(), + restingWidths: restingWidths, + draggedWidths: LaneLayoutMath.drawnWidths( + units: session.laneUnits, collapsed: session.laneCollapsed, + standard: standard(), gap: gap, collapsedWidth: collapsedWidth), gap: gap, current: session.stripProposal(onBoardRooted: store.rootKey) ) guard let slot else { return } // a dead region: hold the current proposal - let index = min(max(0, slot), restingUnits.count) + let index = min(max(0, slot), restingWidths.count) session.propose(DropTarget(boardRoot: store.rootKey, container: .strip, index: index)) } @@ -443,6 +465,26 @@ struct BoardDropContext { // by its callers, so the delegate, the strip's fall-through and the autoscroll driver all // leave the same address behind. session.noteRetarget(.lane(laneID), registry: registry) + // **A folded lane is one zone, and the zone means the end of the lane** (03-board-ui.md § Lane ▸ + // Collapsed lanes: "dropping a drag onto the collapsed strip appends the payload at the lane's + // END, like an end-of-lane drop"). It is answered *before* the cursor is read, deliberately, and + // the answer says why: a strip draws no cards, so there is no row geometry for a position to be + // resolved against and nothing about the pointer's y can change the reading. The strip's own + // accent edge is the feedback the missing shadow would have been (`LaneView.collapsedDropStroke`). + // + // The index is counted in the lane's **resting** layout — its cards with the dragged run lifted + // out — because that is the space every proposal in this app is counted in and the space + // `BoardStore.moveCards` resolves in. + // + // No hover-to-auto-expand in v1 (noted as future work): the drop lands, the lane stays folded, + // and the badge on its strip goes up by one when the echo reload arrives. + if let lane = store.snapshot.lanes.first(where: { $0.id == laneID }), + LaneLayoutMath.isCollapsed(lane) { + let hidden = session.hiddenMembers(onBoardRooted: store.rootKey) + let count = lane.cards.count { !hidden.contains($0.id) } + session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: count)) + return + } guard let cursor = globalCursor() else { return } guard let resting = session.restingLayouts.layout( inLane: laneID, @@ -477,12 +519,8 @@ struct BoardDropContext { func retargetCardsFromStrip() { guard session.isDraggingCards, let cursor = stripCursor() else { return } let lanes = store.snapshot.lanes - let index = LaneLayoutMath.laneIndex( - atX: cursor.x, - unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) }, - standard: standard(), - gap: gap - ) + // Drawn widths, so a folded lane's narrow zone is where it actually is — see `retargetLanes`. + let index = LaneLayoutMath.laneIndex(atX: cursor.x, widths: drawnWidths(of: lanes), gap: gap) guard let index, lanes.indices.contains(index) else { return } retargetCards(inLane: lanes[index].id) } @@ -664,13 +702,28 @@ struct BoardDropContext { /// exactly the tradeoff every proposal in this app makes: the answer stays a pure function of the /// cursor and the snapshot, so it cannot oscillate — the drawn layout never feeds back into it. func retargetFile(inLane laneID: ItemID, info: DropInfo) { - guard acceptsFileDrop(info), let cursor = globalCursor(), - let lane = store.snapshot.lanes.first(where: { $0.id == laneID }), - let grid = registry.grids[laneID] + guard acceptsFileDrop(info), + let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else { session.proposeFile(nil) return } + // **A folded lane is one zone, and it creates at the lane's end** — `retargetCards`' collapsed + // branch, in the file mode's vocabulary and for its reasons: no card faces means no attach + // target and no row geometry, so neither of `FileDropZones.landing`'s first two answers can + // apply and the third has nothing to resolve against. The count is the files', as always. + if LaneLayoutMath.isCollapsed(lane) { + session.proposeFile(FileDropTarget( + boardRoot: store.rootKey, + landing: .create(laneID: laneID, index: lane.cards.count), + fileCount: FinderDrop.shadowCount(info.itemProviders(for: [.fileURL])) + )) + return + } + guard let cursor = globalCursor(), let grid = registry.grids[laneID] else { + session.proposeFile(nil) + return + } let rendered = lane.cards let heights = rendered.map { registry.heights[$0.id] ?? registry.nominalCardHeight } @@ -720,12 +773,7 @@ struct BoardDropContext { return } let lanes = store.snapshot.lanes - let index = LaneLayoutMath.laneIndex( - atX: cursor.x, - unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) }, - standard: standard(), - gap: gap - ) + let index = LaneLayoutMath.laneIndex(atX: cursor.x, widths: drawnWidths(of: lanes), gap: gap) guard let index, lanes.indices.contains(index) else { session.proposeFile(nil) return diff --git a/Kanban/UI/Board/BoardMetrics.swift b/Kanban/UI/Board/BoardMetrics.swift index a571314..16ee66a 100644 --- a/Kanban/UI/Board/BoardMetrics.swift +++ b/Kanban/UI/Board/BoardMetrics.swift @@ -92,6 +92,23 @@ enum BoardMetrics { em(0.3, bodyPointSize: bodyPointSize) } + /// **A collapsed lane's slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — the one lane + /// width in the app that is *not* the window's division: a collapsed lane takes this and nothing + /// more, and `LaneLayoutMath.standardWidth` divides what is left among the expanded lanes. + /// + /// 3.4 em — 44pt at the standard 13pt body, which is the figure the ruling asked for (roughly + /// 40–48pt) and the same multiple `nominalCardHeight` uses, so the strip is about as wide as a + /// default card is tall. Wide enough for the lane's glyph, a rotated title and the count badge + /// stacked in a column; narrow enough that a folded lane reads as a spine rather than as a lane. + /// + /// **Font-derived like everything else here**, and it has to be: the strip's whole content is + /// type and a badge, so a figure fixed at 44pt would clip its own glyph at a large system text + /// size, and 10-accessibility.md's full-relative-scaling rule would quietly stop holding. It moves + /// with the board's zoom for the same reason every other figure does. + static func collapsedLaneWidth(bodyPointSize: CGFloat) -> CGFloat { + em(3.4, bodyPointSize: bodyPointSize) + } + /// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities) — the lane accent band's /// height. static func laneAccentBandHeight(bodyPointSize: CGFloat) -> CGFloat { @@ -110,6 +127,25 @@ enum BoardMetrics { em(1.7, bodyPointSize: bodyPointSize) } + /// The room the header reserves for the **collapse chevron** beside the new-card button + /// (03-board-ui.md § Lane ▸ Collapsed lanes) — `newCardButtonReserve`'s twin, and it exists for + /// exactly that figure's reason: two glyphs now share the header's trailing edge, and a title has + /// to truncate before it reaches either. + /// + /// 1.4 em — 18pt at the standard 13pt body: the chevron is a smaller glyph than the plus and sits + /// closer to it than to the title, so it asks for less breathing room than the button it precedes. + static func laneCollapseButtonReserve(bodyPointSize: CGFloat) -> CGFloat { + em(1.4, bodyPointSize: bodyPointSize) + } + + /// Everything the lane header's trailing chrome takes — the chevron plus the new-card button. The + /// one figure the header pads by, so "a long title truncates before it collides" stays one rule + /// however many controls end up living there. + static func laneHeaderTrailingReserve(bodyPointSize: CGFloat) -> CGFloat { + newCardButtonReserve(bodyPointSize: bodyPointSize) + + laneCollapseButtonReserve(bodyPointSize: bodyPointSize) + } + /// The count badge's capsule inset — horizontal and vertical, which are deliberately different: /// a capsule around a single digit wants width, not height. static func badgeHorizontalPadding(bodyPointSize: CGFloat) -> CGFloat { diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index e937492..55e09c2 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -131,6 +131,15 @@ struct BoardView: View { /// degenerate case accepted, not floored" (03-board-ui.md § Layout — full visibility). private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: zoom.bodyPointSize) } + /// A collapsed lane's fixed strip width (03-board-ui.md § Lane ▸ Collapsed lanes) — **the one lane + /// width on the strip that is not the window's division**. It is taken off the top and the + /// remainder is what the expanded lanes divide (`LaneLayoutMath.standardWidth`), 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. + private var collapsedWidth: CGFloat { + BoardMetrics.collapsedLaneWidth(bodyPointSize: zoom.bodyPointSize) + } + var body: some View { // The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator // between "the gate did not suppress" and "Observation invalidated the lane directly". @@ -341,12 +350,16 @@ struct BoardView: View { // container" survives a right-to-left layout direction or a lane slot the // resize session lifts to `zIndex(1)`. .accessibilitySortPriority(Double(slots.count - index)) - case let .shadow(_, units): + case let .shadow(_, units, collapsed): // One of the drag's N contiguous shadows, at the exact width the arriving lane // will occupy — its units measured against *this* strip's standard, which is - // what makes the drop land precisely where the shadow shows. + // what makes the drop land precisely where the shadow shows. **A folded lane's + // shadow is the slim strip**: the drop leaves it folded, so its `width` key is not + // what the strip is about to draw (03-board-ui.md § Lane ▸ Collapsed lanes). DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize)) - .frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)) + .frame(width: LaneLayoutMath.drawnWidth( + units: units, isCollapsed: collapsed, + standard: standard, gap: spacing, collapsedWidth: collapsedWidth)) .frame(maxHeight: .infinity) } } @@ -585,7 +598,14 @@ struct BoardView: View { // the pre-drag layout for a round trip. let resizing = resize.governs(lane.id) let units = resize.displayUnits(of: lane) - let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing) + // **A folded lane takes the slim strip and nothing else** (03-board-ui.md § Lane ▸ Collapsed + // lanes). Its `width` still reads — the key is preserved untouched while collapsed, which is how + // expanding restores the lane the user had — but the strip is what it draws, and the division + // above has already taken this width off the top rather than dividing it. + let collapsed = LaneLayoutMath.isCollapsed(lane) + let slotWidth = LaneLayoutMath.drawnWidth( + units: units, isCollapsed: collapsed, + standard: standard, gap: spacing, collapsedWidth: collapsedWidth) ZStack(alignment: .topLeading) { if resizing { DragShadow( @@ -617,26 +637,34 @@ struct BoardView: View { } .frame(width: slotWidth, alignment: .topLeading) .zIndex(resizing ? 1 : 0) + // **No grab strip on a folded lane** (03-board-ui.md § Lane ▸ Collapsed lanes: "lane resize + // handles don't appear on collapsed lanes"). The strip's width is a constant and its `width` key + // is outside the division while it is folded, so a drag there would be a gesture with nothing to + // move — and it would grow the *window* per snap for a lane that is not drawing units at all. + // The width stepper and ⌥⌘→/⌥⌘← stay available and still edit the preserved key, which is what + // the lane will be when it unfolds. .overlay(alignment: .trailing) { - LaneResizeHandle( - store: store, - session: resize, - laneID: lane.id, - committedUnits: units, - standard: standard, - gap: spacing, - totalUnits: stripTotalUnits, - window: window - ) - // The read-only lock disables every mutating gesture, not just the menu items - // (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag - // resizes the *window* on the way, so a refused commit would leave the window grown - // around a lane that snapped back — and the lock's row is already saying why nothing - // can be written. The focused-editor rule closes it too, like every board command, and - // so does a drag session in flight — the edge drag "refuses to start while a card/lane - // session is in flight" (DRAG-REORDER.md § Adjacent interaction): two gestures mutating - // one strip layout is not a state this view has a meaning for. - .disabled(store.isReadOnly || store.isEditingInline || appModel.dragSession.isActive) + if !collapsed { + LaneResizeHandle( + store: store, + session: resize, + laneID: lane.id, + committedUnits: units, + standard: standard, + gap: spacing, + totalUnits: stripTotalUnits, + window: window + ) + // The read-only lock disables every mutating gesture, not just the menu items + // (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag + // resizes the *window* on the way, so a refused commit would leave the window grown + // around a lane that snapped back — and the lock's row is already saying why nothing + // can be written. The focused-editor rule closes it too, like every board command, and + // so does a drag session in flight — the edge drag "refuses to start while a card/lane + // session is in flight" (DRAG-REORDER.md § Adjacent interaction): two gestures mutating + // one strip layout is not a state this view has a meaning for. + .disabled(store.isReadOnly || store.isEditingInline || appModel.dragSession.isActive) + } } } @@ -733,12 +761,19 @@ struct BoardView: View { /// from the destination's side: the shadow occupies its units, and the strip has to make room /// for them or the shadow would be drawn at a width the lane will not have /// (`arrivingLaneUnits`). + /// - a **collapsed** lane's fixed strip, which is the one contribution that is *subtracted* rather + /// than divided (03-board-ui.md § Lane ▸ Collapsed lanes: "the strip is not part of the width + /// re-division; collapsed strips consume their fixed width"). Folding a lane therefore re-divides + /// exactly as the trash's unit joining and leaving does — the window is untouched and the expanded + /// lanes grow into the space the lane gave up. private func standardWidth(stripWidth: CGFloat) -> CGFloat { if resize.isActive { return resize.standard } return LaneLayoutMath.standardWidth( stripWidth: stripWidth, totalUnits: stripTotalUnits + arrivingLaneUnits, - gap: spacing + gap: spacing, + collapsedCount: stripCollapsedCount + arrivingCollapsedLanes, + collapsedWidth: collapsedWidth ) } @@ -754,6 +789,16 @@ struct BoardView: View { LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0) } + /// How many of this strip's lanes are drawn as slim strips — `standardWidth`'s subtraction, and the + /// other half of the divide beside `stripTotalUnits` (03-board-ui.md § Lane ▸ Collapsed lanes). + /// + /// Read off the **snapshot** rather than off what the strip is showing, exactly as the unit total is: + /// a lane in flight is still a lane on the board, so a within-board drag never re-divides the strip + /// (DRAG-REORDER.md § The lane strip's resting layout is arithmetic). + private var stripCollapsedCount: Int { + LaneLayoutMath.collapsedCount(of: boardLanes) + } + /// The units a cross-board lane run would add to this strip while its shadow is proposed here; /// zero for a within-board drag, whose lanes are already counted. /// @@ -773,13 +818,34 @@ struct BoardView: View { /// (`standardWidth`), and the drop delegates retarget against it through `dropContext.standard`, /// so the zones can never disagree with what is on screen. private var arrivingLaneUnits: Int { + guard isArrivingFromAnotherBoard else { return 0 } + let session = appModel.dragSession + let folded = session.laneCollapsed + // **A folded visitor brings no units, only its strip** — the resting rule read from the + // destination's side: what the shadow has to make room for is the width the lane will *draw*, + // and a collapsed lane draws the fixed strip `arrivingCollapsedLanes` accounts for. + return session.laneUnits.enumerated() + .filter { !($0.offset < folded.count && folded[$0.offset]) } + .reduce(0) { $0 + $1.element } + } + + /// How many **folded** lanes a cross-board run would add to this strip while its shadow is proposed + /// here — `arrivingLaneUnits`' other half, and the subtraction side of the same pre-divide. + private var arrivingCollapsedLanes: Int { + guard isArrivingFromAnotherBoard else { return 0 } + return appModel.dragSession.laneCollapsed.prefix(appModel.dragSession.laneUnits.count).count { $0 } + } + + /// Whether a lane run from **another** board is proposing into this strip right now — the gate both + /// pre-divide contributions share, so they can never disagree about whether there is a visitor. + private var isArrivingFromAnotherBoard: Bool { let session = appModel.dragSession guard session.isDraggingLanes, session.stripProposal(onBoardRooted: store.rootKey) != nil, let source = session.sourceRoot, source != store.rootKey - else { return 0 } - return session.laneUnits.reduce(0, +) + else { return false } + return true } /// The strip's current lane-drop proposal — the reflow's narrow animation key, and where the @@ -791,14 +857,16 @@ struct BoardView: View { /// One position in the strip: a lane, or one of the drag's N contiguous shadows. private enum StripSlot: Identifiable { case lane(Lane) - case shadow(index: Int, units: Int) + /// A shadow at the arriving lane's **drawn** footprint — its frozen unit count, plus whether it + /// arrives folded, which decides between the two (03-board-ui.md § Lane ▸ Collapsed lanes). + case shadow(index: Int, units: Int, collapsed: Bool) var id: String { switch self { case let .lane(lane): "lane:\(lane.id.rawValue)" // Constant per position, so a shadow run keeps its identity as the proposal slides and // the run animates as a move rather than blinking out and back in. - case let .shadow(index, _): "shadow:\(index)" + case let .shadow(index, _, _): "shadow:\(index)" } } } @@ -811,7 +879,13 @@ struct BoardView: View { let hidden = session.hiddenMembers(onBoardRooted: store.rootKey) var slots = boardLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane) guard let index = stripProposal else { return slots } - let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) } + let folded = session.laneCollapsed + let shadows = session.laneUnits.enumerated().map { + StripSlot.shadow( + index: $0.offset, + units: $0.element, + collapsed: $0.offset < folded.count && folded[$0.offset]) + } slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count)) return slots } @@ -1027,7 +1101,8 @@ struct BoardView: View { if mode == .jump, direction == .left || direction == .right { return jumpToEndLane(direction) } - guard let first = Self.firstCard(scanning: boardLanes, filter: store.searchFilter) else { return .handled } + guard let first = NavigationMath.firstCard(scanning: boardLanes, filter: store.searchFilter) + else { return .handled } replaceSelection(with: first, in: .board) return .handled } @@ -1168,11 +1243,12 @@ struct BoardView: View { } let lanes = boardLanes let filter = store.searchFilter - // A lane the search emptied is scanned past exactly as an empty one is — the jump lands on - // the first lane that is *showing* a card, which is what the user can see. + // A lane the search emptied is scanned past exactly as an empty one is — and so is a collapsed + // one (03-board-ui.md § Lane ▸ Collapsed lanes). The jump lands on the first lane that is + // *showing* a card, which is what the user can see (`NavigationMath.firstCard`). let target = direction == .right - ? Self.firstCard(scanning: lanes.reversed(), filter: filter) - : Self.firstCard(scanning: lanes, filter: filter) + ? NavigationMath.firstCard(scanning: lanes.reversed(), filter: filter) + : NavigationMath.firstCard(scanning: lanes, filter: filter) guard let target else { return .handled } replaceSelection(with: target, in: .board) return .handled @@ -1224,6 +1300,11 @@ struct BoardView: View { guard let lane = store.snapshot.lanes.first(where: { $0.id == head }) else { return .handled } + // **A folded lane has nothing to descend into** (03-board-ui.md § Lane ▸ Collapsed lanes): + // its cards are not rendered, so ↓ is inert exactly as it is on an empty lane — landing on + // a card the strip draws nowhere would leave the arrows with no frame to step from next. + // The way in is to expand the lane, which is what the strip's own click does. + guard !LaneLayoutMath.isCollapsed(lane) else { return .handled } let cards = lane.cards guard let target = mode == .jump ? cards.last : cards.first else { return .handled } replaceSelection(with: target.id, in: .board) @@ -1241,19 +1322,4 @@ struct BoardView: View { private func replaceSelection(with id: ItemID, in container: ItemContainer) { store.select([id], in: container, anchor: id, head: id) } - - /// The first rendered card of the first lane that has one — the scan every "first/last lane" - /// destination shares, run over the lane order forwards or reversed. - /// - /// "Rendered" includes the search filter, so a lane whose cards the query all hid is scanned - /// past like an empty one — `liveCards(in:filter:)`'s membership, one lane at a time. - private static func firstCard( - scanning lanes: some Sequence, - filter: SearchFilter = .inactive - ) -> ItemID? { - for lane in lanes { - if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id } - } - return nil - } } diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index 5f4a22e..2cfaf18 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -323,6 +323,19 @@ final class DragSession { /// whichever board's standard width it is being proposed into. @ObservationIgnored private(set) var laneUnits: [Int] = [] + /// Which of the dragged lanes are **collapsed**, aligned 1:1 with `laneUnits` — frozen at drag + /// start beside them (03-board-ui.md § Lane ▸ Collapsed lanes). + /// + /// A second array rather than a richer element type, because it is read in exactly the two places + /// `laneUnits` is and always together with it: the shadow's width, and the run's span cap + /// (`BoardView.stripSlots`, `DropSlotMath.laneSlot`). A folded lane's shadow is its slim strip, not + /// the slot its preserved `width` would have bought — the drop leaves it folded, so a wider shadow + /// would promise a lane the strip is not about to draw. + /// + /// **Empty is "none of them"**, which is what a card session and a cleared session both hold, and + /// what a `beginLanes` caller with no collapse question to ask leaves it as. + @ObservationIgnored private(set) var laneCollapsed: [Bool] = [] + /// The per-lane resting layouts this drag's retargets propose against, built once per snapshot /// rather than once per mouse sample (`RestingLayoutCache`, which states why that leaves rule 1 /// of the re-grounding trio exactly as it was). @@ -627,16 +640,22 @@ final class DragSession { ) cardHeights = heights laneUnits = [] + laneCollapsed = [] } /// Begins a lane session. /// /// `container` is a parameter because a **trashed lane row**'s drag is a `.lanes` session in /// `.trash` — the restore at the lane level (04-interactions.md ▸ The trash ▸ Drag-to-restore). + /// + /// `collapsed` is the run's fold state, aligned with `units` — see `laneCollapsed`. It defaults to + /// empty, which reads as "none of them": a trashed lane row has no `collapsed` reading to carry + /// (03-board-ui.md § Trash — a trashed lane is an opaque row, never a lane on the board). func beginLanes( _ members: [ItemID], folders: [URL], units: [Int], + collapsed: [Bool] = [], container: ItemContainer = .board, source: BoardStore, mixesKinds: Bool = false, @@ -647,6 +666,7 @@ final class DragSession { container: container, source: source, mixesKinds: mixesKinds, seed: seed ) laneUnits = units + laneCollapsed = collapsed cardHeights = [] } @@ -771,6 +791,7 @@ final class DragSession { folders = [] cardHeights = [] laneUnits = [] + laneCollapsed = [] restingLayouts.clear() proposal = nil operation = .move diff --git a/Kanban/UI/Board/DropSlotMath.swift b/Kanban/UI/Board/DropSlotMath.swift index fdcd8cc..bf3dd87 100644 --- a/Kanban/UI/Board/DropSlotMath.swift +++ b/Kanban/UI/Board/DropSlotMath.swift @@ -138,10 +138,21 @@ enum DropSlotMath { /// for that shorter list: it is a function of the board's unit total, and a lane in flight is /// still a lane on the board (DRAG-REORDER.md § The lane strip's resting layout is arithmetic). static func laneExtents(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> [ClosedRange] { + laneExtents( + widths: unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) }, + gap: gap) + } + + /// The same extents over **drawn** widths, which is what a strip holding collapsed lanes has: a + /// slim strip's width is a fixed figure and not a multiple of the standard (03-board-ui.md § Lane ▸ + /// Collapsed lanes; `LaneLayoutMath.drawnWidths`). + /// + /// The primitive, with the unit-count version above as its wrapper — one walk, so a folded lane can + /// only ever move a zone, never change how zones are built. + static func laneExtents(widths: [CGFloat], gap: CGFloat) -> [ClosedRange] { var extents: [ClosedRange] = [] var left = gap - for units in unitCounts { - let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap) + for width in widths { extents.append(left...(left + width)) left += width + gap } @@ -152,9 +163,16 @@ enum DropSlotMath { /// widths plus the `n − 1` gaps between them. This is the span the trigger regions are capped /// at, and it is exactly the shadow run's future footprint. static func laneRunSpan(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat { - guard !unitCounts.isEmpty else { return 0 } - let widths = unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) } - return widths.reduce(0, +) + gap * CGFloat(unitCounts.count - 1) + laneRunSpan( + widths: unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) }, + gap: gap) + } + + /// The same span over **drawn** widths — a dragged run that includes a collapsed lane occupies that + /// lane's slim strip, not the slot its `width` key would have bought (see `laneExtents(widths:)`). + static func laneRunSpan(widths: [CGFloat], gap: CGFloat) -> CGFloat { + guard !widths.isEmpty else { return 0 } + return widths.reduce(0, +) + gap * CGFloat(widths.count - 1) } /// Where a lane drag would land: an index into the ordered live lanes **with the dragged run @@ -179,12 +197,36 @@ enum DropSlotMath { standard: CGFloat, gap: CGFloat, current: Int? + ) -> Int? { + laneSlot( + cursorX: cursorX, + restingWidths: restingUnits.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) }, + draggedWidths: draggedUnits.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) }, + gap: gap, + current: current + ) + } + + /// The same answer from **drawn** widths — what the board actually asks, since a collapsed lane's + /// slim strip is neither a unit count nor a multiple of the standard (03-board-ui.md § Lane ▸ + /// Collapsed lanes). + /// + /// A folded lane is an ordinary member of both lists: it occupies one zone in the resting strip like + /// any other lane (so a reorder drags *across* it in one narrow zone rather than several), and a + /// folded lane being dragged contributes its strip's width to the run's span cap, so the trigger + /// region matches the footprint the drop will actually produce. + static func laneSlot( + cursorX: CGFloat, + restingWidths: [CGFloat], + draggedWidths: [CGFloat], + gap: CGFloat, + current: Int? ) -> Int? { slot( cursor: cursorX, - extents: laneExtents(unitCounts: restingUnits, standard: standard, gap: gap), + extents: laneExtents(widths: restingWidths, gap: gap), gap: gap, - draggedSpan: laneRunSpan(unitCounts: draggedUnits, standard: standard, gap: gap), + draggedSpan: laneRunSpan(widths: draggedWidths, gap: gap), current: current ) } diff --git a/Kanban/UI/Board/LaneLayoutMath.swift b/Kanban/UI/Board/LaneLayoutMath.swift index 1ff43b1..c16c4d3 100644 --- a/Kanban/UI/Board/LaneLayoutMath.swift +++ b/Kanban/UI/Board/LaneLayoutMath.swift @@ -42,9 +42,27 @@ enum LaneLayoutMath { /// considered in the pathfinder and deliberately rejected. The 1pt floor exists only so a frame /// is never zero or negative — the pathological input (a strip narrower than its own gaps) must /// not produce a negative size for SwiftUI to complain about. - static func standardWidth(stripWidth: CGFloat, totalUnits: Int, gap: CGFloat) -> CGFloat { + /// + /// **Collapsed lanes are taken off the top, never divided** (03-board-ui.md § Lane ▸ Collapsed + /// lanes): each one consumes a fixed `collapsedWidth` plus the gap that follows it, and the + /// remainder is what the expanded lanes' `totalUnits` divide. So a strip of `T` units and `C` + /// collapsed strips still fills exactly — `C·collapsedWidth + T·standard + (T + C + 1)·gap` — and + /// `collapsedCount * (collapsedWidth + gap)` is that identity rearranged, which is why the + /// subtraction carries a gap with it. + /// + /// Both extra arguments default to nothing, so a board with no folded lane reads exactly as it did + /// before they existed — and so does every call site that has no collapse question to ask (the + /// resize drag's two regimes, whose `startTotalUnits` is already the expanded total). + static func standardWidth( + stripWidth: CGFloat, + totalUnits: Int, + gap: CGFloat, + collapsedCount: Int = 0, + collapsedWidth: CGFloat = 0 + ) -> CGFloat { let count = CGFloat(max(1, totalUnits)) - return max(1, (stripWidth - gap * (count + 1)) / count) + let folded = CGFloat(max(0, collapsedCount)) * (collapsedWidth + gap) + return max(1, (stripWidth - folded - gap * (count + 1)) / count) } /// The rendered width of a `units`-unit lane: `units` standard widths plus the `units - 1` @@ -70,8 +88,81 @@ enum LaneLayoutMath { max(1, lane.width.value ?? 1) } - /// The unit total a strip of `lanes` divides across — the sum of their display units, never - /// below 1 so `standardWidth` cannot be handed a zero divisor for an empty board. + /// **Whether the lane is drawn as a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — the + /// one place the rest of the app asks, so the leniency below is stated once. + /// + /// `collapsed` is a **lenient** field exactly like `width` (01-storage-format.md § Frontmatter): a + /// missing key, an explicit `false`, and a value with no boolean reading at all (`.malformed`) are + /// one answer here — **expanded** — with the author's bytes left alone either way. Only a real + /// `true` folds a lane, which is what makes an unreadable value harmless rather than surprising. + /// + /// A collapsed lane keeps its `width` untouched (`BoardStore.setLaneCollapsed` writes one key and + /// only one), so `displayUnits` still answers for it — and is deliberately still asked, by the + /// interior masonry the lane will draw again the moment it expands. + static func isCollapsed(_ lane: Lane) -> Bool { + lane.collapsed.value == true + } + + /// How many of `lanes` are drawn as slim strips — the count `standardWidth` takes off the top. + static func collapsedCount(of lanes: [Lane]) -> Int { + lanes.count { isCollapsed($0) } + } + + /// A lane's **drawn** width: its slot width when expanded, the fixed strip when collapsed. The one + /// answer every hit test and every zone list is built from, so the arithmetic can never disagree + /// with what the strip laid out (`BoardView.laneSlot` frames each lane with this). + static func drawnWidth( + units: Int, + isCollapsed: Bool, + standard: CGFloat, + gap: CGFloat, + collapsedWidth: CGFloat + ) -> CGFloat { + isCollapsed ? collapsedWidth : slotWidth(units: units, standard: standard, gap: gap) + } + + /// `drawnWidth` over a run of lanes, in the order given — the widths list the strip's zones and + /// hit tests walk (`DropSlotMath.laneExtents`, `laneIndex(atX:widths:gap:)`). + static func drawnWidths( + of lanes: [Lane], + standard: CGFloat, + gap: CGFloat, + collapsedWidth: CGFloat + ) -> [CGFloat] { + lanes.map { + drawnWidth( + units: displayUnits(of: $0), + isCollapsed: isCollapsed($0), + standard: standard, + gap: gap, + collapsedWidth: collapsedWidth) + } + } + + /// `drawnWidth` over a **dragged** run, whose fold state travels beside its unit counts rather than + /// inside a `Lane` (`DragSession.laneUnits` / `laneCollapsed`, both frozen at pickup). + /// + /// A `collapsed` array shorter than `units` reads as expanded past its end, which is what a + /// `beginLanes` caller that passed none means — and the trashed-lane restore is exactly that caller. + static func drawnWidths( + units: [Int], + collapsed: [Bool], + standard: CGFloat, + gap: CGFloat, + collapsedWidth: CGFloat + ) -> [CGFloat] { + units.enumerated().map { index, units in + drawnWidth( + units: units, + isCollapsed: index < collapsed.count && collapsed[index], + standard: standard, + gap: gap, + collapsedWidth: collapsedWidth) + } + } + + /// The unit total a strip of `lanes` divides across — the sum of the **expanded** lanes' display + /// units, never below 1 so `standardWidth` cannot be handed a zero divisor for an empty board. /// /// The caller decides *which* lanes: the strip passes the snapshot's, in order. There is no /// liveness question left to ask — "Cards only. Lanes are never trashed" (03-board-ui.md § @@ -86,8 +177,20 @@ enum LaneLayoutMath { /// Show/Hide Trash is therefore a **re-divide trigger** and nothing more — the window is /// untouched, and the existing width divides across one more (or one fewer) unit, exactly as a /// lane add does (§ Layout — full visibility). + /// + /// **A collapsed lane contributes nothing** (03-board-ui.md § Lane ▸ Collapsed lanes: "the strip + /// is not part of the width re-division"): its fixed width is `standardWidth`'s subtraction, not a + /// share of the division, so folding a lane away is a re-divide trigger of the same family — the + /// window is untouched and the siblings grow into the space the lane gave up. + /// + /// The `max(1,)` therefore covers one more shape than it used to: a board whose **every** lane is + /// collapsed has no expanded unit at all, and the 1 it answers is a divisor guard rather than a + /// description of anything on screen. Nothing draws with that standard — the strips take their + /// fixed width and the leftover is empty board — except a shown trash column, which is a real unit + /// in the total and correctly gets the whole remainder. static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int { - max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) } + max(0, trashUnits)) + let units = lanes.reduce(0) { $0 + (isCollapsed($1) ? 0 : displayUnits(of: $1)) } + return max(1, units + max(0, trashUnits)) } // MARK: - Hit testing @@ -104,9 +207,21 @@ enum LaneLayoutMath { /// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back /// into logic"). static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? { + laneIndex( + atX: x, + widths: unitCounts.map { slotWidth(units: $0, standard: standard, gap: gap) }, + gap: gap) + } + + /// The same hit test over **drawn** widths — the shape a strip with collapsed lanes in it has to + /// ask, since a slim strip's width is a fixed figure rather than a multiple of the standard + /// (`drawnWidths(of:standard:gap:collapsedWidth:)`). + /// + /// This is the primitive and the unit-count version above is its wrapper: one walk, one origin + /// convention, so a board with no folded lane cannot answer differently from one with. + static func laneIndex(atX x: CGFloat, widths: [CGFloat], gap: CGFloat) -> Int? { var left = gap - for (index, units) in unitCounts.enumerated() { - let width = slotWidth(units: units, standard: standard, gap: gap) + for (index, width) in widths.enumerated() { if x >= left, x < left + width { return index } left += width + gap } diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index be7362a..5dad153 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -24,6 +24,22 @@ import SwiftUI /// the quick-style recents row, the Width stepper and Delete — 11-command-nexus.md ▸ Context menus' /// Lane row, in its order, complete as of m5. /// +/// ### Collapsed: the slim strip +/// +/// A lane whose `collapsed` reading is `true` (`LaneLayoutMath.isCollapsed`) draws **none** of the +/// above: no header, no masonry, no resize handle — a fixed-width vertical strip carrying the lane's +/// glyph, the same count badge, and its title rotated to read **bottom-up** and justified to the top of +/// the room below them (03-board-ui.md § Lane ▸ Collapsed lanes; the orientation is an owner ruling of +/// 2026-08-08/09 — see `collapsedTitle`). The accent band, the plate, the selection treatment and the +/// cut dim are unchanged, because a folded lane is still that lane. +/// +/// What the fold takes away it takes away **by not drawing it**, which is how the hidden trash's +/// invisibility works and why this needs so little code elsewhere: no card face means no marquee +/// registration and no navigation frame, no grid registration means the card zones have nothing to +/// resolve against (`BoardDropContext.retargetCards` answers end-of-lane instead), and no header means +/// no rename target and no new-card button. The strip keeps exactly three gestures: a plain click +/// expands, a modified click selects the lane, and a drag reorders it. +/// /// ### The card face /// /// `CardFaceView`, complete as of m5: the search filter narrows what the masonry lays out and what @@ -122,6 +138,12 @@ struct LaneView: View, Equatable { /// is set to. @State private var measuredHeaderHeight: CGFloat = 0 + /// The vertical room the collapsed strip's rotated title has to run in — measured, because it is + /// whatever the strip's height leaves after the glyph and the badge, and the title's **truncation + /// is stated against it** (`collapsedTitle`). Zero until the strip lays out, which draws no title + /// for one frame rather than a badly-placed one. + @State private var collapsedTitleTravel: CGFloat = 0 + /// The card stack's viewport height — the masonry's height *floor* (`scrollableCards`). /// Measured because a `ScrollView` proposes nothing along its scroll axis, so no frame maximum /// can stretch the content to fill it; only an explicit minimum can. @@ -169,14 +191,27 @@ struct LaneView: View, Equatable { // lane's top edge, so it must sit outside the content inset rather than in it. VStack(alignment: .leading, spacing: 0) { accentBand - VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) { - header - cardStack + // **The fold is a different body, not a hidden one** (03-board-ui.md § Lane ▸ Collapsed + // lanes): the header and the masonry are not built at all, which is what makes every + // "collapsed lanes are excluded from …" rule below true by construction rather than by a + // guard per gesture — nothing registers a frame, a grid, a header rect or a marquee target + // because nothing exists to register one. + if isCollapsed { + collapsedStrip + .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)) + } else { + VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) { + header + cardStack + } + .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)) } - .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)) } .background(lanePlate) .overlay(selectionStroke) + // The fold's own drop feedback — see `collapsedDropStroke`. Outside the `if` so it is one + // overlay whatever the lane is doing, and inert (clear) whenever there is nothing to say. + .overlay(collapsedDropStroke) // The deferred cut's dim (04-interactions.md ▸ Clipboard) — on the whole lane, because a cut // lane is cut cards and all. .cutTreatment(of: lane.id, in: store) @@ -198,7 +233,171 @@ struct LaneView: View, Equatable { // hid is never built, so it leaves the masonry and the accessibility tree in the same pass, // which is 10's "filtered-out cards leave layout and the accessibility tree together" holding // by construction rather than by a second rule. - .accessibilityLabel(AccessibilityPhrases.laneLabel(title: lane.title.value, cards: renderedCards.count)) + .accessibilityLabel(AccessibilityPhrases.laneLabel( + title: lane.title.value, + // **A collapsed lane speaks its held cards, not its rendered ones** — it renders none, and + // "0 cards" would say the lane was empty when it is merely shut. The badge on the strip + // shows the same number for the same reason (`collapsedStrip`). + cards: isCollapsed ? lane.cards.count : renderedCards.count, + collapsed: isCollapsed)) + } + + /// Whether this lane is folded to its slim strip — `LaneLayoutMath.isCollapsed`'s reading, named + /// here because this body asks it a dozen times (03-board-ui.md § Lane ▸ Collapsed lanes). + private var isCollapsed: Bool { + LaneLayoutMath.isCollapsed(lane) + } + + // MARK: - The collapsed strip + + /// The fold's whole face: the lane's glyph, its title rotated to read top-to-bottom, and the count + /// badge, stacked in a column the width of `BoardMetrics.collapsedLaneWidth` (03-board-ui.md § Lane + /// ▸ Collapsed lanes). + /// + /// **The whole strip is one surface with three gestures**, deliberately fewer than the expanded + /// lane's two-surface arrangement (header + empty space) because there is nothing to tell apart: a + /// plain click **expands** (the fold's own reversal, and the discoverable one — there is no header + /// to hang a chevron on), a ⌘/⇧ click enters the ordinary selection grammar so a folded lane can + /// still be selected and ranged over by pointer, and a drag reorders the lane exactly as its header + /// would (`startLaneDrag`). A double click is a plain click twice: the first expands, and the second + /// lands on the lane that is now open — no create, because the strip has no empty space to + /// double-click *into*. + private var collapsedStrip: some View { + VStack(spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) { + // **The glyph and the badge are the strip's chrome, and they sit at its top** (owner ruling + // 2026-08-08/09): the title is top-justified below them, so the two fixed pieces stay + // exactly where they are however long the title runs — a badge underneath a + // variable-length run would drift with the words above it. + Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane)) + .foregroundStyle(.secondary) + .imageScale(.medium) + // **The lane's held count, not its rendered one**: a folded lane renders no card, so the + // filter has nothing to narrow here and `0` would be a lie about the lane's contents. The + // search's own answer is the *expanded* badge's rule and stays there (`countBadge`). + countBadgeText("\(lane.cards.count)") + collapsedTitle + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + // The strip is chrome all the way down, so it has to be hit-testable across its whole height — + // including the empty stretch under the badge, which is most of it on a tall board. + .contentShape(Rectangle()) + .onTapGesture { + let modifier = ClickModifier.current + guard modifier == .plain else { + // ⌘/⇧ keep their grammar: the strip is the lane's one pointer surface while folded, so + // taking the modifiers away would make a collapsed lane unselectable by mouse. + store.click( + SelectionTarget(id: lane.id, kind: .lane, container: .board), + modifier: modifier + ) + return + } + expand() + } + .onDrag(startLaneDrag, preview: { dragReplica }) + // **The strip is the lane's heading while folded** — the header's role, since there is no + // header (10-accessibility.md ▸ Rotor: "lane titles are headings, so the headings rotor jumps + // lane-to-lane"). Flattened for the same reason the header is: the glyph and the count are the + // container's information, already spoken by its label. + .accessibilityElement(children: .ignore) + .accessibilityLabel(AccessibilityPhrases.displayTitle(lane.title.value)) + .accessibilityAddTraits(headerTraits) + // The keyboard's way out of the fold, and the pointer's, on one method: VO-Space would + // otherwise be the one path that could not undo what it can see. + .accessibilityAction(named: "Expand Lane") { expand() } + .accessibilityAction { toggleLaneSelection() } + .boardTextInk(headerInk) + .contextMenu { laneMenu } + .accessibilityActions { laneActions } + // The Style… popover's anchor while folded — the strip stands in for the header, which is + // where an expanded lane's popover hangs (`styleEditorPresentation` decides whether this lane + // is the session's anchor at all). Without it a restyle invoked from the strip's own menu would + // have nowhere to present from. + .popover(isPresented: styleEditorPresentation(store, anchor: lane.id), arrowEdge: .trailing) { + StyleEditorPopover(store: store, recents: appModel.styleRecents) + } + } + + /// The title, turned a quarter turn **counterclockwise** so it reads **bottom-up** — the European + /// book-spine orientation — and **justified to the top** of the room below the strip's chrome + /// (owner ruling 2026-08-08/09, superseding the top-down reading this shipped with for an hour). + /// + /// Both halves are one alignment plus one sign, and both are worth spelling out because the + /// rotation is a *visual* transform over an unrotated layout: + /// + /// - **`-90°`** maps the unrotated `+x` direction (the reading direction) onto `-y` (up), so the + /// string's first character lands at the run's bottom and the last at its top: the eye travels + /// upward, and the tail ellipsis — plain `.truncationMode(.tail)`, untouched — lands at the top + /// end, which is where the string's tail lands by construction. + /// - **`alignment: .trailing`** puts a short string against the unrotated box's right edge, and + /// right maps to up under the same rotation: the glyphs occupy the *topmost* stretch of the run + /// and the slack falls away beneath them. Anchoring the box instead of the glyphs would leave a + /// short title floating in the middle of a full-height frame. + /// + /// **It truncates against the strip's height**, which is the whole reason the travel is measured + /// rather than derived: a rotated `Text` given no width lays out at its full ideal length and simply + /// overhangs the strip's ends. Laying it out at `collapsedTitleTravel` first — the leftover height, + /// measured by the clear box it is drawn over — is 03's graceful-truncation rule holding in the one + /// place on the board where the axis is vertical. + /// + /// The clear box takes whatever the chrome above it left (`maxHeight: .infinity`), so the leftover + /// arithmetic is the layout's rather than this file's. + private var collapsedTitle: some View { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { collapsedTitleTravel = $0 } + .overlay { rotatedTitle(travel: collapsedTitleTravel) } + } + + /// The title turned on its side, laid out against `travel` points of vertical room — the strip's and + /// its replica's one construction, so the drag image reads and truncates exactly as the lane does. + private func rotatedTitle(travel: CGFloat) -> some View { + Text(lane.title.value ?? "Untitled") + .boardFont(.headline) + .foregroundStyle(lane.title.value == nil ? .secondary : .primary) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: travel, alignment: .trailing) + .rotationEffect(.degrees(-90)) + } + + /// **The drop feedback a folded lane can give** (03-board-ui.md § Lane ▸ Collapsed lanes: a drop on + /// the strip appends at the lane's end, "with the normal drop feedback"). + /// + /// The normal feedback is a shadow opening in the masonry at the landing slot, and a strip has no + /// masonry to open one in — so the strip says the same thing the way the card-attach highlight does + /// (04-interactions.md ▸ Drag and drop: "each target gets one clear signal, and the card-attach + /// highlight exists precisely because that target has no shadow"): an accent edge around the lane + /// while the proposal names it. Both session kinds reach it, ours and Finder's, because both land + /// at the same place. + /// + /// Clear rather than absent when there is nothing to say, so the overlay never changes identity. + @ViewBuilder + private var collapsedDropStroke: some View { + let proposing = isCollapsed + && (cardProposal != nil + || drops.session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: lane.id) != nil) + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder( + proposing + ? Color.accentColor.opacity(Accommodations.accentOpacity(0.6, contrast: contrast)) + : .clear, + lineWidth: Accommodations.borderWidth(2, contrast: contrast) + ) + .allowsHitTesting(false) + } + + /// Unfolds the lane — the strip's click, its VoiceOver action, and the menu row's expand half, on + /// one method so the three cannot mean different things. + private func expand() { + guard store.acceptsBoardMutations else { return } + store.setLaneCollapsed(lane.id, collapsed: false) + } + + /// Folds it — the header chevron's and the menu row's collapse half. + private func collapse() { + guard store.acceptsBoardMutations else { return } + store.setLaneCollapsed(lane.id, collapsed: true) } // MARK: - Header @@ -256,7 +455,15 @@ struct LaneView: View, Equatable { measuredHeaderHeight = frame.height } .onDisappear { drops.registry.removeHeader(lane.id) } - .overlay(alignment: .trailing) { newCardButton } + // **The header's trailing chrome, outside the drag region** — the overlay is attached + // after `.onDrag` deliberately, which is what makes both controls clickable rather than + // grabbable (the new-card button's long-standing arrangement, now shared). + .overlay(alignment: .trailing) { + HStack(spacing: 0) { + collapseButton + newCardButton + } + } // **10-accessibility.md's ≥ 4.5:1 rule, at the one place on the board where text sits on // a colour the user chose** (`BoardTextInk`) — palette name and hand-written hex alike, // since the ink they need is the same question and only the *verification* differs @@ -351,6 +558,18 @@ struct LaneView: View, Equatable { widthControl + // **The fold's own row, under the width control it belongs beside** (03-board-ui.md § Lane ▸ + // Collapsed lanes) — one row whose title says which direction it goes, the Show/Hide Trash + // shape rather than a checkmark: a menu row that toggles a *geometry* reads better as the verb + // it performs than as a state it reports. + // + // **Single-lane, like the width stepper above it and unlike Style… and Delete**: the design + // gives the width batch to the ⌥⌘→/⌥⌘← menu items and keeps the stepper on the lane whose menu + // is open, and the fold has no keyboard face to carry a batch. So this row acts on the clicked + // lane outright, which is also what its singular title promises. + collapseControl + .disabled(!store.acceptsBoardMutations) + Divider() // Delete: File ▸ Delete's exact store path (`store.delete`), on the same widened target set @@ -373,10 +592,21 @@ struct LaneView: View, Equatable { .disabled(!store.acceptsBoardMutations) Button("Decrease Width") { store.setLaneWidth(lane.id, units: units - 1) } .disabled(!store.acceptsBoardMutations || units <= 1) + collapseControl + .disabled(!store.acceptsBoardMutations) Button("Delete") { deleteTargets() } .disabled(!store.acceptsBoardMutations) } + /// The fold's one row, in the direction this lane can actually go — the context menu's and its + /// VoiceOver twin's, spelled once so the two surfaces cannot drift into meaning different things + /// (the `laneActions` rule). + private var collapseControl: some View { + isCollapsed + ? Button("Expand Lane") { expand() } + : Button("Collapse Lane") { collapse() } + } + /// Board ▸ Rename's store path, seeded with the lane's live title — one method, two callers /// (the context menu row and its accessibility twin). private func beginRename() { @@ -456,12 +686,13 @@ struct LaneView: View, Equatable { countBadge Spacer(minLength: 0) } - // Reserves the button's width so a long title truncates before it collides, and keeps the - // button out of the gestured region. **Font-derived** rather than a fixed 22pt: the button - // is an `Image` at a relative image scale, so a fixed reserve would be overrun by the glyph - // itself at a large system text size and 03-board-ui.md's graceful-truncation rule would - // quietly stop holding (`BoardMetrics.newCardButtonReserve`). - .padding(.trailing, BoardMetrics.newCardButtonReserve(bodyPointSize: pointSize)) + // Reserves the trailing chrome's width so a long title truncates before it collides, and keeps + // those controls out of the gestured region. **Font-derived** rather than a fixed figure: both + // are `Image`s at a relative image scale, so a fixed reserve would be overrun by the glyphs + // themselves at a large system text size and 03-board-ui.md's graceful-truncation rule would + // quietly stop holding (`BoardMetrics.laneHeaderTrailingReserve`, which is the collapse + // chevron's room plus the new-card button's). + .padding(.trailing, BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: pointSize)) .padding(.horizontal, BoardMetrics.laneHeaderInset(bodyPointSize: pointSize)) } @@ -509,7 +740,14 @@ struct LaneView: View, Equatable { /// what the body renders, and while an inline rename is open its card is one of the things the /// body renders (see `renderedCards`). private var countBadge: some View { - Text("\(renderedCards.count)") + countBadgeText("\(renderedCards.count)") + } + + /// The badge's chrome with the number as a parameter — the header's and the collapsed strip's + /// badge are the *same* badge, and the two differ only in which count they are of (see + /// `collapsedStrip`). + private func countBadgeText(_ text: String) -> some View { + Text(text) .boardFont(.caption) .monospacedDigit() .foregroundStyle(.secondary) @@ -518,6 +756,35 @@ struct LaneView: View, Equatable { .background(Capsule().fill(.quaternary)) } + /// **The collapse chevron** (03-board-ui.md § Lane ▸ Collapsed lanes) — the pointer's way into the + /// fold, beside the new-card button at the header's trailing edge. + /// + /// **Always visible, not hover-revealed**, because the header has no hover chrome to match: the + /// new-card button beside it has been unconditional since it existed, and one control that appears + /// on hover next to one that does not reads as a glitch rather than as a convention. The cost is + /// paid in the header's trailing reserve, which is the figure that keeps the title truncating before + /// either glyph (`headerContent`). + /// + /// `chevron.left` because the fold travels that way — the lane's content collapses toward its + /// leading edge and leaves a spine — and the strip's reversal is a click on the strip itself, which + /// is why there is no `chevron.right` twin drawn over there: the whole strip is the button. + /// + /// Mutating, so it disables exactly where every other write path does — the read-only lock and the + /// focused-editor rule (`store.acceptsBoardMutations`, the pointer twin of a disabled menu item). + private var collapseButton: some View { + Button { + collapse() + } label: { + Image(systemName: "chevron.left") + .imageScale(.small) + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(AccessibilityPhrases.collapseLaneLabel(lane: lane.title.value)) + .disabled(!store.acceptsBoardMutations) + } + /// The new-card button — a **pointer twin** of File ▸ New Card whose click *names its target*: /// "the lane header's new-card button overrides [the ⌘N target] rule — the click names its /// target lane, selection notwithstanding" (11-command-nexus.md ▸ Pointer grammar, settled), so @@ -585,6 +852,9 @@ struct LaneView: View, Equatable { // The dragged items' own sizes, frozen at drag start — the one thing that is // (03-board-ui.md § Motion). units: members.map { LaneLayoutMath.displayUnits(of: $0) }, + // And which of them are folded, frozen with them: a collapsed lane's shadow is its slim + // strip rather than the slot its preserved `width` would buy (`DragSession.laneCollapsed`). + collapsed: members.map { LaneLayoutMath.isCollapsed($0) }, source: store, // The strip's own-slot seed (`DragSession.begin`): the grabbed lane's position among // the lanes with the dragged run removed — the strip proposal's own index space. A @@ -617,7 +887,14 @@ struct LaneView: View, Equatable { /// it was lifted from (`DragPreviewAnchor`, which is where the arithmetic and its reasoning live). private var dragReplica: some View { let count = max(1, draggedLaneCount) - let anchor = DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY) + // **A folded lane needs no anchoring, and must not have any.** The padding exists because the + // grab surface — the title bar — is a sliver at the top of a full-height replica, so the image + // has to be shifted for the cursor to keep the point it grabbed. The strip *is* the whole lane: + // SwiftUI centres the preview on the view the drag started from, which here is already the + // thing being carried, so a shift would be the very displacement the anchor exists to undo. + let anchor = isCollapsed + ? DragPreviewAnchor.Padding.none + : DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY) return ZStack { if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) } if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) } @@ -648,31 +925,38 @@ struct LaneView: View, Equatable { private var replicaFace: some View { VStack(alignment: .leading, spacing: 0) { accentBand - VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) { - headerContent - VStack(alignment: .leading, spacing: cardSpacing) { - ForEach(renderedCards.prefix(12)) { card in - HStack(alignment: .firstTextBaseline, - spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) { - Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card)) - .foregroundStyle(.secondary) - .imageScale(.medium) - Text(card.title.value ?? "Untitled") - .boardFont(.body) - .lineLimit(2) - Spacer(minLength: 0) + // **A folded lane's replica is its strip** (03-board-ui.md § Motion's faithful-replica + // rule, read literally): the drop leaves the lane folded, so carrying a squeezed header and + // a stack of card rows would show the user a lane the board is not about to draw. + if isCollapsed { + collapsedReplicaStrip + } else { + VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) { + headerContent + VStack(alignment: .leading, spacing: cardSpacing) { + ForEach(renderedCards.prefix(12)) { card in + HStack(alignment: .firstTextBaseline, + spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) { + Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card)) + .foregroundStyle(.secondary) + .imageScale(.medium) + Text(card.title.value ?? "Untitled") + .boardFont(.body) + .lineLimit(2) + Spacer(minLength: 0) + } + .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize)) + .fill(BoardSurface.cardPlate) + ) } - .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize)) - .fill(BoardSurface.cardPlate) - ) + Spacer(minLength: 0) } - Spacer(minLength: 0) } + .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)) } - .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)) } .frame(width: replicaWidth, height: replicaHeight, alignment: .topLeading) // The lane's own wash over an opaque base — the replica floats over whatever the cursor is @@ -684,12 +968,45 @@ struct LaneView: View, Equatable { .dragReplicaShadow(zoom: zoom) } + /// The collapsed strip as the replica draws it: the same three pieces in the same column, with the + /// travel a *fixed* figure rather than a measured one. + /// + /// A drag image is a snapshot — no geometry observers — so the live strip's measured travel is not + /// available here, and reusing `collapsedTitleTravel` would let the replica write into the state the + /// resting lane truncates by. The replica's own height less its furniture is the honest figure, and + /// it is the same arithmetic the live strip's layout performs. + private var collapsedReplicaStrip: some View { + VStack(spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) { + Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane)) + .foregroundStyle(.secondary) + .imageScale(.medium) + countBadgeText("\(lane.cards.count)") + // The live strip's own travel, **fixed rather than flexible**: a drag image is a snapshot + // and must not observe its own geometry (it would write into the state the resting lane + // truncates by, from inside a preview). The figure is honest because the replica is drawn at + // the lane's own height, so the leftover room is the same leftover room. + Color.clear + .frame(maxWidth: .infinity) + .frame(height: collapsedTitleTravel) + .overlay { rotatedTitle(travel: collapsedTitleTravel) } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .top) + .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)) + } + /// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet /// (`BoardMetrics`). Named rather than inlined in the frame because the anchoring below has to /// ask the same question the drawing does, and two derivations of one figure would be two /// answers. + /// + /// **A folded lane takes the strip's exact width, floor and all**: the floor exists for a lane that + /// has not measured itself and so has no width to draw at, and the strip's width is a constant + /// nobody has to measure (`BoardMetrics.collapsedLaneWidth`) — applying a 6-em floor to a 3.4-em + /// strip would draw a replica almost twice as wide as the thing it is a replica of. private var replicaWidth: CGFloat { - max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize)) + guard !isCollapsed else { return BoardMetrics.collapsedLaneWidth(bodyPointSize: pointSize) } + return max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize)) } private var replicaHeight: CGFloat { diff --git a/Kanban/UI/Board/NavigationMath.swift b/Kanban/UI/Board/NavigationMath.swift index 2cca128..3651d2e 100644 --- a/Kanban/UI/Board/NavigationMath.swift +++ b/Kanban/UI/Board/NavigationMath.swift @@ -13,7 +13,11 @@ import CoreGraphics /// /// **It is also how the hidden trash stays invisible for free**: a hidden column registers nothing, /// so there is nothing here to filter out — 04's "hidden, it is invisible to every gesture" needs no -/// code of its own. +/// code of its own. **A collapsed lane's cards ride the same mechanism** (03-board-ui.md § Lane ▸ +/// Collapsed lanes: "cards inside are not rendered"): a lane drawn as a slim strip lays out no card +/// faces, so it registers no frames and `nearest` cannot step into one. The `⌥`-jumps are the half +/// that *does* need code, because they name an absolute destination rather than a neighbour — see +/// `firstCard(scanning:filter:)`. /// /// Pure and `CoreGraphics`-only, for `SelectionGrammar`'s reason: the branches become lines of test /// rather than gestures to drive, and the four arrow handlers stay thin over it. @@ -85,6 +89,33 @@ public enum NavigationMath { } return best?.id } + + /// **The first card the board is actually showing**, scanning `lanes` in the order given — the + /// landing every absolute keyboard destination shares: ⌥←/⌥→'s first/last lane, and the seed an + /// arrow from an empty selection takes (04-interactions.md ▸ Grammar). + /// + /// Three ways a lane is scanned past, and they are one rule — *the user cannot see into it*: + /// + /// - it holds no cards; + /// - the search query hid all of them (04 § Search — "a jump that landed nowhere because the end + /// lane happens to be empty would be a dead key", and a lane the query emptied is empty to the + /// eye); + /// - it is **collapsed** (03-board-ui.md § Lane ▸ Collapsed lanes): the slim strip draws no card + /// faces at all, so a jump that landed on one would select something rendered nowhere and leave + /// the arrows with no frame to step from. The strip itself stays selectable **as a lane**, which + /// is the lane domain's business and not this scan's. + /// + /// Pure, and here rather than in the view for `SortMath`'s reason: the three skips are lines of + /// test instead of a board to drive. + public static func firstCard( + scanning lanes: some Sequence, + filter: SearchFilter = .inactive + ) -> ItemID? { + for lane in lanes where !LaneLayoutMath.isCollapsed(lane) { + if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id } + } + return nil + } } // MARK: - Within-lane sort diff --git a/Kanban/UI/Board/NewCardTarget.swift b/Kanban/UI/Board/NewCardTarget.swift index 4e0e167..0f5baee 100644 --- a/Kanban/UI/Board/NewCardTarget.swift +++ b/Kanban/UI/Board/NewCardTarget.swift @@ -53,10 +53,28 @@ enum NewCardTarget { lastActiveLaneID: ItemID?, snapshot: BoardModel ) -> Resolution? { - let lanes = snapshot.lanes + // **Collapsed lanes are not creation targets** (03-board-ui.md § Lane ▸ Collapsed lanes: + // "⌘N new-card targeting skips collapsed lanes"): the placeholder is a pseudo-card drawn in the + // lane's masonry (`LaneSlot.placeholder`), and a folded lane draws none — so a card created + // there would be a focused text field rendered nowhere, with no way to type a title into it and + // no way out but Escape. Skipping is therefore not a preference but the only coherent answer, + // and it is applied to every branch below by narrowing the lane list once. + // + // **A board whose every lane is folded has no target at all**, which is exactly the zero-lane + // board's answer and reaches the same place: `nil` is New Card's `disabled` condition as well + // as its refusal, so the item greys out rather than doing nothing when pressed. + let lanes = snapshot.lanes.filter { !LaneLayoutMath.isCollapsed($0) } guard !lanes.isEmpty else { return nil } - if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) { + // **A selection inside a folded lane falls through rather than refusing** — the stale + // selection's rule verbatim, for its reason: the user pressed ⌘N and the board has lanes it + // can create into. So collapsing a lane with one of its cards selected leaves ⌘N working, at + // the last-active lane below. `flattenAnchor` itself is deliberately untouched: it is + // **shared with paste** ("the same anchor serves paste"), and a paste into a folded lane is + // perfectly coherent — it writes a rank, it renders nothing, and the card is there when the + // lane unfolds. + if let anchor = flattenAnchor(selection: selection, snapshot: snapshot), + lanes.contains(where: { $0.id == anchor.laneID }) { return anchor } // Nothing selected, a trash selection, or a stale one — the ids name nothing the board diff --git a/KanbanTests/BannerCenterTests.swift b/KanbanTests/BannerCenterTests.swift index 62ad8fc..d6e2e3e 100644 --- a/KanbanTests/BannerCenterTests.swift +++ b/KanbanTests/BannerCenterTests.swift @@ -50,13 +50,18 @@ private let everyOperation: [WriteOperation] = [ .purge(title: "Fix login"), .style(title: "Fix login"), .resize(title: "Fix login"), + .collapse(title: "Todo"), + .expand(title: "Todo"), .rename(title: "Fix login"), .importAttachment(filename: "photo.png"), .listAttachments, .renumberChildren, ] -/// The titled cases, and only those: `withTitle(_:)`'s own list of what can carry one. +/// The titled cases whose untitled fallback is the family's kind-free "the item" — the invariant the +/// loop below reads. `.collapse` / `.expand` are deliberately absent: only a lane carries `collapsed`, +/// so their fallback names the kind outright ("Couldn't collapse the lane") and there is no guess for +/// the rule to protect against (see `BannerCenter.actionPhrase(for:)`). private let titledOperations: [(with: WriteOperation, without: WriteOperation)] = [ (.move(title: "Fix login"), .move(title: nil)), (.reorder(title: "Fix login"), .reorder(title: nil)), diff --git a/KanbanTests/BoardZoomTests.swift b/KanbanTests/BoardZoomTests.swift index 90043f9..eaa3ebc 100644 --- a/KanbanTests/BoardZoomTests.swift +++ b/KanbanTests/BoardZoomTests.swift @@ -204,6 +204,8 @@ struct BoardZoomMetricsTests { ("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }), ("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }), ("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }), + ("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }), + ("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(bodyPointSize: $0) }), ("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }), ("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }), ("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }), diff --git a/KanbanTests/ChangeNarratorTests.swift b/KanbanTests/ChangeNarratorTests.swift index a1450cb..d8296d5 100644 --- a/KanbanTests/ChangeNarratorTests.swift +++ b/KanbanTests/ChangeNarratorTests.swift @@ -26,6 +26,13 @@ private func baseBoard(_ fixture: WriterFixture) throws { try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Ship it") } +/// The base board with its first lane already folded — the "before" an Expand needs +/// (03-board-ui.md § Lane ▸ Collapsed lanes). +private func baseBoardWithFoldedTodo(_ fixture: WriterFixture) throws { + try baseBoard(fixture) + try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n") +} + /// A UUID-shaped name `Ident` does not already spend on the base board — the trash suites need one /// more identity than the fixture offers, and reusing a live card's would be a duplicate the loader /// would rightly withhold. @@ -184,6 +191,36 @@ struct CommitMessageSingleEventTests { #expect(message == "Resize lane 'Todo' to 2×") } + /// **The fold has its own two words** (03-board-ui.md § Lane ▸ Collapsed lanes; 06 ▸ Commit + /// messages' vocabulary), told apart by diff shape alone exactly as the trash pair is. + @Test("A folded lane is a Collapse, and an unfolded one an Expand") + func collapsingALane() throws { + let collapsed = try compose { fixture in + try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n") + } + #expect(collapsed == "Collapse lane 'Todo'") + + let expanded = try compose( + board: { fixture in + try baseBoardWithFoldedTodo(fixture) + }, + change: { fixture in + try fixture.lane(Ident.lane1, order: "1024", title: "Todo") + } + ) + #expect(expanded == "Expand lane 'Todo'") + } + + /// The narrator composes off the **reading**, not the key: `collapsed: false` beside an absent key + /// moves no lane on screen, so it is the "sequence, not raw `order`" discipline one field over. + @Test("A `collapsed: false` that changes no lane composes nothing") + func anExplicitFalseIsNotAnEvent() throws { + let message = try compose { fixture in + try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: false\n---\n\n") + } + #expect(message == ChangeNarrator.unnamedSubject) + } + @Test("An attachment composes Attach, named by the card it landed on") func attachingAFile() throws { let message = try compose { fixture in diff --git a/KanbanTests/FrontmatterTests.swift b/KanbanTests/FrontmatterTests.swift index f4ad8ef..db5ac9d 100644 --- a/KanbanTests/FrontmatterTests.swift +++ b/KanbanTests/FrontmatterTests.swift @@ -748,6 +748,52 @@ struct FrontmatterLenientFieldTests { #expect(try document("width: banana").width == .malformed(raw: "banana")) } + /// **`collapsed` reads like `width`, one type over** (03-board-ui.md § Lane ▸ Collapsed lanes; + /// 01-storage-format.md § Frontmatter's lane table): a boolean scalar reads as itself — including + /// YAML 1.1's other spellings, which the parser has already resolved — and a *quoted* one of those + /// words coerces, the numeric-string precedent applied to a boolean field. + @Test func collapsedReadsBooleansAndTheirQuotedSpellings() throws { + #expect(try document("collapsed: true").collapsed == .valid(true)) + #expect(try document("collapsed: false").collapsed == .valid(false)) + // YAML 1.1 resolves these to booleans unquoted, so they arrive here already typed. + #expect(try document("collapsed: yes").collapsed == .valid(true)) + #expect(try document("collapsed: off").collapsed == .valid(false)) + // Quoted, they are strings — and a string that spells a boolean word has a sensible reading. + #expect(try document("collapsed: \"true\"").collapsed == .valid(true)) + #expect(try document("collapsed: \"No\"").collapsed == .valid(false)) + #expect(try document("collapsed: \"ON\"").collapsed == .valid(true)) + } + + /// **Everything else has no boolean reading, and therefore renders as expanded** — the default is + /// the absent key's, so an unreadable value can never fold a lane by accident. + @Test func collapsedIsMalformedForEveryNonBooleanReading() throws { + #expect(try document("schema: 1").collapsed == .missing) + #expect(try document("collapsed: null").collapsed == .missing) + #expect(try document("collapsed: maybe").collapsed == .malformed(raw: "maybe")) + #expect(try document("collapsed: 1").collapsed == .malformed(raw: "1")) + #expect(try document("collapsed: [true]").collapsed == .malformed(raw: "[true]")) + #expect(try document("collapsed: {a: 1}").collapsed == .malformed(raw: "{a: 1}")) + } + + /// A lenient field with no reading files a coerce-tier trace and leaves the bytes exactly as + /// written — the family's posture, `collapsed` included. + @Test func anUnreadableCollapsedFilesATraceAndRoundTrips() throws { + let text = "---\nschema: 1\ncollapsed: maybe\nwidth: 2\n---\nbody\n" + let parsed = try FrontmatterDocument.parse(text) + #expect(parsed.serialized() == text) + #expect(parsed.coercedFields == [CoercedField(key: "collapsed", raw: "maybe")]) + // A readable value is an absence of trace, not a trace of a value. + #expect(try document("collapsed: true").coercedFields.isEmpty) + #expect(try document("collapsed: false").coercedFields.isEmpty) + } + + /// The key is the schema's, so the card window's Details section does not list it beside a user's + /// own overlay keys (`FrontmatterKeys.schemaOwned`). + @Test func collapsedIsSchemaOwnedRatherThanAnUnknownKey() throws { + #expect(FrontmatterKeys.schemaOwned.contains(FrontmatterKeys.collapsed)) + #expect(try document("collapsed: true").unknownFields.isEmpty) + } + @Test func malformedLenientValuesStillRoundTrip() throws { let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 1.5\nicon: {a: 1}\n---\nbody\n" let document = try FrontmatterDocument.parse(text) diff --git a/KanbanTests/KeyboardGrammarTests.swift b/KanbanTests/KeyboardGrammarTests.swift index a5455d8..f4532ef 100644 --- a/KanbanTests/KeyboardGrammarTests.swift +++ b/KanbanTests/KeyboardGrammarTests.swift @@ -183,6 +183,47 @@ struct NavigationMathTests { // From the row itself, ↓ reaches the card below it: navigation crosses back. #expect(NavigationMath.nearest(from: row.frame, direction: .down, among: all) == card2) } + + /// **A collapsed lane is scanned past by the absolute destinations** (03-board-ui.md § Lane ▸ + /// Collapsed lanes: "cards inside are not rendered — they are excluded from … keyboard spatial + /// navigation"), which is what ⌥←/⌥→ and the empty-selection seed land through. + /// + /// The *relative* half — a plain or ⇧ arrow — needs nothing: a folded lane registers no card frame, + /// so `nearest` has no candidate to reject, exactly as the hidden trash has none. + /// `@MainActor` where its neighbours are not, and only because the fixture is: a board on disk is + /// the honest way to ask what a *read-side* fold reads as, and `WriterFixture` is main-actor bound. + @MainActor + @Test("The absolute destinations scan past a folded lane, an empty one, and one the query emptied") + func firstCardSkipsFoldedLanes() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + // Lane 1 folded, lane 2 open: the first card the *board* is showing is lane 2's. + try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n") + let lanes = try load(fixture).lanes + + #expect(NavigationMath.firstCard(scanning: lanes) == card5) + // Reversed — ⌥→'s scan — the last showing lane is lane 2 as well, since lane 3 is empty and + // lane 1 is folded. + #expect(NavigationMath.firstCard(scanning: lanes.reversed()) == card5) + + // The filter narrows the same scan: "Sixth" is in the open lane, "First" is in the folded one + // and stays unreachable however well it matches. + #expect(NavigationMath.firstCard(scanning: lanes, filter: SearchFilter(query: "Sixth")) == card6) + #expect(NavigationMath.firstCard(scanning: lanes, filter: SearchFilter(query: "First")) == nil) + + // Every lane folded: there is nowhere to land at all, which is the same answer an empty board + // gives and leaves the press inert rather than selecting something drawn nowhere. + let allFolded = try WriterFixture() + defer { allFolded.tearDown() } + try allFolded.item("", Item.board) + try allFolded.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n") + try allFolded.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + #expect(NavigationMath.firstCard(scanning: try load(allFolded).lanes) == nil) + + // And unfolding restores it, key removal and all — nothing about the cards changed. + try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\n---\n\n") + #expect(NavigationMath.firstCard(scanning: try load(fixture).lanes) == card1) + } } // MARK: - The registry the arrows and the band read diff --git a/KanbanTests/LaneCollapseWriteTests.swift b/KanbanTests/LaneCollapseWriteTests.swift new file mode 100644 index 0000000..a29b79d --- /dev/null +++ b/KanbanTests/LaneCollapseWriteTests.swift @@ -0,0 +1,377 @@ +import CoreGraphics +import Foundation +import Testing +@testable import Kanban + +/// `BoardStore.setLaneCollapsed` — the one commit point every collapse gesture shares (03-board-ui.md +/// § Lane ▸ Collapsed lanes): the header chevron, the context menu's Collapse/Expand Lane row, and a +/// click on the collapsed strip's body. +/// +/// `LaneWidthWriteTests`' suite, one key over, and deliberately its twin: collapse is document state +/// exactly as width is, so the claims worth making are the same claims — the value that lands, the +/// stamps that follow it, the remove-at-default rule, and everything else surviving byte-for-byte. Real +/// stores over real temp boards, read back as **raw bytes** rather than through the app's own read path. +/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. + +// MARK: - Fixtures + +/// A lane index carrying an explicit `collapsed:` beside a `width:` — half of what is under test is +/// what happens to values that are already there, and the other half is that `width` is not one of them. +private func laneText(order: String, title: String, width: String?, collapsed: String?) -> String { + let widthLine = width.map { "width: \($0)\n" } ?? "" + let collapsedLine = collapsed.map { "collapsed: \($0)\n" } ?? "" + return """ + --- + schema: 1 + title: \(title) + order: \(order) + \(widthLine)\(collapsedLine)project: lanework # agent overlay + created: 2026-01-01T09:00:00Z + modified: 2026-02-02T09:00:00Z + modified-by: claude + --- + \(title) body. + + """ +} + +/// A board with one plain lane, one already folded at 3×, and one whose frontmatter is readable but +/// uneditable. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: "2", collapsed: nil)) + try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3", collapsed: "true")) + try fixture.item(Ident.lane3, Item.uneditable) + return fixture +} + +/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come +/// through a fold byte-for-byte, in order. `width:` is deliberately **not** in the exclusion list: the +/// whole point is that a collapse leaves it alone. +private func untouchedLines(_ text: String) -> [Substring] { + text.split(separator: "\n", omittingEmptySubsequences: false).filter { + // `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one. + !$0.hasPrefix("modified") && !$0.hasPrefix("collapsed:") && !$0.hasPrefix("kind:") + } +} + +private func loadedLane(_ id: String, in fixture: WriterFixture) throws -> Lane { + let model = try BoardLoader.load(boardRoot: fixture.root).model + return try #require(model.lanes.first { $0.id.rawValue == id }) +} + +// MARK: - Tests + +@MainActor +@Suite("BoardStore ▸ lane collapse") +struct LaneCollapseWriteTests { + + @Test("A collapse writes `collapsed: true`, stamps modified, clears modified-by, and touches nothing else") + func collapseWritesTheKeyAndStamps() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexText(Ident.lane1) + + store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true) + + let after = try fixture.indexText(Ident.lane1) + #expect(after.contains("collapsed: true")) + #expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution") + #expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh") + + // Everything the write does not own survives exactly, in order — **`width: 2` included**, which + // is the whole of "expanding restores the lane the user had". + #expect(untouchedLines(after) == untouchedLines(before)) + #expect(after.contains("width: 2")) + + let lane = try loadedLane(Ident.lane1, in: fixture) + #expect(lane.collapsed == .valid(true)) + #expect(lane.width == .valid(2), "the fold does not read or rewrite the width key") + #expect(LaneLayoutMath.isCollapsed(lane)) + #expect(lane.modifiedBy.isMissing) + let modified = try #require(lane.modified.value) + #expect(abs(modified.timeIntervalSinceNow) < 60, "modified is stamped with the time of the write") + + #expect(store.banners.oneShots.isEmpty) + } + + @Test("Expanding removes the key rather than writing `false` — the remove-at-default family") + func expandRemovesTheKey() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false) + + let after = try fixture.indexText(Ident.lane2) + #expect(!after.contains("collapsed"), "a default lane's frontmatter stays clean") + #expect(!after.contains("false")) + let lane = try loadedLane(Ident.lane2, in: fixture) + #expect(lane.collapsed.isMissing) + #expect(!LaneLayoutMath.isCollapsed(lane)) + // And the preserved width is what the lane comes back as. + #expect(lane.width == .valid(3)) + #expect(after.contains("width: 3")) + } + + @Test("A fold survives a width change, and a width change survives a fold") + func theTwoKeysAreIndependent() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // The stepper still edits the preserved key while the lane is folded — 03's "the width stepper + // and ⌥⌘→/⌥⌘← still edit the preserved key, which is what the lane will be when it unfolds". + store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 5) + let afterResize = try loadedLane(Ident.lane2, in: fixture) + #expect(afterResize.width == .valid(5)) + #expect(afterResize.collapsed == .valid(true), "resizing a folded lane does not unfold it") + + let reopened = try BoardStore(rootURL: fixture.root) + reopened.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false) + #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(5)) + } + + @Test("Setting the state a lane already reads writes nothing at all") + func unchangedStateIsANoOp() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let folded = try fixture.indexData(Ident.lane2) + let expanded = try fixture.indexData(Ident.lane1) + + // Neither may stamp `modified`: a second Collapse on a folded lane, and an Expand on a lane + // that has no key to remove. + store.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: true) + #expect(try fixture.indexData(Ident.lane2) == folded) + + store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: false) + #expect(try fixture.indexData(Ident.lane1) == expanded) + #expect(try fixture.entryNames(Ident.lane1) == ["index.md"], "no temp-file residue either") + } + + @Test("A hand-written `collapsed: false` reads as expanded and is preserved until the app edits it") + func handWrittenFalseIsPreserved() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: nil, collapsed: "false")) + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexData(Ident.lane1) + + #expect(!LaneLayoutMath.isCollapsed(try loadedLane(Ident.lane1, in: fixture))) + // Reads as expanded, asked to expand: the unchanged-state guard fires before the + // remove-at-default rule can, so the legal hand edit stays byte-for-byte — `width: 1`'s rule. + store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: false) + #expect(try fixture.indexData(Ident.lane1) == before) + + // A real change replaces it with the one shape the app writes. + store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true) + let after = try fixture.indexText(Ident.lane1) + #expect(after.contains("collapsed: true")) + #expect(!after.contains("collapsed: false")) + } + + @Test("A value with no boolean reading is replaced on collapse and removed on expand") + func aMalformedValueIsOverwritten() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: nil, collapsed: "maybe")) + let store = try BoardStore(rootURL: fixture.root) + + // Lenient on the read side — renders expanded with the bytes left alone — so a collapse is a + // real change and overwrites it. + #expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 }?.collapsed + == .malformed(raw: "maybe")) + store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true) + + let after = try fixture.indexText(Ident.lane1) + #expect(after.contains("collapsed: true")) + #expect(!after.contains("maybe")) + #expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true)) + } + + @Test("A lane that is not in the snapshot is a no-op") + func unknownLaneIsANoOp() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexData(Ident.lane1) + + // The lane vanished under the gesture (or never existed): the reload that removed it is the + // authority, and inventing a file here would be the app disagreeing with disk. + store.setLaneCollapsed(ItemID(rawValue: Ident.indexless), collapsed: true) + + #expect(!fixture.exists(Ident.indexless)) + #expect(try fixture.indexData(Ident.lane1) == before) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("Collapsing a lane whose card is selected selects the lane") + func collapsingReSelectsTheLane() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.board() + try fixture.lane(Ident.lane1, order: "1024", title: "Todo") + try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "First") + try fixture.lane(Ident.lane2, order: "2048", title: "Doing") + let store = try BoardStore(rootURL: fixture.root) + let lane = ItemID(rawValue: Ident.lane1) + let card = ItemID(rawValue: Ident.card1) + + store.select([card], in: .board) + store.setLaneCollapsed(lane, collapsed: true) + + // The card stops being rendered, so a selection naming it would leave the arrows with no frame + // to step from — the lane the user just folded is what they are holding instead. + #expect(store.selection.ids == [lane]) + #expect(store.selection.container == .board) + + // A selection elsewhere is untouched, and so is one of the lane itself. + let other = ItemID(rawValue: Ident.lane2) + store.select([other], in: .board) + store.setLaneCollapsed(lane, collapsed: false) + #expect(store.selection.ids == [other], "expanding never touches the selection") + } + + @Test("Undo puts the fold back, and each direction names itself") + func undoAndRedoRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let history = NativeHistoryProvider() + store.history = history + let lane = ItemID(rawValue: Ident.lane1) + + store.setLaneCollapsed(lane, collapsed: true) + #expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true)) + #expect(history.undoActionName == "Collapse Lane") + + history.undo() + #expect(try loadedLane(Ident.lane1, in: fixture).collapsed.isMissing, + "the inverse removes the key rather than writing `false`") + #expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2), + "and it never touched the width") + + history.redo() + #expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true)) + + // The other direction names itself: an expand's row must not read "Collapse". + let reopened = try BoardStore(rootURL: fixture.root) + let reopenedHistory = NativeHistoryProvider() + reopened.history = reopenedHistory + reopened.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false) + #expect(reopenedHistory.undoActionName == "Expand Lane") + reopenedHistory.undo() + #expect(try loadedLane(Ident.lane2, in: fixture).collapsed == .valid(true)) + } + + @Test("A readable-but-uneditable lane refuses the write, banners it in the gesture's own word, and keeps its bytes") + func uneditableLaneBannersAndChangesNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexData(Ident.lane3) + + store.setLaneCollapsed(ItemID(rawValue: Ident.lane3), collapsed: true) + + #expect(try fixture.indexData(Ident.lane3) == before) + #expect(try fixture.indexText(Ident.lane3) == Item.uneditable) + + // `performWrite` posts before it rethrows and the store swallows the rethrow — the banner is the + // only thing that says the gesture did not happen, and it must say *collapse* rather than + // resize: the user pressed Collapse Lane. + #expect(store.banners.oneShots.count == 1) + let posted = try #require(store.banners.oneShots.first) + #expect(posted.error.operation == .collapse(title: "Odd"), + "the title is enriched off the document the write refused") + #expect(posted.error.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) + #expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't collapse 'Odd' — ")) + } + + /// **A drop on the strip appends at the lane's end** (03-board-ui.md § Lane ▸ Collapsed lanes: + /// "dropping a drag onto the collapsed strip appends the payload at the lane's END, like an + /// end-of-lane drop"). + /// + /// It is asserted with **no window at all**, which is the point rather than a convenience: a folded + /// lane draws no cards, so there is no row geometry a position could be resolved against and nothing + /// about the pointer can change the answer — the branch is taken before the cursor is ever read. + @Test("A card drag over a folded lane proposes the end of its card list, cursor notwithstanding") + func dropOnTheStripAppendsAtTheEnd() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.board() + try fixture.lane(Ident.lane1, order: "1024", title: "Todo") + try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "First") + try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Second") + try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third") + // The destination: folded, holding two cards of its own. + try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Doing\norder: 2048\ncollapsed: true\n---\n\n") + try fixture.card(Ident.card4, in: Ident.lane2, order: "1024", title: "Fourth") + try fixture.card(Ident.indexless, in: Ident.lane2, order: "2048", title: "Fifth") + + let store = try BoardStore(rootURL: fixture.root) + let session = DragSession() + let registry = LaneDropRegistry() + let drops = BoardDropContext( + store: store, + session: session, + registry: registry, + gap: 12, + window: { nil }, + stripFrame: { .zero }, + standard: { 260 } + ) + let dragged = ItemID(rawValue: Ident.card1) + session.beginCards( + [dragged], + folders: [store.rootURL + .appendingPathComponent(Ident.lane1, isDirectory: true) + .appendingPathComponent(Ident.card1, isDirectory: true)], + heights: [44], + container: .board, + source: store + ) + + drops.retargetCards(inLane: ItemID(rawValue: Ident.lane2)) + let proposal = try #require(session.proposal) + #expect(proposal.container == .lane(ItemID(rawValue: Ident.lane2))) + #expect(proposal.index == 2, "the end of the folded lane's two cards") + + // Into the lane the run came *from*, the index is counted in the resting layout — the dragged + // card lifted out — which is the space every proposal in this app is counted in. + drops.retargetCards(inLane: ItemID(rawValue: Ident.lane1)) + #expect(session.proposal?.index == 2, "three cards minus the one in flight") + + // Expanded, the lane goes back to needing geometry: with no window and no registered grid there + // is nothing to resolve, and the standing proposal simply holds (the hysteresis contract). + try fixture.lane(Ident.lane2, order: "2048", title: "Doing") + let reopened = try BoardStore(rootURL: fixture.root) + let reopenedDrops = BoardDropContext( + store: reopened, session: session, registry: registry, gap: 12, + window: { nil }, stripFrame: { .zero }, standard: { 260 } + ) + session.propose(nil) + reopenedDrops.retargetCards(inLane: ItemID(rawValue: Ident.lane2)) + #expect(session.proposal == nil, "an expanded lane answers from its grid, and there is none here") + } + + @Test("A read-only board refuses the write without a second banner") + func readOnlyBoardRefusesQuietly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + let before = try fixture.indexData(Ident.lane1) + + store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true) + + // The lock row is already standing; a refusal per gesture would bury it under echoes of itself. + #expect(try fixture.indexData(Ident.lane1) == before) + #expect(store.banners.oneShots.isEmpty) + #expect(store.bannerRows.contains { $0.id == "read-only-lock" }) + } + +} diff --git a/KanbanTests/LaneLayoutMathTests.swift b/KanbanTests/LaneLayoutMathTests.swift index 9354216..69e4d8c 100644 --- a/KanbanTests/LaneLayoutMathTests.swift +++ b/KanbanTests/LaneLayoutMathTests.swift @@ -49,7 +49,10 @@ private func snapped(_ liveWidth: CGFloat, current: Int, range: ClosedRange /// leniency is a *read-side* rule (01-storage-format.md § Frontmatter), so the only honest way to /// ask "what does a malformed width display as" is to put the malformed bytes on disk and load /// them. -private func lanes(widths: [String?]) throws -> [Lane] { +/// `collapsed` is the parallel list of `collapsed:` values (`nil` writes no key), padded with `nil` +/// when it is shorter than `widths` — a board with no folded lane passes none at all and reads exactly +/// as it did before the key existed. +private func lanes(widths: [String?], collapsed: [String?] = []) throws -> [Lane] { let root = FileManager.default.temporaryDirectory .appendingPathComponent("LaneLayoutMathTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) @@ -61,6 +64,9 @@ private func lanes(widths: [String?]) throws -> [Lane] { if let width { frontmatter += "width: \(width)\n" } + if index < collapsed.count, let value = collapsed[index] { + frontmatter += "collapsed: \(value)\n" + } frontmatter += "---\n" let folder = root.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) @@ -545,3 +551,152 @@ struct LaneRedivideTests { #expect(LaneLayoutMath.resizeWindowDelta(from: 4, to: 4, fittingUnits: fit, step: step) == 0) } } + +// MARK: - Collapsed lanes + +/// **The fold's arithmetic** (03-board-ui.md § Lane ▸ Collapsed lanes, settled 2026-08-08): a collapsed +/// lane takes a fixed slim strip and is *not* part of the width re-division — its width comes off the +/// top and what is left divides among the expanded lanes alone. +/// +/// The exact-fill identity is the property worth pinning rather than any one number, because it is what +/// "every lane is always on screen" means arithmetically once two kinds of lane share the strip: +/// `C·collapsedWidth + T·standard + (T + C + 1)·gap` is the whole strip, always. +@Suite("LaneLayoutMath ▸ collapsed lanes") +struct LaneCollapseLayoutTests { + + /// A slim strip in this suite. Deliberately not `BoardMetrics`' figure: the math takes it as a + /// parameter, and a test that read the metric would pin the metric rather than the arithmetic. + private let strip: CGFloat = 44 + + @Test("The reading is lenient: only a real `true` folds a lane") + func onlyTrueFolds() throws { + #expect(LaneLayoutMath.isCollapsed(try lane(width: nil)) == false) + let folded = try lanes(widths: [nil], collapsed: ["true"]) + #expect(LaneLayoutMath.isCollapsed(try #require(folded.first))) + + // An explicit `false`, a quoted word, and a value with no boolean reading at all — three + // shapes, two readings, and the malformed one is expanded because the default is the absent + // key's. + for (raw, expected) in [("false", false), ("\"yes\"", true), ("maybe", false), ("3", false)] { + let loaded = try lanes(widths: [nil], collapsed: [raw]) + #expect(LaneLayoutMath.isCollapsed(try #require(loaded.first)) == expected, + "collapsed: \(raw) should read as \(expected ? "folded" : "expanded")") + } + } + + @Test("Collapsed lanes are outside the unit total, so folding one is a re-divide trigger") + func collapsedLanesLeaveTheUnitTotal() throws { + let loaded = try lanes(widths: ["2", "3", nil], collapsed: [nil, "true", nil]) + // The folded lane keeps its `width` — that is how expanding restores the lane the user had — + // and still answers `displayUnits`; it simply contributes none of them to the division. + #expect(loaded.map(LaneLayoutMath.displayUnits(of:)) == [2, 3, 1]) + #expect(LaneLayoutMath.collapsedCount(of: loaded) == 1) + #expect(LaneLayoutMath.totalUnits(of: loaded) == 3) + #expect(LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1) == 4) + } + + @Test("The strip still fills exactly with both kinds of lane in it") + func mixedStripsFillExactly() throws { + let loaded = try lanes(widths: ["2", "3", nil], collapsed: [nil, "true", nil]) + let total = LaneLayoutMath.totalUnits(of: loaded) + let folded = LaneLayoutMath.collapsedCount(of: loaded) + let standard = LaneLayoutMath.standardWidth( + stripWidth: 1000, totalUnits: total, gap: gap, + collapsedCount: folded, collapsedWidth: strip) + + // 1000 = 3 standards + 1 strip + 5 gaps (4 slots, so 5 gaps counting both margins). + #expect(abs(standard - (1000 - strip - gap * 5) / 3) < 0.0001) + let widths = LaneLayoutMath.drawnWidths(of: loaded, standard: standard, gap: gap, collapsedWidth: strip) + #expect(abs(widths.reduce(0, +) + gap * CGFloat(widths.count + 1) - 1000) < 0.0001) + // The expanded lanes are their slots; the folded one is the constant, whatever its `width`. + #expect(abs(widths[0] - LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap)) < 0.0001) + #expect(widths[1] == strip) + #expect(abs(widths[2] - standard) < 0.0001) + } + + @Test("Folding a lane widens its siblings without touching the window") + func foldingRedividesTheRemainder() throws { + let expanded = try lanes(widths: [nil, nil, nil]) + let folded = try lanes(widths: [nil, nil, nil], collapsed: [nil, nil, "true"]) + + let before = LaneLayoutMath.standardWidth( + stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: expanded), gap: gap, + collapsedCount: 0, collapsedWidth: strip) + let after = LaneLayoutMath.standardWidth( + stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: folded), gap: gap, + collapsedCount: LaneLayoutMath.collapsedCount(of: folded), collapsedWidth: strip) + #expect(after > before, "the two survivors grow into what the third gave up") + } + + @Test("A board with every lane folded divides nothing and still answers a positive width") + func allCollapsedIsTheDegenerateEdge() throws { + let loaded = try lanes(widths: ["2", "3"], collapsed: ["true", "true"]) + #expect(LaneLayoutMath.collapsedCount(of: loaded) == 2) + // No expanded unit exists, so the total is the divisor guard rather than a description of + // anything on screen — and nothing draws with the standard it produces. + #expect(LaneLayoutMath.totalUnits(of: loaded) == 1) + let standard = LaneLayoutMath.standardWidth( + stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: loaded), gap: gap, + collapsedCount: 2, collapsedWidth: strip) + #expect(standard > 0) + let widths = LaneLayoutMath.drawnWidths(of: loaded, standard: standard, gap: gap, collapsedWidth: strip) + #expect(widths == [strip, strip]) + + // A shown trash column *is* a real unit in that total, and correctly takes the remainder. + let withTrash = LaneLayoutMath.standardWidth( + stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1), gap: gap, + collapsedCount: 2, collapsedWidth: strip) + #expect(abs(withTrash - (1000 - 2 * strip - gap * 4)) < 0.0001) + + // The pathological strip stays positive rather than negative, `standardWidth`'s 1pt floor. + #expect(LaneLayoutMath.standardWidth( + stripWidth: 10, totalUnits: 1, gap: gap, collapsedCount: 4, collapsedWidth: strip) == 1) + } + + @Test("A strip with no folded lane reads exactly as it did before the key existed") + func theDefaultsAreInert() { + #expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: gap) + == LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: gap, + collapsedCount: 0, collapsedWidth: strip)) + } + + @Test("Hit testing walks the drawn widths, so a folded lane's zone is where it is drawn") + func hitTestingFollowsTheDrawnWidths() throws { + let loaded = try lanes(widths: [nil, nil, nil], collapsed: [nil, "true", nil]) + let localStandard: CGFloat = 100 + let widths = LaneLayoutMath.drawnWidths( + of: loaded, standard: localStandard, gap: gap, collapsedWidth: strip) + #expect(widths == [100, strip, 100]) + + // Lane 0 spans [12, 112), the strip [124, 168), lane 2 [180, 280) — gaps answer nil. + #expect(LaneLayoutMath.laneIndex(atX: 50, widths: widths, gap: gap) == 0) + #expect(LaneLayoutMath.laneIndex(atX: 118, widths: widths, gap: gap) == nil) + #expect(LaneLayoutMath.laneIndex(atX: 130, widths: widths, gap: gap) == 1) + #expect(LaneLayoutMath.laneIndex(atX: 167, widths: widths, gap: gap) == 1) + #expect(LaneLayoutMath.laneIndex(atX: 200, widths: widths, gap: gap) == 2) + #expect(LaneLayoutMath.laneIndex(atX: 400, widths: widths, gap: gap) == nil) + + // The unit-count entry point is the same walk, so a board with no folded lane cannot answer + // differently from one with. + #expect(LaneLayoutMath.laneIndex(atX: 130, unitCounts: [1, 1, 1], standard: localStandard, gap: gap) + == LaneLayoutMath.laneIndex(atX: 130, widths: [100, 100, 100], gap: gap)) + } + + @Test("A dragged run's span is its drawn footprint, folded members included") + func draggedRunsMeasureWhatTheyWillDraw() { + let localStandard: CGFloat = 100 + // Two dragged lanes, the second folded: 100 + gap + 44 rather than 100 + gap + 100. + let widths = LaneLayoutMath.drawnWidths( + units: [1, 3], collapsed: [false, true], + standard: localStandard, gap: gap, collapsedWidth: strip) + #expect(widths == [100, strip]) + #expect(DropSlotMath.laneRunSpan(widths: widths, gap: gap) == 100 + gap + strip) + + // A shorter `collapsed` list reads as expanded past its end — what a caller with no fold to + // report passes (the trashed-lane restore). + #expect(LaneLayoutMath.drawnWidths( + units: [1, 2], collapsed: [], + standard: localStandard, gap: gap, collapsedWidth: strip) + == [100, LaneLayoutMath.slotWidth(units: 2, standard: localStandard, gap: gap)]) + } +} diff --git a/KanbanTests/NewCardTargetTests.swift b/KanbanTests/NewCardTargetTests.swift index 53f8600..08c36b9 100644 --- a/KanbanTests/NewCardTargetTests.swift +++ b/KanbanTests/NewCardTargetTests.swift @@ -190,4 +190,46 @@ struct NewCardTargetTests { #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], container: .board)) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) } + + /// **⌘N skips collapsed lanes** (03-board-ui.md § Lane ▸ Collapsed lanes): the placeholder is a + /// pseudo-card drawn in the lane's masonry, and a folded lane draws none — so a creation there would + /// be a focused text field rendered nowhere. + @Test("A folded lane is never a creation target, in any branch") + func foldedLanesAreSkipped() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n") + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // The default fall-through skips it: the first *open* lane is the target. + #expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) + // And so does the last-active lane, which may well be the lane the user just folded. + #expect(resolve(snapshot, lastActive: lane1) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) + + // A selection inside it **falls through** rather than refusing — the stale selection's rule, + // for its reason: the user pressed ⌘N and the board has lanes it can create into. + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board)) + == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1], container: .board)) + == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) + + // A selection in the open lane is untouched by any of it. + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], container: .board)) + == NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3)) + } + + @Test("A board whose every lane is folded has no target at all — the zero-lane answer") + func everyLaneFoldedRefuses() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n") + try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Doing\norder: 2048\ncollapsed: true\n---\n\n") + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // `nil` is New Card's `disabled` condition as well as its refusal, so the item greys out + // rather than doing nothing when pressed. + #expect(resolve(snapshot) == nil) + #expect(resolve(snapshot, lastActive: lane1) == nil) + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board)) == nil) + } } diff --git a/KanbanTests/VisualAccommodationsTests.swift b/KanbanTests/VisualAccommodationsTests.swift index 869d878..d8d07b3 100644 --- a/KanbanTests/VisualAccommodationsTests.swift +++ b/KanbanTests/VisualAccommodationsTests.swift @@ -40,6 +40,11 @@ struct BoardMetricsSettledFiguresTests { #expect(BoardMetrics.laneHeaderSpacing(bodyPointSize: size) == 6) #expect(BoardMetrics.laneAccentBandHeight(bodyPointSize: size) == 5) #expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) == 22) + // The fold's two figures (03-board-ui.md § Lane ▸ Collapsed lanes): the strip is the ruling's + // ~44pt, and the chevron's reserve is 18 — together the header's 40pt trailing budget. + #expect(BoardMetrics.collapsedLaneWidth(bodyPointSize: size) == 44) + #expect(BoardMetrics.laneCollapseButtonReserve(bodyPointSize: size) == 18) + #expect(BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size) == 40) #expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8) #expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4) #expect(BoardMetrics.cardContentPadding(bodyPointSize: size) == 10) @@ -91,6 +96,9 @@ struct BoardMetricsScalingTests { ("laneHeaderInset", { BoardMetrics.laneHeaderInset(bodyPointSize: $0) }), ("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }), ("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }), + ("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }), + ("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(bodyPointSize: $0) }), + ("laneHeaderTrailingReserve", { BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: $0) }), ("badgeHorizontalPadding", { BoardMetrics.badgeHorizontalPadding(bodyPointSize: $0) }), ("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }), ("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),