Remove the face carousel — one card presentation

03's resettlement reverses the pathfinder carry-over: the
selection-keyed dual presentation proved undesirable, so a card has one
presentation — selection changes styling, never geometry, and the
masonry never reflows on click. Deleted the carousel view (page dots,
glass underlay, scroll-tick monitor), the QuickLook thumbnail cache
(sole consumer), the pure paging/suppression rules, and the
sole-selected animation key — Motion now keys transactions on the
search query and the drop proposal only. The attachment chip stays as
the face's whole attachment story; viewing media is the card window's
job. No carousel state had leaked beyond the view layer.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 08:11:59 -04:00
parent 3f4125e324
commit 1020d9fca4
14 changed files with 47 additions and 1143 deletions
+6 -7
View File
@@ -26,11 +26,10 @@ import os
///
/// **The one read inside a card folder** is `attachmentNames(in:)`: a single flat listing of
/// `attachments/`, feeding `Card.attachments`. It is a *names* read and nothing more it never
/// opens a file, never descends, never warns, and degrades to `[]` on any failure. Two board-
/// window surfaces need it before a card window exists (the face's paperclip indicator and the
/// sole-selected card's carousel 03-board-ui.md § Card face), and the snapshot is where they
/// read from. Everything else about a card folder's contents remains outside this loader's
/// business.
/// opens a file, never descends, never warns, and degrades to `[]` on any failure. The board
/// window's face needs it before a card window exists (the quiet paperclip indicator
/// 03-board-ui.md § Card face), and the snapshot is where it reads from. Everything else about a
/// card folder's contents remains outside this loader's business.
///
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
/// followed, whether it points to a file or a directory this loader does not resolve
@@ -195,8 +194,8 @@ public enum BoardLoader: Sendable {
/// stance the level walk takes).
///
/// **Finder order** (`localizedStandardCompare`), so `"shot 2.png"` sorts before
/// `"shot 10.png"`: the order has to be stable across loads for the face carousel's pages and
/// its dots, and where it is already the sidebar's order it may as well be the same one.
/// `"shot 10.png"`: the order has to be stable across loads, and where it is already the
/// sidebar's order it may as well be the same one.
///
/// Failure is silent: an unlistable directory yields `[]`. Fail-fast is reserved for
/// structure (01-storage-format.md § Malformed input), and this field decorates a card a
+6 -6
View File
@@ -180,12 +180,12 @@ public struct Card: Identifiable, Sendable, Equatable {
/// empty when it has one that couldn't be listed the field is cosmetic, so a
/// directory-listing race degrades to "nothing to show" rather than failing a load.
///
/// Names, not URLs: the two board-window consumers only need to know *whether*, *how many*,
/// and in *what order* the face's quiet paperclip indicator (03-board-ui.md § Card face)
/// and the sole-selected card's attachment carousel. The card window's sidebar does its own
/// listing through `BoardWriter.listAttachments`, since it acts on the files rather than
/// showing them; that call answers through the very same enumeration
/// (`BoardLoader.attachmentNames(in:)`), so the two surfaces agree by construction.
/// Names, not URLs: the board-window consumer only needs to know *whether*, *how many*, and
/// in *what order* the face's quiet paperclip indicator (03-board-ui.md § Card face). The
/// card window's sidebar does its own listing through `BoardWriter.listAttachments`, since it
/// acts on the files rather than showing them; that call answers through the very same
/// enumeration (`BoardLoader.attachmentNames(in:)`), so the two surfaces agree by
/// construction.
///
/// Freshness is the watcher's, by construction: it reloads on any change anywhere under the
/// board root, so an attachment added in Finder rebuilds the snapshot exactly like an edited
+2 -2
View File
@@ -1125,8 +1125,8 @@ public enum BoardWriter: Sendable {
/// disturbs anything inside it, subfolders included.
///
/// The listing itself is `BoardLoader.attachmentNames(in:)`, which is also what fills
/// `Card.attachments` for the board window's face indicator and carousel. **One function, so
/// the sidebar and the face can never disagree** about a card's attachments or their order.
/// `Card.attachments` for the board window's face indicator. **One function, so the sidebar
/// and the face can never disagree** about a card's attachments or their order.
/// What this call adds over that one is the card-folder guard and this API's error
/// vocabulary the difference between a surface that *acts* on the files and two that
/// merely show them.
@@ -1,293 +0,0 @@
import AppKit
import SwiftUI
// MARK: - AttachmentCarousel
/// The sole-selected card's paged attachment carousel, drawn inside the card face's own plate
/// (03-board-ui.md § Card face):
///
/// > QuickLook thumbnails for anything previewable, Finder icon fallback, page dots on a glass
/// > underlay, paged by trackpad pan / dot click / scroll wheel.
///
/// *Which* card draws it, and when, is `CardCarousel`'s this view is handed a page list and knows
/// nothing about selection. That split is what keeps the rule testable and the view free of it.
///
/// ### Display-only, this milestone
///
/// Nothing here opens a file, reveals it, or drags one out. Those belong to the card window's
/// attachment section (05-card-window.md; m6-card-window below), which is also where
/// 10-accessibility.md puts the *accessible* attachment surface "the sole-selection **attachment
/// carousel** is decorative too page dots and paging included, nothing focusable", the count
/// already riding on the face's own element. Hence the whole thing is hidden from the accessibility
/// tree, and every gesture it does not itself use falls through to the face beneath: a click still
/// selects, a double click still opens the card window, a right click still raises the card's
/// context menu.
///
// m6-card-window: opening, revealing and dragging an attachment out are the card window's sidebar
// section, whose rows carry their own context menu (11-command-nexus.md Context menus). When that
// lands, this view stays display-only two surfaces, one of them keyboard-native, is the design's
// own division of labour, not a gap here.
struct AttachmentCarousel: View {
let pages: [CardCarousel.Page]
/// 10-accessibility.md Reduce Transparency: "glass underlays (carousel page dots) go solid".
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
/// Reduce Motion, for the page slide a dot click and a wheel tick perform (10-accessibility.md).
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// The page currently under the viewport, as the scroll view reports it and as the dots and the
/// wheel set it. `nil` until the first report, which is the first page.
@State private var page: String?
/// The carousel's drawn width, which is every page's width paging is only exact when a page is
/// the viewport. Measured rather than derived: the face's content width is a masonry column
/// minus the plate's padding, and re-deriving that here would be a second answer.
@State private var width: CGFloat = 0
/// One wheel monitor's worth of storage see `ScrollWheelPaging`.
@State private var wheel = WheelMonitorBox()
/// The page area's height. Fixed rather than per-image, because a carousel whose height followed
/// its content would resize the card on every page and scaled with the text metric, since
/// 10-accessibility.md commits the whole face to "relative text styles everywhere layout
/// survives the largest system text sizes".
@ScaledMetric(relativeTo: .body) private var pageHeight: CGFloat = 116
private let cornerRadius: CGFloat = 6
var body: some View {
pageStrip
.frame(height: pageHeight)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.overlay(alignment: .bottom) { pageDots }
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 }
.modifier(ScrollWheelPaging(box: wheel) { step(by: $0) })
// Decorative, whole and entire (10-accessibility.md): the card is "one flattened
// element" whose value already carries the attachment count, and nothing in here is a
// second place to land.
.accessibilityHidden(true)
}
// MARK: - The pages
/// Horizontal paged scrolling the stock behaviour, which is what a trackpad pan rides.
///
/// `LazyHStack` so a card with two dozen attachments generates two dozen thumbnails only if the
/// user actually pages to them; `scrollTargetLayout` + `.paging` so a pan settles on a page
/// rather than between two.
private var pageStrip: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(pages) { page in
AttachmentPage(page: page, width: width, height: pageHeight)
.frame(width: width, height: pageHeight)
.id(page.id)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.paging)
.scrollIndicators(.never)
.scrollPosition(id: $page)
}
// MARK: - The page dots
/// The dots, on their glass underlay 03-board-ui.md § Card face names both.
///
/// **A single page shows none.** A dot row that can only ever read "1 of 1" is furniture over a
/// picture; the row exists to say how many there are and where you are, and with one attachment
/// there is nothing to say.
@ViewBuilder
private var pageDots: some View {
if pages.count > 1 {
underlaid {
HStack(spacing: 6) {
ForEach(pages) { page in
dot(for: page)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
}
.padding(.bottom, 6)
}
}
/// One dot, with a hit area much larger than itself: the dot is a 5-point mark and a 5-point
/// click target would be a dare.
private func dot(for target: CardCarousel.Page) -> some View {
Circle()
.fill(target.id == currentID ? AnyShapeStyle(.primary) : AnyShapeStyle(.tertiary))
.frame(width: 5, height: 5)
.frame(width: 14, height: 14)
.contentShape(Rectangle())
.onTapGesture {
// The structural voice a page arriving is a positional change, and 03 § Motion
// gives every one of those the snappy spring.
withAnimation(Motion.structural(reduced: reduceMotion)) { page = target.id }
}
}
/// The dots' backing: macOS 26 glass, or a solid capsule under Reduce Transparency
/// (10-accessibility.md Reduce Transparency, verbatim: "glass underlays (carousel page dots)
/// go solid"). Solid rather than merely opaque-ish the accommodation asks for no blur at all,
/// and a translucent fallback would be the same request half-honoured.
@ViewBuilder
private func underlaid<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
if reduceTransparency {
content().background(Capsule().fill(.background))
} else {
content().glassEffect(.regular, in: .capsule)
}
}
// MARK: - Paging
/// The page the dots highlight: what the scroll view last reported, or the first page before it
/// has reported anything.
private var currentID: String? {
page ?? pages.first?.id
}
/// Moves `step` pages from wherever the carousel is the scroll wheel's landing, clamped by
/// `CardCarousel.page(from:step:count:)` rather than wrapped.
private func step(by step: Int) {
guard let currentID, let index = pages.firstIndex(where: { $0.id == currentID }) else { return }
let target = CardCarousel.page(from: index, step: step, count: pages.count)
guard target != index else { return }
withAnimation(Motion.structural(reduced: reduceMotion)) { page = pages[target].id }
}
}
// MARK: - One page
/// One attachment: its QuickLook thumbnail once there is one, its Finder icon until then and
/// instead of one for anything QuickLook declines (03-board-ui.md § Card face).
///
/// The two states are deliberately not a crossfade or a spinner. A thumbnail lands in a few
/// milliseconds from QuickLook's own cache for anything the user has seen in Finder, and a page that
/// animated every arrival would flicker down a lane the arrow keys are walking.
private struct AttachmentPage: View {
let page: CardCarousel.Page
let width: CGFloat
let height: CGFloat
/// The board window's thumbnail memory, read through the environment rather than threaded down
/// the strip: nothing between `BoardView` and here has any use for it, and a parameter passed
/// through four views to reach a leaf is four places to keep in step.
@Environment(AttachmentThumbnailCache.self) private var thumbnails
/// The screen's backing scale what the thumbnail is requested at, and what it is drawn at, so
/// a Retina page is a Retina image rather than a doubled one.
@Environment(\.displayScale) private var displayScale
var body: some View {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background.tertiary)
// Keyed on the slot, so a card that changes width regenerates and a reselection of the
// same card at the same width does not the cache's whole purpose, expressed as the
// task's identity rather than as a guard inside it.
.task(id: slot) {
guard width > 0 else { return }
await thumbnails.load(slot, url: page.url, height: height, scale: displayScale)
}
}
@ViewBuilder
private var content: some View {
if let image = thumbnails.thumbnail(for: slot) {
Image(decorative: image, scale: displayScale)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
// The icon is drawn at icon size rather than stretched to the page: an upscaled 32-point
// icon is a blurry rectangle, and the fallback should read as "a file" rather than as a
// failed picture.
Image(nsImage: thumbnails.icon(forFileAt: page.url))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 44, height: 44)
}
}
private var slot: AttachmentThumbnailKey.Slot {
AttachmentThumbnailKey.Slot(path: page.url.path, width: width)
}
}
// MARK: - The scroll wheel
/// Storage for the one local event monitor `ScrollWheelPaging` installs a `@MainActor` class so
/// it is `Sendable`, and a class so `@State` can hold a handle SwiftUI must not copy.
@MainActor
private final class WheelMonitorBox {
var monitor: Any?
}
/// Pages the carousel on a **discrete scroll-wheel tick** the third of 03-board-ui.md § Card
/// face's three paging inputs, and the only one the stock paging behaviour does not already serve
/// (see `CardCarousel.wheelStep(deltaX:deltaY:)`).
///
/// ### Why an `NSEvent` monitor
///
/// SwiftUI has no scroll-wheel modifier, and the view that *would* receive the event is the paging
/// scroll view itself anything layered over it to intercept would also swallow the pan this is
/// meant to leave alone. A local monitor sees the event before the scroll view does and can decline
/// it, which is exactly the shape the problem has.
///
/// ### What it declines, which is nearly everything
///
/// - **It exists only while the pointer is over this carousel.** At most one card on a board is the
/// sole selection, so there is at most one of these in the app, and only while hovered.
/// - **Trackpad and Magic Mouse pans fall straight through**, gated on
/// `hasPreciseScrollingDeltas` and on the event carrying no phase: a pan is a continuous gesture
/// the paging behaviour already handles correctly, and taking it away would replace a smooth drag
/// with a jump.
/// - **A tick that resolves to no direction falls through too**, rather than being eaten for
/// nothing.
private struct ScrollWheelPaging: ViewModifier {
let box: WheelMonitorBox
let step: (Int) -> Void
func body(content: Content) -> some View {
content
.onHover { hovering in
if hovering { install() } else { remove() }
}
// The carousel collapses the moment its card stops being the sole selection, and a
// collapse while hovered would otherwise leave the monitor behind.
.onDisappear(perform: remove)
}
private func install() {
guard box.monitor == nil else { return }
box.monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
guard !event.hasPreciseScrollingDeltas,
event.phase.isEmpty,
event.momentumPhase.isEmpty
else { return event }
let direction = CardCarousel.wheelStep(
deltaX: event.scrollingDeltaX,
deltaY: event.scrollingDeltaY
)
guard direction != 0 else { return event }
// A local monitor is delivered on the main thread by contract; the event itself never
// crosses the hop, only the direction it resolved to.
MainActor.assumeIsolated { step(direction) }
return nil
}
}
private func remove() {
guard let monitor = box.monitor else { return }
NSEvent.removeMonitor(monitor)
box.monitor = nil
}
}
-181
View File
@@ -1,181 +0,0 @@
import AppKit
import Observation
import QuickLookThumbnailing
import SwiftUI
// MARK: - AttachmentThumbnailKey
/// What a generated thumbnail is filed under: **which file, at which drawn size, as of which
/// bytes** (03-board-ui.md § Card face "QuickLook thumbnails for anything previewable").
///
/// The third component is what makes the cache honest rather than merely fast. A board is a live
/// view over folders anyone may write to (02-architecture.md), so a cache keyed on the path alone
/// would happily show a picture of a file that has since been replaced the one failure mode this
/// app treats as a bug rather than a staleness. Modification date *and* size, because either alone
/// misses a case: a same-second rewrite keeps the date, and an edit that preserves the length keeps
/// the size.
///
/// **Missing values are a legitimate key, not a refusal.** A file that cannot be stat'd it went
/// away between the listing and the render, a volume dropped keys as `(nil, nil)` and simply
/// misses on the next attempt, which is the ordinary "no thumbnail, show the icon" path rather than
/// a second error surface.
struct AttachmentThumbnailKey: Hashable, Sendable {
/// The half a *view* can name without touching the disk the path and the width it is drawn at.
///
/// The cache is looked up by this during a render and completed by the full key in a task,
/// which is the whole reason it is a type: a render must never `stat`, and a stale-check must
/// never be skipped. The cache holds one `Slot Key` entry per file it has ever resolved, so
/// the lookup on screen stays two dictionary reads.
struct Slot: Hashable, Sendable {
let path: String
let width: Int
/// Widths **bucket to whole points**: a card face is laid out by a `Layout` and its content
/// width can land on a fraction, and a thumbnail regenerated because a card grew by a third
/// of a point would be a cache that never hits.
init(path: String, width: CGFloat) {
self.path = path
self.width = max(1, Int(width.rounded()))
}
}
let slot: Slot
let modified: Date?
let size: Int64?
}
// MARK: - AttachmentThumbnailCache
/// The board window's thumbnail memory **one per board window**, held by `BoardView` and read
/// through the environment by the faces beneath it.
///
/// ### Why the window and not the app, and not the card
///
/// Per *card* would defeat the point: 03-board-ui.md § Card face expands the carousel on selection,
/// so a user walking a lane with the arrow keys reselects cards constantly, and a cache that died
/// with the view would regenerate every thumbnail on every visit. Per *app* would outlive the thing
/// it is caching a board window closing is the natural moment to forget its files, and a
/// process-wide dictionary keyed by path would keep a closed board's images alive for the session.
/// The window is the scope where "the files I am looking at" is true.
///
/// ### What it does not do
///
/// It never watches, invalidates or evicts on change: the key carries the file's stamp, so a
/// rewritten file simply keys differently and the stale entry ages out under the cap below. There is
/// deliberately no invalidation path to keep honest the same posture `Card.attachments` takes
/// ("freshness is the watcher's, by construction").
@MainActor
@Observable
final class AttachmentThumbnailCache {
/// How many generated thumbnails one window keeps. A cap rather than unbounded growth because a
/// board can hold thousands of attachments and a decoded image is not small; a plain
/// insertion-ordered drop rather than a recency policy because the access pattern this serves is
/// "the card I just selected, and the one before it".
static let limit = 96
/// The resolved stamp for each drawn slot the render-time half of the lookup.
private var keys: [AttachmentThumbnailKey.Slot: AttachmentThumbnailKey] = [:]
private var images: [AttachmentThumbnailKey: CGImage] = [:]
/// Insertion order over `images`, for the cap.
private var order: [AttachmentThumbnailKey] = []
/// Keys QuickLook declined a plain-text file, an unknown type, an unreadable one. Remembered
/// so a non-previewable attachment costs one generation attempt per version of itself rather
/// than one per reselection; the face shows its Finder icon and stops asking.
private var unpreviewable: Set<AttachmentThumbnailKey> = []
/// Finder icons, by path. **Deliberately outside observation** (`@ObservationIgnored`): this one
/// is filled *during* a render, because an icon is the fallback a page draws while its thumbnail
/// is still being made, and a tracked write there would invalidate the view that just read it.
/// Nothing depends on an icon changing `NSWorkspace` answers the same image for the same path
/// until the app is relaunched.
@ObservationIgnored private var icons: [String: NSImage] = [:]
// MARK: - Reading, during a render
/// This slot's thumbnail, or `nil` when there is not one *yet* the two dictionary reads a
/// render is allowed to do. A miss is not a failure; it is the icon fallback's cue.
func thumbnail(for slot: AttachmentThumbnailKey.Slot) -> CGImage? {
guard let key = keys[slot] else { return nil }
return images[key]
}
/// The file's Finder icon "Finder icon fallback" (03-board-ui.md § Card face), shown while a
/// thumbnail is being generated and kept for anything QuickLook cannot preview.
///
/// `icon(forFile:)` rather than an icon for the file's *type*, deliberately: it is what Finder
/// itself shows, custom icons and application bundles included, and an attachment that looks
/// different in Finder than on the card would be the app disagreeing with the substrate it is a
/// view over.
func icon(forFileAt url: URL) -> NSImage {
if let cached = icons[url.path] { return cached }
let icon = NSWorkspace.shared.icon(forFile: url.path)
icons[url.path] = icon
return icon
}
// MARK: - Filling, off the render
/// Resolves this slot's key and generates its thumbnail if that key has none the whole of the
/// cache's write side, called from a page's `.task` and never from a body.
///
/// Both halves run **off the main actor** (`nonisolated`): the `stat` because a render is
/// waiting on this task, and the generation because it is a round trip to a QuickLook extension
/// in another process. Only `CGImage` crosses back, which is `Sendable`; the representation the
/// generator hands out is not, and never leaves the function that made it.
func load(_ slot: AttachmentThumbnailKey.Slot, url: URL, height: CGFloat, scale: CGFloat) async {
let key = await Self.resolve(slot, url: url)
keys[slot] = key
guard images[key] == nil, !unpreviewable.contains(key) else { return }
let size = CGSize(width: CGFloat(slot.width), height: height)
guard let image = await Self.generate(url: url, size: size, scale: scale) else {
unpreviewable.insert(key)
return
}
remember(image, for: key)
}
private func remember(_ image: CGImage, for key: AttachmentThumbnailKey) {
if images.updateValue(image, forKey: key) == nil {
order.append(key)
}
while order.count > Self.limit {
images.removeValue(forKey: order.removeFirst())
}
}
private nonisolated static func resolve(
_ slot: AttachmentThumbnailKey.Slot,
url: URL
) async -> AttachmentThumbnailKey {
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
return AttachmentThumbnailKey(
slot: slot,
modified: values?.contentModificationDate,
size: values?.fileSize.map(Int64.init)
)
}
/// One QuickLook request, at the page's own size. `.thumbnail` alone rather than
/// `.all`: the icon representations QuickLook would fall back to are exactly what
/// `icon(forFileAt:)` already draws, and taking them here would cache a decorated icon under a
/// thumbnail's key and never try for a real one again. `iconMode` is off for the same reason
/// the page wants the picture, not a page-curled document.
private nonisolated static func generate(url: URL, size: CGSize, scale: CGFloat) async -> CGImage? {
let request = QLThumbnailGenerator.Request(
fileAt: url,
size: size,
scale: scale,
representationTypes: .thumbnail
)
request.iconMode = false
guard let representation = try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request)
else { return nil }
return representation.cgImage
}
}
-9
View File
@@ -94,14 +94,6 @@ struct BoardView: View {
/// the cards and trash rows only *register* into it (`MarqueeTargetRegistry`).
@State private var marqueeTargets = MarqueeTargetRegistry()
/// This window's attachment thumbnails, for the sole-selected card's carousel (03-board-ui.md
/// § Card face). `@State` for the sessions' reason one per window, living exactly as long as
/// the window, so a board that closes forgets the files it was showing (see
/// `AttachmentThumbnailCache` on why the window and not the app). Handed down through the
/// environment rather than threaded, since nothing between here and a card face has any use for
/// it.
@State private var thumbnails = AttachmentThumbnailCache()
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
/// in: `LaneLayoutMath.laneIndex` reads an x measured from the strip's leading edge, outer margin
/// included, and no global or lane-local space is that.
@@ -157,7 +149,6 @@ struct BoardView: View {
.onDrop(of: boardDropTypes, delegate: StripDropDelegate(context: dropContext))
}
.background(boardBackground)
.environment(thumbnails)
// The window-level fallback, *behind* the specific targets: a release over any in-window
// region they don't cover commits the current proposal instead of leaking the session into a
// cancel-snapback the drop lands where the shadows show, which is what the shadows promise.
-149
View File
@@ -1,149 +0,0 @@
import CoreGraphics
import Foundation
/// The sole-selection attachment carousel's rules, as pure functions of the selection and the card
/// (`CardCarouselTests`) 03-board-ui.md § Card face:
///
/// > Attachments: **the sole selected card** shows the paged media carousel when it has attachments
/// > Single selection only: multi-selections and unselected cards stay compact, and the expansion
/// > animates under the selection-keyed transaction.
///
/// A rule surface rather than a method on the view, for the reason every rule in this codebase that
/// can be one is: the branches are lines of test rather than UI states to drive, and the animation
/// key and the render condition then read the *same* answer instead of two hand-kept-in-sync
/// conditions.
///
/// ### Two answers, deliberately, and why the key is the narrower one
///
/// `soleSelection(_:)` is **the animation key** 03 § Motion's "animated transactions are keyed
/// narrowly on the sole-selected card (carousel expansion) never on broad state like the
/// selection set". `expanded(_:marqueeActive:)` is **what renders**, which is the key with the
/// marquee's suppression on top (below).
///
/// Keeping the marquee out of the *key* is not an oversight it is the second half of the same
/// bullet. A band's selection churn sits in 03's animation-free-by-construction list, so a carousel
/// collapsing because a band began, and one expanding because a band ended naming exactly one card,
/// both change `expanded` without changing the key: no transaction, nothing eases, the face simply
/// is what it is. The only thing that ever animates is the sole selection genuinely becoming a
/// different card.
///
/// ### The marquee suppression, filed for design ratification
///
/// **A band in flight suppresses the carousel entirely** an implementation ruling, awaiting the
/// design's word. 03 says the carousel belongs to "the sole selected card" and says nothing about
/// the rubber band, which sweeps *through* sole selections on its way to a set: drag a band across a
/// row of single cards and every one of them is, for one sample, the entire selection. Expanding and
/// collapsing a carousel per sample would be the board flickering under the cursor, and 03 § Motion
/// already rules that the band is input echo rather than settled state ("the marquee rectangle
/// animating input echo would be lag"). The reading taken here is that a band *names a set in
/// progress, not a settled selection*, and that only a settled selection expands anything. The
/// moment the band ends, whatever it left behind is settled and the ordinary rule applies.
enum CardCarousel {
// MARK: - Who expands
/// The sole-selected **live** card, or `nil` the narrow animation key.
///
/// Three refusals, one line each:
///
/// - **A trashed selection expands nothing.** 03 puts the carousel on the card face, and a
/// tombstoned card has no face it is one row in the trash column (03 § Trash).
/// - **A multi-selection expands nothing** "multi-selections and unselected cards stay
/// compact", which is also why `count == 1` and not `count >= 1`.
/// - An empty selection expands nothing, which is the same clause.
///
/// **Kind falls out of identity rather than being checked here.** A selection is a set of ids
/// and nothing else (`ItemReferenceSet`), so its kind is always re-derived from the snapshot
/// and the one caller re-derives it in the cheapest way there is: a card face compares this
/// answer against *its own id* (`expands(_:expanded:)`), so a sole-selected **lane** returns
/// that lane's id here and matches no face on the board. One walk of the snapshot per rendered
/// card, to reach the same place, is the thing not done.
static func soleSelection(_ selection: ItemReferenceSet) -> ItemID? {
guard selection.liveness == .live, selection.ids.count == 1 else { return nil }
return selection.ids.first
}
/// The card whose carousel is actually **drawn**: the sole selection, suppressed for as long as
/// a rubber band is in flight the ruling filed above.
static func expanded(_ selection: ItemReferenceSet, marqueeActive: Bool) -> ItemID? {
marqueeActive ? nil : soleSelection(selection)
}
/// Whether *this* card draws the carousel: it is the whole selection, and it has files. A card
/// with no attachments stays compact however it is selected the face keeps its quiet paperclip
/// chip and nothing else changes (03 § Card face).
static func expands(_ card: Card, expanded: ItemID?) -> Bool {
guard let expanded, expanded == card.id else { return false }
return !card.attachments.isEmpty
}
// MARK: - What it pages through
/// One page: one attachment, named and located.
///
/// The name is the identity because a folder cannot hold two files by one name and because it
/// is what `Card.attachments` carries, so the page list and the snapshot cannot drift.
struct Page: Identifiable, Equatable, Sendable {
let name: String
let url: URL
var id: String { name }
}
/// The carousel's pages **one per attachment, in the loaded order and no other**.
///
/// That order is Finder's (`localizedStandardCompare`), settled in 01-storage-format.md
/// § Attachments and applied once, in the loader's `attachmentNames(in:)`: "one shared
/// enumeration between loader and Writer, so the carousel and the sidebar can never disagree".
/// Nothing here sorts, filters, or de-duplicates re-deciding any of that would be the second
/// answer that rule exists to prevent.
static func pages(of card: Card, boardRoot: URL, laneID: ItemID) -> [Page] {
let folder = attachmentsFolder(boardRoot: boardRoot, laneID: laneID, cardID: card.id)
return card.attachments.map { Page(name: $0, url: folder.appendingPathComponent($0)) }
}
/// `<root>/<lane>/<card>/attachments/` the fractal path 01-storage-format.md § Fractal layout
/// fixes, spelled through `BoardWriter.attachmentsFolderName` so the folder's one name lives in
/// one place.
static func attachmentsFolder(boardRoot: URL, laneID: ItemID, cardID: ItemID) -> URL {
boardRoot
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
.appendingPathComponent(BoardWriter.attachmentsFolderName, isDirectory: true)
}
// MARK: - The scroll wheel's direction
/// Which way one discrete scroll-wheel tick pages: `+1` forward, `-1` back, `0` for a tick that
/// carried nothing.
///
/// 03 § Card face lists three paging inputs "trackpad pan / dot click / scroll wheel" and
/// the first two are the stock paging behaviour's and a button's. The wheel is the one that
/// needs a rule, because a discrete tick is worth a handful of points and a paging scroll view
/// snaps a handful of points straight back to the page it started on: left alone, a wheel does
/// nothing at all here. `AttachmentCarousel` reads the tick and moves a whole page.
///
/// **The sign is AppKit's, not a guess.** A scroll view advances its document by *subtracting*
/// the scrolling delta from the visible origin, so a negative delta moves the content forward
/// and a positive one moves it back which is why this reads the sign rather than the
/// direction-inversion flag: `scrollingDelta` already has natural scrolling folded into it, and
/// undoing that would invert the setting rather than honour it.
///
/// A horizontal tick (a tilt wheel, or with a plain one) wins over a vertical one when both
/// are present: it names this carousel's own axis.
static func wheelStep(deltaX: CGFloat, deltaY: CGFloat) -> Int {
let delta = deltaX != 0 ? deltaX : deltaY
guard delta != 0 else { return 0 }
return delta < 0 ? 1 : -1
}
/// The page a tick lands on: the current index moved by `step` and **clamped**, never wrapped.
///
/// Clamped because a carousel is a short flat list rather than a loop wrapping from the last
/// attachment back to the first would make "how many are there" unanswerable by paging, and the
/// dots below already answer it at a glance.
static func page(from index: Int, step: Int, count: Int) -> Int {
guard count > 0 else { return 0 }
return min(max(0, index + step), count - 1)
}
}
+22 -78
View File
@@ -27,8 +27,11 @@ import SwiftUI
/// ### The card face
///
/// `CardFaceView`, complete as of m5: the search filter narrows what the masonry lays out and what
/// the badge counts, the deferred cut's dim rides the face (`cutTreatment`), and the sole-selected
/// card expands its attachment carousel in place (`CardCarousel`).
/// the badge counts, the deferred cut's dim rides the face (`cutTreatment`), and selection changes
/// only the face's styling never its geometry. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): the masonry never reflows on
/// a click, and viewing an attachment's media is the card window's job, not the face's
/// (03-board-ui.md § Card face).
struct LaneView: View {
let store: BoardStore
@@ -457,7 +460,6 @@ struct LaneView: View {
CardFaceView(
store: store,
card: card,
laneID: lane.id,
marquee: marquee,
drops: drops,
openCard: openCard
@@ -865,8 +867,7 @@ private enum CardFaceMetrics {
static let stripeWidth: CGFloat = 4
/// The plate's inset around its content.
static let contentPadding: CGFloat = 10
/// Between the icon, the title and the attachments chip and between the title row and the
/// carousel below it.
/// Between the icon, the title and the attachments chip.
static let rowSpacing: CGFloat = 6
}
@@ -893,34 +894,23 @@ private enum CardFaceMetrics {
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
///
/// ### The attachment carousel
/// ### One presentation, selection styling only
///
/// The face is a top-aligned `VStack` and its two decorations the accent stripe and the selection
/// stroke are shapes in overlays, so both stretch to whatever height the content takes. That is
/// what lets the carousel expand *inside* this card without any of it being re-derived: the masonry
/// already isolates column heights, so a taller card pushes only the cards below it in its own
/// column, and every consumer of a card's size reads a **live registered frame** rather than a
/// nominal one the drop model's resting grid (`onGeometryChange` into `LaneDropRegistry`) and the
/// rubber band's sweep universe (`marqueeTarget`) both re-register the moment the card grows, so
/// neither needed a word about the expansion.
///
/// Who expands and when is `CardCarousel`'s, and it is the whole of what this view decides here:
/// `soleSelectedCardID` is the narrow animation key (03-board-ui.md § Motion), `expandedCardID` is
/// the same answer with the rubber band's suppression on top, and the difference between them is
/// why a band never animates anything.
/// The face is a top-aligned title row and its two decorations the accent stripe and the
/// selection stroke are shapes in overlays. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
/// face's styling the selection stroke below and never its geometry, so the masonry never
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
/// attachment's media is the card window's job ( / double-click, 05-card-window.md), not the
/// face's the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
/// face).
private struct CardFaceView: View {
let store: BoardStore
let card: Card
/// The lane this face is drawn in the middle component of the card's folder path, which is
/// what the carousel needs to reach `attachments/`. Passed rather than looked up: the lane
/// rendering this face already knows, and a walk of the snapshot per card face to re-learn it
/// would be the board's own layout asking the board where its cards are.
let laneID: ItemID
/// The strip's rubber band the registry this face registers its drawn frame into, and the
/// session whose in-flight band suppresses the carousel (`CardCarousel.expanded`).
/// The strip's rubber band the registry this face registers its drawn frame into.
let marquee: MarqueeControl
/// The board window's drop machinery: this face registers its measured height into the geometry
@@ -932,10 +922,6 @@ private struct CardFaceView: View {
/// The app-wide quick-style recents see `LaneView`'s own note.
@Environment(AppModel.self) private var appModel
/// Reduce Motion, for the carousel's expansion and its page slides (10-accessibility.md). Read
/// from the environment and handed to `Motion`, which owns what "reduced" means.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// The plate's corner radius shared with the accent stripe, which rounds its left corners to
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
@@ -947,18 +933,7 @@ private struct CardFaceView: View {
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
var body: some View {
VStack(alignment: .leading, spacing: CardFaceMetrics.rowSpacing) {
titleRow
carousel
}
// **The narrow transaction** (03-board-ui.md § Motion: "animated transactions are keyed
// narrowly on the sole-selected card (carousel expansion) never on broad state like the
// selection set"). The key is the sole selection and nothing else: a multi-select's churn
// never changes it, so multi-select churn never animates the same bullet's other half,
// held by construction rather than by suppression.
//
// The band is deliberately *not* in the key, only in what renders see `CardCarousel`.
.animation(Motion.carouselExpansion(reduced: reduceMotion), value: soleSelectedCardID)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(CardFaceMetrics.contentPadding)
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
@@ -1258,7 +1233,7 @@ private struct CardFaceView: View {
/// A value that resolves to nothing a typo'd palette name, a malformed hex, a sequence where
/// a scalar belongs draws **no stripe**, and the value stays on disk exactly as written.
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
/// content does, m5's carousel expansion included.
/// content does a title wrapping across its full four lines included.
@ViewBuilder
private var accentStripe: some View {
if let color = Palette.color(for: card.background) {
@@ -1270,37 +1245,6 @@ private struct CardFaceView: View {
}
}
// MARK: - The attachment carousel
/// The paged carousel, when this card is the entire selection and has files to page through
/// (03-board-ui.md § Card face) below the title, inside this same plate, adding height and
/// changing nothing above it.
///
/// **An untitled card gets it identically**: the placeholder is what sits above, and 03 makes
/// titles optional at every level without qualifying anything that reads them.
@ViewBuilder
private var carousel: some View {
if CardCarousel.expands(card, expanded: expandedCardID) {
AttachmentCarousel(
pages: CardCarousel.pages(of: card, boardRoot: store.rootURL, laneID: laneID)
)
.transition(Motion.carouselTransition)
}
}
/// **The animation key** the sole-selected card, band or no band. See `CardCarousel`: keeping
/// the marquee out of the key is what makes a band's arrival and departure change the face
/// without easing anything, which is 03 § Motion's animation-free-by-construction rule for the
/// marquee applied to the one surface its churn could otherwise animate.
private var soleSelectedCardID: ItemID? {
CardCarousel.soleSelection(store.selection)
}
/// What actually expands: the key, suppressed for as long as a rubber band is in flight.
private var expandedCardID: ItemID? {
CardCarousel.expanded(store.selection, marqueeActive: marquee.session.isActive)
}
// MARK: - Selection and rename plumbing
private var isSelected: Bool {
@@ -1354,8 +1298,8 @@ private struct CardFaceView: View {
///
/// A newly created card is always default-styled `BoardWriter.createCard` writes `schema`, `title`
/// and `order` and nothing else so "the arriving card's chrome" is exactly: the level-default
/// symbol, the standard secondary tint, no accent stripe, no selection stroke (the commit re-selects
/// the *lane*), and no carousel (nothing is attached yet, and it is not the sole selection).
/// symbol, the standard secondary tint, no accent stripe, and no selection stroke (the commit
/// re-selects the *lane*).
private struct NewCardStubView: View {
let store: BoardStore
@@ -1459,8 +1403,8 @@ private struct NewCardStubView: View {
///
/// **`NewCardStubView.arrivingFace`'s precedent, applied to the drag**, and for the same reason: a
/// static rendition at the same numbers (`CardFaceMetrics`) is what makes the echo's swap invisible
/// rather than merely un-animated. It carries no gestures, no drop target, no geometry registration
/// and no carousel it stands in for exactly one round trip, and every surface that reads a card's
/// rather than merely un-animated. It carries no gestures, no drop target, and no geometry
/// registration it stands in for exactly one round trip, and every surface that reads a card's
/// drawn frame (the drop zones, the rubber band) is reading the *snapshot*'s cards, which this is
/// not one of.
///
+3 -3
View File
@@ -90,9 +90,9 @@ struct MasonryPlacement: Equatable, Sendable {
/// Children are assigned round-robin to `columns` equal-width vertical columns (child `i` column
/// `i % columns`), and each column stacks its children top-aligned and independently there is
/// **no row alignment across columns**. With uniform card heights this renders exactly like a
/// row-major grid, but when one card grows taller (the sole selected card's attachment carousel,
/// 03-board-ui.md § Card face) it only pushes the cards below it in its *own* column; the
/// neighbouring columns do not move.
/// row-major grid, but when one card grows taller than its neighbours (a longer title wrapping
/// across more lines, say) it only pushes the cards below it in its *own* column; the neighbouring
/// columns do not move.
///
/// A `Layout` rather than an `HStack` of per-column `VStack`s so the caller keeps a single
/// `ForEach` reflowing cards across columns preserves view identity and animates as positional
-28
View File
@@ -105,20 +105,6 @@ enum Motion {
reduced ? nil : .snappy(duration: Duration.laneResize)
}
/// **The sole-selected card's carousel expanding and collapsing** 03-board-ui.md § Motion's
/// first named narrow key ("keyed narrowly on the sole-selected card (carousel expansion)").
///
/// The structural voice, because that is what the change *is*: the card grows, and the cards
/// below it in its masonry column slide down to make room. Nothing about it is a content reflow
/// the carousel's own arrival is a crossfade over the movement (`carouselAppearance`), and the
/// movement is positional.
///
/// It is spelled here rather than aliased at the call site so the figure stays one decision: if
/// the expansion ever wants a duration of its own, this is the line that changes.
static func carouselExpansion(reduced: Bool) -> Animation? {
structural(reduced: reduced)
}
/// The content-reflow voice: search filtering and undo/redo restore, "deliberately paired so a
/// restore reads like the search filter leavers and arrivers run their transition, survivors
/// reflow under one gentle spring".
@@ -179,20 +165,6 @@ enum Motion {
laneAppearance(reduced: reduced).transition
}
/// **The attachment carousel arriving inside an already-drawn card** the one appearance in the
/// app with no reduced variant, because it is already the reduced form.
///
/// 03-board-ui.md fixes an appear/disappear *scale* for cards (~0.8) and lanes (~0.9) and for
/// nothing else, and inventing a third would be the per-site literal this type exists to
/// prevent. It is also unnecessary: the movement a carousel expansion performs is the card
/// growing, which `carouselExpansion` already animates the carousel itself only has to arrive
/// over it, and 10-accessibility.md's word for that is crossfade. So a Reduce Motion user gets
/// the same fade with the growth gone instant, which is exactly what the per-voice rule asks
/// for, one accessor rather than two.
static var carouselAppearance: Appearance { .crossfade }
static var carouselTransition: AnyTransition { carouselAppearance.transition }
// MARK: - The AppKit face
/// The lane-resize window animation's duration, for `NSAnimationContext` which takes a number
+4 -4
View File
@@ -504,10 +504,10 @@ struct CardAttachmentListingTests {
/// **Finder order** (`localizedStandardCompare`), not plain lexicographic digits inside a
/// name *count* rather than collate, so the run `importAttachments` itself produces on a
/// collision (`shot.png` `shot 2.png` `shot 10.png`) pages 2-before-10, and the face's
/// carousel matches the card window sidebar's listing, which answers through this same
/// enumeration. (`shot.png` trailing its own numbered copies is Finder's own ordering of a
/// space against a dot, not a quirk of ours.)
/// collision (`shot.png` `shot 2.png` `shot 10.png`) sorts 2-before-10, matching the card
/// window sidebar's listing, which answers through this same enumeration. (`shot.png` trailing
/// its own numbered copies is Finder's own ordering of a space against a dot, not a quirk of
/// ours.)
@Test func namesSortInFinderOrderSoNumbersCountRatherThanCollate() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
-356
View File
@@ -1,356 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// The sole-selection attachment carousel's rules (03-board-ui.md § Card face, § Motion;
/// 10-accessibility.md Reduce Transparency).
///
/// **What is testable here is the whole of what was written to be.** A thumbnail is a round trip to
/// a QuickLook extension in another process, a page slide is a scroll view under a pointer, and a
/// glass underlay is pixels none of that is a unit test. What *is* one is every decision that
/// precedes them: who expands, when the rubber band suppresses it, what the pages are and in what
/// order, what a cached thumbnail is filed under, and which way a wheel tick moves. Those are pure
/// functions precisely so this file can exist.
///
/// The board underneath is a real load off a real temp tree, `NewCardTargetTests`' posture and for
/// its reason: the ordering these assertions make claims about is the *loader's*
/// (`localizedStandardCompare`, 01-storage-format.md § Attachments), and a hand-built `Card` would
/// let this file agree with itself while disagreeing with the app.
// MARK: - Fixtures
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
/// A card with no `title` key at all the untitled placeholder's card, which 03 gives the carousel
/// on exactly the same terms as any other.
private func untitled(order: String) -> String {
"""
---
schema: 1
order: \(order)
---
Body without a title.
"""
}
/// One lane, three cards: one with attachments whose names sort by Finder's rule rather than by
/// ASCII, one with none, and one untitled but attached.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Attached"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Bare"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", untitled(order: "3072"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
// Finder order is `localizedStandardCompare`, so "shot2" precedes "shot10" the one ordering a
// plain lexicographic sort gets wrong, which is why these are the names.
for name in ["shot10.png", "shot2.png", "notes.txt"] {
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(name)", Data("x".utf8))
}
try fixture.file("\(Ident.lane1)/\(Ident.card3)/attachments/only.pdf", Data("x".utf8))
return fixture
}
private func card(_ id: ItemID, in snapshot: BoardModel) throws -> Card {
try #require(snapshot.lanes.flatMap(\.cards).first { $0.id == id })
}
private func live(_ ids: ItemID...) -> ItemReferenceSet {
ItemReferenceSet(ids: Set(ids), liveness: .live)
}
// MARK: - Who expands
@MainActor
@Suite("CardCarousel ▸ the sole-selection rule")
struct CardCarouselSelectionTests {
/// The positive case, stated on its own: one live card, and it is the key.
@Test("A sole live selection is the key")
func aSoleLiveSelectionIsTheKey() {
#expect(CardCarousel.soleSelection(live(card1)) == card1)
}
/// "Multi-selections and unselected cards stay compact" both halves, and the reason the
/// predicate reads `count == 1` rather than `count >= 1`.
@Test("Nothing selected, and more than one thing selected, expand nothing")
func multiAndEmptySelectionsExpandNothing() {
#expect(CardCarousel.soleSelection(.empty) == nil)
#expect(CardCarousel.soleSelection(live(card1, card2)) == nil)
#expect(CardCarousel.soleSelection(live(card1, card2, card3)) == nil)
}
/// **Side.** The carousel is a *card face* surface, and a tombstoned card has no face it is
/// one row in the trash column (03-board-ui.md § Trash). A sole trashed selection is therefore
/// not a smaller carousel; it is none.
@Test("A sole trashed selection expands nothing")
func aSoleTrashedSelectionExpandsNothing() {
#expect(CardCarousel.soleSelection(ItemReferenceSet(ids: [card1], liveness: .trashed)) == nil)
}
/// **Kind, settled by identity rather than by a snapshot walk.** A sole-selected *lane* is a
/// perfectly good sole selection and this function says so it returns the lane's id, which
/// then matches no card face on the board, so nothing expands. The claim under test is that the
/// two steps compose to the right answer, because that composition is what saved every rendered
/// card a walk of the snapshot.
@Test("A sole-selected lane matches no card face")
func aSoleSelectedLaneMatchesNoCardFace() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let attached = try card(card1, in: snapshot)
let sole = CardCarousel.soleSelection(live(lane1))
#expect(sole == lane1)
#expect(!CardCarousel.expands(attached, expanded: sole))
}
/// The face's own half of the rule: it is the whole selection **and** it has files.
@Test("A card expands only when it is the selection and has attachments")
func aCardExpandsOnlyWhenSelectedAndAttached() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let attached = try card(card1, in: snapshot)
let bare = try card(card2, in: snapshot)
#expect(CardCarousel.expands(attached, expanded: card1))
// "The compact face keeps its quiet paperclip chip unchanged" a card with no files has
// nothing to page through, however it is selected.
#expect(!CardCarousel.expands(bare, expanded: card2))
// Somebody else's selection.
#expect(!CardCarousel.expands(attached, expanded: card2))
#expect(!CardCarousel.expands(attached, expanded: nil))
}
/// **An untitled card gets the carousel on identical terms** 03 makes titles optional at every
/// level and qualifies nothing that reads them, so the placeholder sits above and the pages sit
/// below exactly as they would for a titled card.
@Test("An untitled card expands like any other")
func anUntitledCardExpandsLikeAnyOther() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let untitledCard = try card(card3, in: snapshot)
#expect(untitledCard.title.value == nil)
#expect(CardCarousel.expands(untitledCard, expanded: card3))
}
}
// MARK: - The marquee suppression
@MainActor
@Suite("CardCarousel ▸ the rubber band's suppression")
struct CardCarouselMarqueeTests {
/// The ruling filed for ratification, as a pure function of the two things it reads: a band in
/// flight expands nothing, whatever the selection currently says.
@Test("A band in flight suppresses every expansion")
func aBandInFlightSuppressesEveryExpansion() {
#expect(CardCarousel.expanded(live(card1), marqueeActive: false) == card1)
#expect(CardCarousel.expanded(live(card1), marqueeActive: true) == nil)
#expect(CardCarousel.expanded(.empty, marqueeActive: true) == nil)
#expect(CardCarousel.expanded(live(card1, card2), marqueeActive: true) == nil)
}
/// **The band never enters the animation key** (03-board-ui.md § Motion's
/// animation-free-by-construction list, applied to the one surface a band's churn could
/// otherwise animate). The key is the selection alone, so beginning and ending a band change
/// what renders without changing what the transaction is keyed on no transaction, nothing
/// eases. This is the assertion that would fail if the two answers were ever collapsed into one.
@Test("Suppression changes what renders, never the animation key")
func suppressionNeverMovesTheAnimationKey() {
let selection = live(card1)
#expect(CardCarousel.soleSelection(selection) == card1)
#expect(CardCarousel.expanded(selection, marqueeActive: true) == nil)
// The key is untouched by the band by construction: it does not take the flag at all.
#expect(CardCarousel.soleSelection(selection) == card1)
}
}
// MARK: - The pages
@MainActor
@Suite("CardCarousel ▸ pages")
struct CardCarouselPageTests {
/// One page per attachment, **in the order the loader produced** Finder's, which is why
/// `shot2` precedes `shot10` and why nothing here sorts.
@Test("Pages are one per attachment, in the loaded Finder order")
func pagesFollowTheLoadedOrder() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let attached = try card(card1, in: snapshot)
#expect(attached.attachments == ["notes.txt", "shot2.png", "shot10.png"])
let pages = CardCarousel.pages(of: attached, boardRoot: fixture.root, laneID: lane1)
#expect(pages.count == attached.attachments.count)
#expect(pages.map(\.name) == attached.attachments)
#expect(pages.map(\.id) == attached.attachments)
}
/// Each page points at the file the fractal layout puts it in
/// `<root>/<lane>/<card>/attachments/<name>` (01-storage-format.md § Fractal layout).
@Test("Each page points at the file under the card's attachments folder")
func eachPagePointsAtItsFile() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let attached = try card(card1, in: snapshot)
let pages = CardCarousel.pages(of: attached, boardRoot: fixture.root, laneID: lane1)
let folder = CardCarousel.attachmentsFolder(boardRoot: fixture.root, laneID: lane1, cardID: card1)
#expect(folder.lastPathComponent == "attachments")
for page in pages {
#expect(page.url == folder.appendingPathComponent(page.name))
#expect(FileManager.default.fileExists(atPath: page.url.path))
}
}
/// A card with no files pages through nothing the same answer `expands` gives, reached
/// independently, so a face that somehow rendered a carousel would render an empty one rather
/// than crash on a first page.
@Test("A card with no attachments has no pages")
func aBareCardHasNoPages() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let bare = try card(card2, in: snapshot)
#expect(CardCarousel.pages(of: bare, boardRoot: fixture.root, laneID: lane1).isEmpty)
}
/// The folder is built from the ids, so the same card in a different lane resolves to a
/// different path which is what makes the lane a parameter rather than something this type
/// could guess.
@Test("The attachments folder is the card's own, lane included")
func theAttachmentsFolderIsTheCardsOwn() {
let root = URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true)
let here = CardCarousel.attachmentsFolder(boardRoot: root, laneID: lane1, cardID: card1)
let there = CardCarousel.attachmentsFolder(boardRoot: root, laneID: lane2, cardID: card1)
#expect(here != there)
#expect(here.pathComponents.suffix(3) == [lane1.rawValue, card1.rawValue, "attachments"])
}
}
// MARK: - Paging arithmetic
@Suite("CardCarousel ▸ paging arithmetic")
struct CardCarouselPagingTests {
/// The wheel's sign, which is AppKit's: a scroll view advances by *subtracting* the delta, so a
/// negative delta pages forward.
@Test("A wheel tick pages in the direction the delta names")
func aWheelTickPagesInTheDeltaSDirection() {
#expect(CardCarousel.wheelStep(deltaX: 0, deltaY: -3) == 1)
#expect(CardCarousel.wheelStep(deltaX: 0, deltaY: 3) == -1)
}
/// A tick carrying nothing moves nothing and, in the view, is handed back to the scroll view
/// rather than eaten.
@Test("A tick with no delta pages nothing")
func anEmptyTickPagesNothing() {
#expect(CardCarousel.wheelStep(deltaX: 0, deltaY: 0) == 0)
}
/// A horizontal tick a tilt wheel, or with a plain one wins, because it names this
/// carousel's own axis.
@Test("A horizontal tick outranks a vertical one")
func aHorizontalTickOutranksAVerticalOne() {
#expect(CardCarousel.wheelStep(deltaX: -5, deltaY: 20) == 1)
#expect(CardCarousel.wheelStep(deltaX: 5, deltaY: -20) == -1)
}
/// **Clamped, never wrapped**: a carousel is a short flat list, and paging past its last
/// attachment back to its first would make "how many are there" unanswerable by paging.
@Test("Paging clamps at both ends")
func pagingClampsAtBothEnds() {
#expect(CardCarousel.page(from: 0, step: 1, count: 3) == 1)
#expect(CardCarousel.page(from: 2, step: 1, count: 3) == 2)
#expect(CardCarousel.page(from: 0, step: -1, count: 3) == 0)
#expect(CardCarousel.page(from: 2, step: -1, count: 3) == 1)
}
/// The degenerate inputs a live board can hand it an emptied `attachments/` between a render
/// and a wheel tick answer zero rather than trapping on a negative index.
@Test("An empty carousel pages to nothing")
func anEmptyCarouselPagesToNothing() {
#expect(CardCarousel.page(from: 0, step: 1, count: 0) == 0)
#expect(CardCarousel.page(from: 4, step: -1, count: 0) == 0)
}
}
// MARK: - The thumbnail cache key
@Suite("AttachmentThumbnailKey ▸ what a thumbnail is filed under")
struct AttachmentThumbnailKeyTests {
private let path = "/tmp/Board.kanban/lane/card/attachments/shot.png"
private let stamp = Date(timeIntervalSince1970: 1_700_000_000)
/// **Widths bucket to whole points.** A card face is laid out by a `Layout` and its content
/// width can land on a fraction; a thumbnail regenerated because a card grew by a third of a
/// point would be a cache that never hits.
@Test("A fractional width buckets to a whole point")
func fractionalWidthsBucket() {
let a = AttachmentThumbnailKey.Slot(path: path, width: 220.4)
let b = AttachmentThumbnailKey.Slot(path: path, width: 219.8)
#expect(a == b)
#expect(a.width == 220)
}
/// A real size change is still a different slot the thumbnail is generated *at* a size.
@Test("A different width is a different slot")
func differentWidthsAreDifferentSlots() {
#expect(AttachmentThumbnailKey.Slot(path: path, width: 220)
!= AttachmentThumbnailKey.Slot(path: path, width: 260))
}
/// A zero or negative width the one frame before the carousel has been measured floors at
/// one rather than producing a degenerate key.
@Test("A width of zero floors at one point")
func zeroWidthFloorsAtOnePoint() {
#expect(AttachmentThumbnailKey.Slot(path: path, width: 0).width == 1)
#expect(AttachmentThumbnailKey.Slot(path: path, width: -8).width == 1)
}
/// **The stamp is what makes the cache honest.** A board is a live view over folders anyone may
/// write to, so an entry keyed on the path alone would show a picture of a file that has since
/// been replaced.
@Test("A rewritten file is a different key")
func aRewrittenFileIsADifferentKey() {
let slot = AttachmentThumbnailKey.Slot(path: path, width: 220)
let original = AttachmentThumbnailKey(slot: slot, modified: stamp, size: 1024)
// Same bytes, same everything: the hit a reselection depends on.
#expect(AttachmentThumbnailKey(slot: slot, modified: stamp, size: 1024) == original)
// Edited a second later, and edited in place at the same length within the same second
// either component alone would miss one of these, which is why both are in the key.
#expect(AttachmentThumbnailKey(slot: slot, modified: stamp.addingTimeInterval(1), size: 1024) != original)
#expect(AttachmentThumbnailKey(slot: slot, modified: stamp, size: 2048) != original)
}
/// A file that could not be stat'd keys as `(nil, nil)` a legitimate key that simply misses,
/// which is the ordinary "no thumbnail, show the icon" path rather than a second error surface.
@Test("An unstattable file is a legitimate key")
func anUnstattableFileIsALegitimateKey() {
let slot = AttachmentThumbnailKey.Slot(path: path, width: 220)
let unknown = AttachmentThumbnailKey(slot: slot, modified: nil, size: nil)
#expect(unknown == AttachmentThumbnailKey(slot: slot, modified: nil, size: nil))
#expect(unknown != AttachmentThumbnailKey(slot: slot, modified: stamp, size: 1024))
}
}
-21
View File
@@ -117,16 +117,6 @@ struct MotionCurveTests {
#expect(Motion.laneResizeWindowDuration == 0.2)
#expect(Motion.laneResize(reduced: false) == .snappy(duration: Motion.laneResizeWindowDuration))
}
/// **The carousel expansion is the structural voice** (03-board-ui.md § Motion's first named
/// narrow key). What the change *is* is positional the card grows and its column-mates slide
/// down so it takes the general snappy spring rather than the filtering one. Pinned as an
/// identity with `structural` rather than as a literal, because if the two ever diverge it
/// should be a deliberate edit to one line.
@Test func theCarouselExpansionIsTheStructuralVoice() {
#expect(Motion.carouselExpansion(reduced: false) == Motion.structural(reduced: false))
#expect(Motion.carouselExpansion(reduced: false) != Motion.contentReflow(reduced: false))
}
}
// MARK: - Reduce Motion
@@ -141,7 +131,6 @@ struct ReduceMotionVariantTests {
#expect(Motion.delete(reduced: true) == nil)
#expect(Motion.laneResize(reduced: true) == nil)
#expect(Motion.contentReflow(reduced: true) == nil)
#expect(Motion.carouselExpansion(reduced: true) == nil)
}
/// "Appear/disappear is scale + fade (cards scale from ~0.8, lanes ~0.9, combined with opacity)."
@@ -156,16 +145,6 @@ struct ReduceMotionVariantTests {
#expect(Motion.cardAppearance(reduced: true) == .crossfade)
#expect(Motion.laneAppearance(reduced: true) == .crossfade)
}
/// **The carousel is the one appearance with no reduced variant, because it is already the
/// reduced form.** 03 fixes an appear/disappear scale for cards and lanes and for nothing else,
/// and the carousel needs none: the movement is the card growing, which `carouselExpansion`
/// animates and Reduce Motion makes instant, so the content only has to fade in over it. This
/// asserts the parameterless accessor is deliberate rather than an omission.
@Test func theCarouselAppearanceIsTheCrossfadeInBothVariants() {
#expect(Motion.carouselAppearance == .crossfade)
#expect(Motion.carouselAppearance != Motion.cardAppearance(reduced: false))
}
}
// MARK: - The create handoff
+3 -5
View File
@@ -13,12 +13,10 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo; a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced).
- **The attachment carousel** — select a single card with files and its face expands in place into a paged carousel, one page per attachment in Finder order: a QuickLook thumbnail for anything previewable, the file's Finder icon while one is being made and instead of one for anything that isn't, and page dots on a glass underlay (solid under Reduce Transparency). Trackpad pan, a click on a dot, and a scroll-wheel tick all page it. It is single-selection only — add a second card to the selection and every carousel folds away — and a rubber band in flight suppresses it outright, so sweeping a band across a row of cards never flickers one open. The expansion animates under a transaction keyed on the sole-selected card and nothing broader, so multi-select churn and the band itself animate nothing at all; Reduce Motion makes the growth instant and leaves the fade. Thumbnails are cached per board window against each file's own bytes, so reselecting a card costs nothing while a file rewritten under the app regenerates.
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
- **Drag & drop** — cards, lanes and trash rows all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane copy strips tombstoned cards while a lane move carries them whole. Dragging a trash row onto a lane restores it at the drop position, ⌥ copies it out live instead, and dropping it on another board follows the same copy-out grammar. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. Tombstoned surfaces are inert to them, and a read-only board or an open inline editor refuses them outright.
- **Drag & drop** — cards, lanes and trash rows all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane copy strips tombstoned cards while a lane move carries them whole. Dragging a trash row onto a lane restores it at the drop position, ⌥ copies it out live instead, and dropping it on another board follows the same copy-out grammar. The same gesture runs the other way: dropping a live card on the shown trash deletes it, exactly as ⌫ would, with the shadow always taking the topmost row — which the newest-first sort makes honest rather than arbitrary. Lanes aren't deliverable that way, a foreign board's card isn't, and ⌥ isn't, since copying into the trash isn't a thing. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. Tombstoned surfaces are inert to them, and a read-only board or an open inline editor refuses them outright.
- **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — with ⇧-arrows extending the range from the anchor and stopping dead at the liveness and card/lane-entry boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't.
@@ -26,7 +24,7 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
- **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live.
- **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there, and drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical tombstone. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live.
- **Live search** — the board window's toolbar carries one item, a search field (Edit ▸ Find, ⌘F), and typing in it filters the board as you type: a card stays when its title *or* its body contains the query, case- and diacritic-insensitively (so `resume` finds "Résumé"), and everything else animates out under one gentle spring while the survivors reflow. Scope is title and body only — attachment filenames are deliberately not searched. The filter is the single source of truth for what's on the board rather than a highlight over it: the masonry, each lane's count badge, drop zones, the rubber band, ⇧-ranges, Select All, arrow navigation and the ⌥-jumps all read it, and the shown trash filters like any other lane, its card rows and lane rows each by their own text. Nothing invisible stays selected — a card the query hides leaves the selection the moment it goes, and so does one an agent edits out of the match while you search. The field is a control, not an editor: board commands stay live and act on the selection while you type (⌘N included, which clears the search first so a new card is never born invisible), only ⌘←/⌘→ and ⌥⌘←/⌥⌘→ stand down so they stay caret chords, plain ⌫ edits the query while ⌘⌫ still deletes the selection, and Return is swallowed because a live filter has nothing to submit. Tab hands the keyboard to the board with the query intact. Escape steps out one layer per press — a non-empty field clears, an empty one returns focus to the board, and a board-focused Escape under an active search clears it before it means deselect. A rename is deliberately not a carve-out: rename a card out of the match during a search and it animates away exactly as an agent's edit would.