Collapsible lanes — frontmatter-backed slim strips outside the width division

A lane folds to a fixed slim vertical strip carrying its glyph, its card-count
badge and its title turned on its side, and the strip is deliberately not part
of the window's division: the expanded lanes' units divide what is left once
each folded strip's fixed width has come off the top, so folding a lane is a
re-divide trigger of the Show/Hide Trash family — the window never moves and
the siblings grow into what the lane gave up.

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

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

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

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

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 22:53:10 -04:00
parent 57542177c1
commit bab456c08d
33 changed files with 1811 additions and 133 deletions
+6 -1
View File
@@ -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)),
+2
View File
@@ -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) }),
+37
View File
@@ -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
+46
View File
@@ -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)
+41
View File
@@ -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
+377
View File
@@ -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" })
}
}
+156 -1
View File
@@ -49,7 +49,10 @@ private func snapped(_ liveWidth: CGFloat, current: Int, range: ClosedRange<Int>
/// 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)])
}
}
+42
View File
@@ -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)
}
}
@@ -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) }),