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
+115 -49
View File
@@ -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<Lane>,
filter: SearchFilter = .inactive
) -> ItemID? {
for lane in lanes {
if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
}
return nil
}
}