Build the sole-selection attachment carousel

The card face's in-place expansion (03-board-ui.md § Card face): a card
with attachments, as the sole selection, grows a paged media band below
its title; every other state stays compact behind the quiet paperclip.

- CardCarousel owns the pure rules: the sole-selection predicate decides
  by identity (a sole-selected lane matches no face, no snapshot walk),
  and expansion is suppressed outside the animation key while a rubber
  band is active — a band names a set in progress, so carousels neither
  flicker nor animate under it.
- QuickLook thumbnails generate off-main into a per-window cache keyed to
  survive reselection, with the Finder-icon fallback while loading and
  for non-previewable types; pages ride the platform paging behavior,
  dots (glass underlay, solid under Reduce Transparency) click to page,
  and a local wheel monitor turns a discrete tick into one clamped page —
  precise trackpad pans fall through untouched.
- The expansion animates under Motion's new carouselExpansion transaction
  keyed narrowly on the sole-selected card; Reduce Motion goes instant.
  Drop-slot math and the marquee read the expanded height for free — both
  re-register on every size change.

928 unit tests (24 new). m5-interactions complete.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 00:21:13 -04:00
parent 6c490ec71c
commit 2fc4020a2b
9 changed files with 1111 additions and 17 deletions
@@ -0,0 +1,293 @@
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
@@ -0,0 +1,181 @@
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,6 +94,14 @@ 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.
@@ -149,6 +157,7 @@ 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
@@ -0,0 +1,149 @@
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)
}
}
+72 -17
View File
@@ -24,11 +24,11 @@ import SwiftUI
/// the quick-style recents row, the Width stepper and Delete 11-command-nexus.md Context menus'
/// Lane row, in its order, complete as of m5.
///
/// ### What is still a later card's
/// ### The card face
///
/// The search-aware filtering behind the count belongs to a later milestone. The card face is real
/// (`CardFaceView`) and wears the deferred cut's dim (`cutTreatment`); what it still owes is the
/// sole-selected card's attachment carousel.
/// `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`).
struct LaneView: View {
let store: BoardStore
@@ -454,7 +454,8 @@ struct LaneView: View {
CardFaceView(
store: store,
card: card,
registry: marquee.registry,
laneID: lane.id,
marquee: marquee,
drops: drops,
openCard: openCard
)
@@ -724,21 +725,35 @@ private enum LaneSlot: Identifiable {
/// 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).
///
/// ### Room for the carousel
/// ### The attachment carousel
///
/// 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 m5's 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.
/// 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.
private struct CardFaceView: View {
let store: BoardStore
let card: Card
/// Where the rubber band looks up what it is sweeping. The face registers its own drawn frame
/// here and takes it out again when it leaves see `View.marqueeTarget`.
let registry: MarqueeTargetRegistry
/// 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`).
let marquee: MarqueeControl
/// The board window's drop machinery: this face registers its measured height into the geometry
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
@@ -749,6 +764,10 @@ 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.
private let cornerRadius: CGFloat = 8
@@ -760,11 +779,16 @@ private struct CardFaceView: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
titleRow
// m5-carousel: the sole selected card's paged attachment carousel expands here below
// the title, inside this same plate, keyed on the selection transaction
// (03-board-ui.md § Card face). It needs `card.attachments` (already loaded) and this
// view's `isSelected`; nothing above it changes.
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(10)
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
@@ -819,7 +843,7 @@ private struct CardFaceView: View {
drops.registry.update(height: height, for: card.id)
}
.onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, side: .live, in: registry)
.marqueeTarget(card.id, kind: .card, side: .live, in: marquee.registry)
.contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
@@ -1073,6 +1097,37 @@ 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 {
+28
View File
@@ -105,6 +105,20 @@ 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".
@@ -165,6 +179,20 @@ 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
+356
View File
@@ -0,0 +1,356 @@
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
@@ -111,6 +111,16 @@ 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
@@ -125,6 +135,7 @@ 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)."
@@ -139,4 +150,14 @@ 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))
}
}
+2
View File
@@ -14,6 +14,8 @@ Lanework is in early development. This list tracks what has actually shipped and
- **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.
- **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.