Materialize the trash — faces, menus, and grammar

Phase 3 finishes the pivot at the surface. One card face serves two
containers: CardFaceView extracted with a role — board or trash — so
stripe, tint, chip, selection stroke, cut dim, marquee registration,
and drag are shared by construction, the trash side differing only in
its absences: no Open, no rename, no Style, no file-hover highlight,
and a Delete that goes through the confirmation host. The column
rewrote around the lanes' own single-column masonry so drag reflow
reads as positional slides; chrome stays the hatched header, symbol,
and count — 11 gives Empty Trash to the File menu alone. Two real
grammar bugs die here: plain Backspace on a trash selection purged
without the confirmation the menu raises, and the context menu's
Delete resolved against the standing selection, so right-clicking a
trash card under a board selection silently did nothing — it now
stages the clicked set explicitly. Open, Rename, Style, and Empty
Trash validation became testable store seams; the column is one named
accessibility container of ordinary card elements. The tombstone era
is swept: deleteItem, restoreItem, stripTombstonedChildren — dead
since lane copies stopped nesting trash — the restore verb, the
unreachable put-back banner row, and every quasi-lane doc comment.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 18:18:39 -04:00
parent 53bc71f7fb
commit 797d020d01
34 changed files with 1272 additions and 1422 deletions
+8 -425
View File
@@ -473,9 +473,9 @@ struct LaneView: View {
CardFaceView(
store: store,
card: card,
role: .board(openCard: openCard),
marquee: marquee,
drops: drops,
openCard: openCard
drops: drops
)
case let .placeholder(phase):
NewCardStubView(store: store, phase: phase, openCard: openCard)
@@ -487,7 +487,7 @@ struct LaneView: View {
}
}
// "Appear/disappear is scale + fade (cards scale from ~0.8 )"
// (03-board-ui.md § Motion), which is how a create, a delete, a Put Back and
// (03-board-ui.md § Motion), which is how a create, a delete, a restore and
// (m5) a search filter's leavers all reach the masonry. The placeholder wears it
// too: it is the card, one round trip early. Whether any of it *performs* is
// decided upstream at the reload for the real cards (`Motion.reloadAnimates`),
@@ -636,10 +636,10 @@ struct LaneView: View {
return result
}
/// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane the
/// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
/// tombstoned lane at all.
/// **A deleted card renders nowhere, and needs no rule to**: deletion is a *move* into `.trash/`
/// (03-board-ui.md § Trash, resettled 2026-07-28), so a deleted card has physically left
/// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
/// effective-liveness predicate are retired with the flag they read.
///
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
/// of the resting layout at pickup and stays out until release *whatever the effective operation
@@ -811,423 +811,6 @@ enum LaneSlot: Identifiable {
}
}
// MARK: - The card plate's metrics
/// The card plate's geometry, spelled once because **two views draw it**: the real face
/// (`CardFaceView`) and the placeholder standing in for a card on its way (`NewCardStubView`'s
/// `.awaitingArrival` face). 02-architecture.md TransientBoardState overlays makes the handoff
/// "read as one arrival the placeholder renders at the arriving card's exact geometry/chrome", and
/// exact is only checkable if there is one set of numbers rather than two that happen to agree.
private enum CardFaceMetrics {
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
/// of the card's edge rather than a bar laid over it.
static let cornerRadius: CGFloat = 8
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities). Reserved as padding whether
/// or not a stripe paints, so colouring a card never shifts its title.
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.
static let rowSpacing: CGFloat = 6
}
// MARK: - Card face
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe
/// (03-board-ui.md § Card face, § Styling Capabilities).
///
/// ### Title-only, deliberately
///
/// **No body excerpt** settled, "the face stays title-only the old 'iterate on the card face
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
/// indicator when the card has files the title dominates", which is why the paperclip is a
/// secondary-tinted caption and not a count pill: the eye should land on the title.
///
/// ### Two lenient fields, two different fallbacks
///
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
/// no** the app never offers a picker for it, but honours what an author writes). Both degrade
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
/// way to **no stripe at all** because there is no sensible default colour for "the author
/// 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).
///
/// ### One presentation, selection styling only
///
/// 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 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
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
let drops: BoardDropContext
let openCard: (ItemID) -> Void
/// The app-wide quick-style recents see `LaneView`'s own note.
@Environment(AppModel.self) private var appModel
/// 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
/// draw this same plate for the create handoff to read as one arrival.
private var cornerRadius: CGFloat { CardFaceMetrics.cornerRadius }
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities, settled in the pathfinder's
/// treatment shootout).
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
var body: some View {
titleRow
.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
// colouring a card never shifts its title relative to its uncoloured neighbours.
.padding(.leading, stripeWidth)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
// highlights while hovered" (04-interactions.md Drag and drop), and the accent stroke is
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
// hovered card that is *also* selected still reads as the target.
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: isFileHovered ? 2.5 : 1.5
)
)
// The deferred cut's dim (04-interactions.md Clipboard: "cut items dim in place until paste
// moves them"). Above `contentShape` so the face stays fully clickable while it waits.
.cutTreatment(of: card.id, in: store)
.contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
// Board Rename. The modifier grammar plain replaces, toggles, ranges is
// `SelectionGrammar`'s, reached through the store's one funnel.
.onTapGesture {
store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current)
}
// "A fast double-click opens the card window ('s pointer twin)" (04 Selection).
//
// `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
// second tap recogniser on the same view makes the single click *wait* to see whether a
// second one arrives, and selection must stay instant. Simultaneous means the first click
// of the pair selects and the second opens Finder's own behaviour.
//
// **Plain only.** and double-clicks are selection gestures that happened twice; opening
// a window out from under a range the user is still building would be a surprise.
.simultaneousGesture(TapGesture(count: 2).onEnded {
guard ClickModifier.current == .plain else { return }
openCard(card.id)
})
// **The whole face is the drag surface** (04-interactions.md Drag and drop). `.onDrag`
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
// the session off until the pointer really moves, so selecting and opening stay instant.
.onDrag(startCardDrag, preview: { dragReplica })
// The card's height, for the drop model's analytic resting grid. A height is content-driven
// and does not animate under the reflow only positions do, and those are never measured
// (`LaneDropRegistry`).
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
drops.registry.update(height: height, for: card.id)
}
.onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry)
.contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
}
// MARK: - The card drag
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order**
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
private func startCardDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let snapshot = store.snapshot
let selection = store.selection
let ids: Set<ItemID> = selection.container == .board
&& selection.ids.contains(card.id)
&& selection.ids.count > 1
? selection.ids
: [card.id]
// Flatten order, and the lane each member currently lives in the folder path's middle
// component.
var lanesByCard: [ItemID: ItemID] = [:]
var titles: [ItemID: String] = [:]
for lane in snapshot.lanes {
for member in lane.cards where ids.contains(member.id) {
lanesByCard[member.id] = lane.id
titles[member.id] = member.title.value
}
}
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
guard !ordered.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
container: .board,
items: ordered.compactMap { id in
guard let laneID = lanesByCard[id] else { return nil }
return DragPayload.Item(
id: id.rawValue,
folder: root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(id.rawValue, isDirectory: true)
.path,
title: titles[id]
)
}
)
drops.session.beginCards(
ordered,
folders: payload.folders,
// The dragged items' sizes, frozen at drag start the pickup transition scales the
// replica, and its lingering "last measured frame" would mis-size the shadow and the
// span-cap (03-board-ui.md § Motion).
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
container: .board,
source: store
)
return payload.itemProvider()
}
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
private var dragReplica: some View {
let count = store.selection.container == .board && store.selection.ids.contains(card.id)
? max(1, store.selection.ids.count)
: 1
return ZStack {
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
replicaFace
}
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(12)
}
/// A static rendition of the face a drag image is a snapshot, so it carries no gestures, no
/// editor and no geometry observers.
private var replicaFace: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
Text(card.title.value ?? "Untitled")
.font(.body)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
.padding(10)
.padding(.leading, stripeWidth)
.frame(width: 220, alignment: .leading)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
}
// MARK: - Context menu
/// Open, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md Context
/// menus' Card row, in its order, complete as of m5.
@ViewBuilder
private var cardMenu: some View {
// Open: Board Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
// alone "a card window is tied to one card" (11-command-nexus.md), so unlike Style and
// Delete below it, this row never widens to the selection; Open never opens multiple, even
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
// first only the plain open.
Button("Open") {
openCard(card.id)
}
Divider()
// Rename: Board Rename's exact store path (`BoardRenameCommand`) `beginRename(of:
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
// this card to be the *sole* selection; a context menu already names its target by where it
// was invoked, so standard macOS practice it acts on the clicked card outright.
Button("Rename") {
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
}
.disabled(!store.acceptsBoardMutations)
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
Divider()
// Delete: File Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
// widened target set below (`targetIDs`) the successor-selection rule is `delete(_:)`'s own,
// so this row gets it for free.
Button("Delete") {
store.delete(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
}
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
/// alone standard macOS context-menu targeting, shared by Style (`styleTarget`) and Delete
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
return .items([card.id])
}
return .items(store.selection.ids)
}
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
/// on the live side).
private var targetIDs: Set<ItemID> {
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
return [card.id]
}
return store.selection.ids
}
// MARK: - Title row
private var titleRow: some View {
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
titleOrEditor
// The title takes the row's width so the indicator sits hard against the trailing
// edge and so the rename field fills the same span the title occupied.
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
}
/// The title, or the rename editor when this card is the rename target. Unchanged from the
/// stub this face replaces: the four exits and their store calls are 04-interactions.md
/// Grammar's, stated once in `InlineTitleField`.
@ViewBuilder
private var titleOrEditor: some View {
if isRenaming {
InlineTitleField(
text: draft,
prompt: "Card title",
onCommit: { store.commitRename() },
onAbandon: { store.transient.discardRename() },
// **Click-away commits** a rename's rule, and the deliberate opposite of the
// placeholder's (04-interactions.md Grammar: "focus loss = commit, matching
// the card window's title field").
onFocusLoss: { store.commitRename() },
onCommitAndOpen: {
let id = card.id
store.commitRename()
openCard(id)
}
)
.font(.body)
} else {
Text(card.title.value ?? "Untitled")
.font(.body)
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
.lineLimit(4)
}
}
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary the tint
/// exists to make a *hand-coloured* icon stand out from its neighbours.
private var iconTint: AnyShapeStyle {
if let color = Palette.color(for: card.iconColor) {
AnyShapeStyle(color)
} else {
AnyShapeStyle(.secondary)
}
}
/// The one face chip in scope shown only when the card actually has files, and quiet enough
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
/// accessibility label rather than onto the face: it is useful to know, not to look at.
@ViewBuilder
private var attachmentsIndicator: some View {
if !card.attachments.isEmpty {
Image(systemName: "paperclip")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("\(card.attachments.count) attachments")
}
}
/// K1 · left edge stripe, painted with the resolved `background` "a card's [colour paints] a
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
/// title text never sits on a coloured fill" (03-board-ui.md § Styling Capabilities).
///
/// 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 a title wrapping across its full four lines included.
@ViewBuilder
private var accentStripe: some View {
if let color = Palette.color(for: card.background) {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
.fill(color)
.frame(width: stripeWidth)
// Decoration only: the whole plate is one click target for selection.
.allowsHitTesting(false)
}
}
// MARK: - Selection and rename plumbing
private var isSelected: Bool {
store.selection.container == .board && store.selection.ids.contains(card.id)
}
/// Whether an external Finder file drag is hovering **this** card the attach highlight
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
/// to one implementation (`BoardDrops`).
private var isFileHovered: Bool {
drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
}
private var isRenaming: Bool {
store.transient.renameEditor?.targetID == card.id
}
private var draft: Binding<String> {
Binding(
get: { store.transient.renameEditor?.draftTitle ?? "" },
set: { store.transient.updateRenameDraft($0) }
)
}
}
// MARK: - The new-card placeholder
/// The card being created, drawn as a pseudo-card in the masonry flow at standard card width
@@ -1371,7 +954,7 @@ private struct NewCardStubView: View {
/// and then the field disappears, which also fires the focus-loss handler an instant later. The
/// store's `commitRename`/`commitPlaceholder` and the transient state's `discard` all no-op against
/// an editor that is already closed, so the overlap costs nothing.
private struct InlineTitleField: View {
struct InlineTitleField: View {
@Binding var text: String
let prompt: String