import SwiftUI /// One lane: a header and a vertically scrolling masonry of cards (03-board-ui.md § Lane). /// /// **The chrome here is deliberately minimal**, and the design's real lane is a later card: the /// leading SF Symbol, the new-card button, the drag surface, the context menu (Rename, Style…, the /// quick-style recents row, the Width stepper, Delete), the colour accent band, inline rename and /// the search-aware count all arrive with the lane-chrome milestone. What this view owes the /// full-visibility layout card is the shape — a header, a body that scrolls vertically only, and a /// masonry whose interior column count is the lane's width — and that is all it does. struct LaneView: View { let lane: Lane /// Interior masonry columns — the lane's width units, or the resize session's snapped count /// while this lane is being dragged. Passed in rather than read off `lane` so the live drag can /// override it (see `BoardView.laneSlot`). let columns: Int /// Spacing between cards, and between the interior columns. private let cardSpacing: CGFloat = 8 var body: some View { VStack(alignment: .leading, spacing: 8) { header ScrollView(.vertical) { // Cards stay standard width whatever the lane spans: at a slot width of // `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly // `units` columns of `standard` (03-board-ui.md § Layout — full visibility). MasonryLayout(columns: columns, spacing: cardSpacing) { ForEach(liveCards) { card in CardStubView(card: card) } } .frame(maxWidth: .infinity, alignment: .topLeading) } } } /// The header — title, or a quiet "Untitled" where there is none, plus the live card count. /// /// Titles are optional at every level (03-board-ui.md § Card face): a missing `title` renders as /// a secondary-styled placeholder rather than as an empty row. **Replaced wholesale by the /// lane-chrome card**, which brings the icon, the count badge's real styling, the new-card /// button, the drag surface and the context menu. private var header: some View { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(lane.title.value ?? "Untitled") .font(.headline) .foregroundStyle(lane.title.value == nil ? .secondary : .primary) .lineLimit(1) .truncationMode(.tail) Text("\(liveCards.count)") .font(.callout) .foregroundStyle(.secondary) .monospacedDigit() Spacer(minLength: 0) } .padding(.horizontal, 4) } /// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane — the /// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective /// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a /// tombstoned lane at all. private var liveCards: [Card] { lane.cards.filter { !$0.isDeleted } } } // MARK: - Card stub /// A card, as a rounded plate with its title — **a stand-in, replaced by the card-face card**, which /// brings the leading icon, the attachment chip, the selection and cut treatments, inline rename and /// the sole-selected card's attachment carousel (03-board-ui.md § Card face). /// /// It takes whatever width `MasonryLayout` proposes (one interior column = one standard width) and /// sizes its own height to its content, which is what makes the masonry masonry: a taller card only /// pushes the cards below it in its own column. private struct CardStubView: View { let card: Card var body: some View { Text(card.title.value ?? "Untitled") .font(.body) .foregroundStyle(card.title.value == nil ? .secondary : .primary) .lineLimit(4) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) .background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary)) } }