import AppKit import SwiftUI // MARK: - Which side of the board a face is on /// **The one axis a card face has** — which container it is drawn in, and the collaborator that /// container's grammar needs. /// /// 03-board-ui.md § Trash, resettled 2026-07-28: "A trashed card is an ordinary card in a special /// place — search, selection, rendering, styling, and clipboard all treat it exactly like any other /// card". So there is **one** card face in this app, and this is the whole of what differs between /// its two homes. Everything the two sides share — the plate, the stripe, the icon tint, the /// attachments chip, the selection treatment, the cut dim, the marquee registration, the drag — is /// shared by construction rather than by two views agreeing. /// /// The three differences are all *absences on the trash side*, and each is 04-interactions.md ▸ The /// trash's "everything edit-shaped is disabled on trash selections" showing up as a branch that is /// simply not taken: /// /// - **no Open** — no double-click gesture at all ("trash cards don't open — double-click stops at /// selection"), which is also why `openCard` is the board case's payload rather than the view's; /// - **no Rename** — the inline editor is board-only (`isRenaming`), so a rename that somehow /// targeted a trashed card would render nothing rather than open a field over it; /// - **no Style…** — no popover anchor, and no Style rows in the context menu. /// /// Plus the two that are not about editing: Finder file drops are inert over the trash (▸ The trash), /// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so /// it needs the window's confirmation host (11-command-nexus.md ▸ Context menus' Trash cards row). /// /// **A fourth board-only row joined 2026-08-09**: Copy Link (design ruling, card 737a949f) — not one /// of the three edit-shaped absences above (it changes nothing on disk), but "sole selected **live** /// card" is its own words for the same board-only scope, so it lives in `boardMenu` only and has no /// trash-side counterpart, absence or otherwise. /// /// **Only the trash side carries the confirmation host** (settled): the board side's Delete is the /// ordinary staged move into `.trash/` and never stands an alert, so `board` needs nothing beyond the /// card opener. /// `Sendable` because `CardFaceView.==` is nonisolated and a nonisolated context may only read a /// main-actor `let` of Sendable type — which is why `openCard` is typed `@MainActor` (an isolated /// function type is Sendable; a bare one is not, and would sink the whole enum). enum CardFaceRole: Sendable { /// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin /// (04-interactions.md ▸ Selection). case board(openCard: @MainActor (ItemID) -> Void) /// A card in `/.trash/`. Carries the window's purge-alert host, because the trash's Delete /// is the permanent one and "confirms exactly where the loss is real" (03 § Trash). case trash(confirmations: TrashConfirmations) /// Which container a click on this face selects in, which container its drag begins in, and which /// container the rubber band sweeps it as — one answer, so the three can never disagree /// (`SelectionGrammar`: "the container is the surface's, not the item's"). var container: ItemContainer { switch self { case .board: .board case .trash: .trash } } /// Whether two roles put the face in the same home with the same collaborator — the comparison /// `CardFaceView.==` makes, and the reason this enum is not simply `Equatable`. /// /// **The board case's `openCard` is deliberately not compared.** It is a closure the strip /// rebuilds on every body pass, so comparing it is impossible and ignoring it is correct: it is a /// pure hand-off to the host's `WindowGroup` key, identical in behaviour whatever closure object /// carries it, and a face that changed *which window it opens into* would be a face in a /// different window and therefore a different view identity entirely. /// /// The trash case's `confirmations` **is** compared, by identity: it is window-lived state /// (`@State` in `BoardWindowHost`), so identity is both cheap and meaningful, and it is the one /// collaborator a role carries that the face actually reads state off. nonisolated func isEquivalent(to other: CardFaceRole) -> Bool { switch (self, other) { case (.board, .board): true case let (.trash(lhs), .trash(rhs)): lhs === rhs default: false } } } // MARK: - Card face /// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled" /// placeholder), two quiet trailing chips — attachments and comments, each present-only — and a /// left-edge colour accent stripe (03-board-ui.md § Card face, § Styling ▸ Capabilities). The /// comments chip joined 2026-08-09 (design ruling, card e729e30a). /// /// ### One face, two containers /// /// **This is the trash's row too** (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized /// trash): "a trashed card is an ordinary card in a special place … rendering … treat it exactly like /// any other card". The tombstone era's compact dimmed plate is retired with the tombstones it drew; /// a trashed card wears its style, its stripe, its icon tint and its attachments chip exactly as it /// did in its lane, because it is the same card and the same view. What the trash takes away is /// listed on `CardFaceRole` and nowhere else. /// /// ### Title-only, deliberately /// /// **No body excerpt** — settled, and still true after 2026-08-09's growth (the hero banner, the /// comments chip): the face never draws a preview of the card's own prose, and never will — what /// grew is exposure of facts the card already carries structurally (an attachment named as hero, a /// folder count), not content. Two face chips are in scope: **attachments**, "a quiet indicator when /// the card has files — the title dominates", and **comments**, its same-vocabulary twin — a /// secondary-tinted glyph (plus a count, for comments — see `commentsIndicator`) rather than a count /// pill, so the eye still lands on the title first. /// /// ### 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). /// /// ### Equality gate /// /// The face is `Equatable` and instantiated through `.equatable()` (`LaneView.scrollableCards`, /// `TrashLaneView.scrollableCards`), because its parent re-runs for reasons that have nothing to do /// with any one card: a lane's body re-evaluates on **every drop-proposal change** while a drag is /// in flight, and without a gate that rebuilds every face in every lane on every cursor move. See /// the `==` below for what the gate covers and what it deliberately does not. struct CardFaceView: View, Equatable { let store: BoardStore let card: Card /// Which side of the board this face is drawn on, and what that side's grammar needs — the view's /// one axis (`CardFaceRole`). let role: CardFaceRole /// 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 /// **This card's hero image, resolved** — the file its `hero` key names inside its own /// `attachments/`, or `nil` for the overwhelmingly common card that names none /// (03-board-ui.md § Card face ▸ Hero image; `CardHero.imageURL(for:inContainer:)`). /// /// A parameter rather than a resolution done here, and for `isSelected`'s kind of reason one axis /// over: resolving it needs the card's *folder*, which this view does not know — it knows its card /// and its container, and finding the path from the snapshot would be a board walk per face. The /// two parents each know their own container folder and compute it once for the whole strip. /// /// Whether the file exists, decodes, or is an image at all is deliberately not asked here: this is /// a URL, and a name that leads nowhere draws no band (`CardHeroImage`), which is the ruling's /// "renders exactly as with no key" kept structurally. let hero: URL? /// Whether this card is in the selection **of its own container** — a parameter rather than a /// read off the store, and that is the whole of the fix RENDER-INSTRUMENTATION.md ▸ "Selection is /// O(board) in card bodies" asked for. /// /// Observation tracks whole properties, so a body that reads `store.selection` is subscribed to /// *every* selection change on the board — 180 faces re-running per marquee sample on the 6×30 /// fixture, 515 on a real board, and `.equatable()` powerless over any of it because a direct /// Observation invalidation never consults the gate. Taking selected-ness as a compared input /// instead moves the subscription up one level, to the parent that already holds it: the lane's /// body reads the selection once for its own header, hands each face the answer, and the gate /// below then re-runs exactly the faces whose flag actually flipped. let isSelected: Bool /// The size of the selection this face belongs to — **1 when unselected**, which the parent /// computes and normalizes so the drag replica's fan and count badge need no store read of their /// own (`dragReplica`). Deliberately not `Int?`: "how many ride along" has an answer for every /// face, and one is it. let selectedCount: Int /// Whether this face's own lane has a live neighbour one step to the **left** — Navigation ▸ Move /// Left's render-safe enablement input (2026-08-09 ▸ "Give the card context menu's Navigation rows /// real card-move behavior", card 06322636), `isSelected`'s exact pattern one row over: the parent /// (`LaneView`) reads `store.snapshot` **once per lane** (`CardMoveTarget.hasNeighbor`) and hands /// every face in it the answer, so a click never costs a per-face board walk. Defaults `false` — /// the trash side never constructs Navigation rows at all, so its faces never need a real answer /// here (`CardFaceRole`). let hasLeftNeighbor: Bool /// `hasLeftNeighbor`'s mirror, for Move Right. let hasRightNeighbor: Bool /// Whether the **live board selection** reaches outside this face's own lane — the "no coherent /// left for a spread" refusal (owner ruling, card 06322636): a selection spanning more than one /// lane disables Navigation for every one of its own selected members, because the widened target /// (`targetIDs`) would name cards `CardMoveTarget.destination` can never place in one direction. /// Only read together with `isSelected` — an unselected face's target is always itself alone, /// trivially confined to its own lane regardless of what this says (`moveLeftEnabled`). /// /// Lane-hoisted like the two neighbour flags above (`CardMoveTarget.selectionSpansOtherLanes`): /// the parent already reads the selection once for `isSelected`/`selectedCount`, so this costs one /// more `Set` comparison over cards it already has in hand, never a second `store.selection` read. let selectionSpansLanes: Bool /// Explicit only for `hasLeftNeighbor`/`hasRightNeighbor`/`selectionSpansLanes` — the three /// Navigation inputs, all defaulted to `false` (every row reads disabled) so the trash side's one /// call site (`TrashLaneView.cardRow`, whose faces never draw Navigation at all) and the pre-move /// equatable-gate tests need no opinion about a feature their construction never exercises. Every /// other parameter stays required — the memberwise init's own shape — because the board side /// (`LaneView.scrollableCards`) always has a real answer for all eight and a silently-defaulted /// `card` or `isSelected` would be the wrong kind of convenience. init( store: BoardStore, card: Card, role: CardFaceRole, marquee: MarqueeControl, drops: BoardDropContext, hero: URL?, isSelected: Bool, selectedCount: Int, hasLeftNeighbor: Bool = false, hasRightNeighbor: Bool = false, selectionSpansLanes: Bool = false ) { self.store = store self.card = card self.role = role self.marquee = marquee self.drops = drops self.hero = hero self.isSelected = isSelected self.selectedCount = selectedCount self.hasLeftNeighbor = hasLeftNeighbor self.hasRightNeighbor = hasRightNeighbor self.selectionSpansLanes = selectionSpansLanes } /// The app-wide quick-style recents — see `LaneView`'s own note. @Environment(AppModel.self) private var appModel /// Increase Contrast, for the plate's borders below (10-accessibility.md: "Increase Contrast /// strengthens borders and the selection indicator"). Read from the environment and handed to /// `Accommodations`, which owns what "increased" does to a stroke. @Environment(\.colorSchemeContrast) private var contrast /// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). **The environment is what /// makes zoom reach a card face at all**: this view is `.equatable()`, and the gate above compares /// nothing that moves with the level — but it does not compare environment values either, because /// SwiftUI invalidates on those itself. A level threaded any other way would be swallowed here. @Environment(\.boardZoom) private var zoom /// The live body metric — every figure this face lays out on is a multiple of it /// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule). Read here rather than /// passed in, which is `CardAttachmentsSection`'s pattern on the card-window side. private var pointSize: CGFloat { zoom.bodyPointSize } /// 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 `BoardMetrics` 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 { BoardMetrics.cardCornerRadius(bodyPointSize: pointSize) } /// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's /// treatment shootout). private var stripeWidth: CGFloat { BoardMetrics.cardStripeWidth(bodyPointSize: pointSize) } /// This face's drawn width — **the replica's** (`replicaFace`). Measured rather than derived, /// because a face is as wide as the interior masonry column its lane gives it, and that is a /// function of the lane's slot width and its column count rather than of the font /// (`LaneLayoutMath`, `MasonryLayout`). Zero until the first layout, which is what the metric's /// fallback is for. @State private var measuredWidth: CGFloat = 0 /// The whole of what this face is a function of **as far as its parent is concerned**: the card /// value (`Card` is `Equatable` down to its attachment names and its parsed document), which home /// it is drawn in (`CardFaceRole.isEquivalent(to:)`), the hero file the parent resolved for it, /// the two selection figures the parent resolves for it, and the three window-lived collaborators /// — the store by identity, the band and the drop machinery by their own equivalence tests, which /// exist because the strip rebuilds both structs, closures and all, on every body pass. /// /// **The hero is compared as a URL, not as a picture.** It moves only when the card's key or its /// container does, both of which are already `card`-and-role facts; comparing it costs a path /// comparison on a value that is `nil` for almost every card, and the picture behind it is the /// banner view's own state (`CardHeroImage`), which no gate here could see anyway. /// /// **Selection is a compared input now, and that is what makes the gate reach it.** It used to be /// an Observation read — `isSelected` off `store.selection` — which meant a click anywhere on the /// board invalidated every face on it *directly*, past the gate entirely, and the measured cost /// of a marquee sample was the whole board's worth of bodies. Selected-ness now rides down as /// `isSelected`/`selectedCount` on the parent's own subscription (`LaneView` already reads the /// selection for its header; `TrashLaneView` gained one hoisted read to match), so a selection /// change re-runs the lane bodies that were subscribed anyway plus exactly the faces whose flag /// flipped. See RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies. /// /// **What the gate still does not suppress is the point.** What remains of this body's own /// Observation reads — `store.transient.pendingCut` and the rename editor, /// `drops.session.isDragging`, `appModel.styleRecents` — invalidates this view directly, and /// `.equatable()` has no say in that. The gate only stops the *parent* handing a face a /// new-but-identical set of inputs and re-running it for nothing, which during a card drag is /// what every proposal change does to every face in the lane. /// /// `store.searchFilter` is deliberately absent from that list: its only use here is `ownSlotSeed`, /// reached from `startBoardCardDrag`, which runs when a drag begins rather than when the body /// does — an event-time read subscribes nothing. `draggedIDs`, the context menus' `targetIDs` and /// the deferred `styleTarget` read the selection the same way, inside actions, which is why they /// stayed as they were. /// /// **The three Navigation inputs joined 2026-08-09** (card 06322636), `isSelected`'s exact /// reason one more time: `hasLeftNeighbor`/`hasRightNeighbor`/`selectionSpansLanes` are what let /// `moveLeftEnabled`/`moveRightEnabled` answer without a `store.snapshot`/`store.selection` read /// inside this face's own `.disabled` — so a gate that swallowed them would leave a face's /// Navigation rows wearing a stale enabled state after a neighbouring lane folded, a wall lane's /// board-edge shifted, or the live selection grew across lanes, none of which touch `card`, `hero` /// or the other selection figures. /// /// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway), /// `@Environment` values (SwiftUI invalidates on those itself), and the `.board` role's /// `openCard` closure (see `CardFaceRole.isEquivalent(to:)`). nonisolated static func == (lhs: CardFaceView, rhs: CardFaceView) -> Bool { lhs.card == rhs.card && lhs.role.isEquivalent(to: rhs.role) && lhs.hero == rhs.hero && lhs.isSelected == rhs.isSelected && lhs.selectedCount == rhs.selectedCount && lhs.hasLeftNeighbor == rhs.hasLeftNeighbor && lhs.hasRightNeighbor == rhs.hasRightNeighbor && lhs.selectionSpansLanes == rhs.selectionSpansLanes && lhs.store === rhs.store && lhs.marquee.isEquivalent(to: rhs.marquee) && lhs.drops.isEquivalent(to: rhs.drops) } /// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both /// sides share is in `face`; what the board has and the trash does not is attached here, so the /// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by /// gestures that fire and refuse (`CardFaceRole`). @ViewBuilder var body: some View { // The lane's gate, observed (`BoardRenderMetrics`) — DEBUG only, and a `let _` because a // `@ViewBuilder` body takes statements as views and a bare call would be one. #if DEBUG let _ = BoardRenderMetrics.countCardBody() #endif switch role { case let .board(openCard): face // "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) }) .contextMenu { boardMenu(openCard: openCard) } // **The menu's rows, additionally as custom actions** — "where SwiftUI additionally // surfaces menu items as custom accessibility actions, that's free improvement, not // a separate design surface" (10-accessibility.md ▸ Actions come from the context // menu). The menu stays the inventory and stays reachable the standard way (VO-⇧-M). // Style… is absent for `LaneView`'s reason: it opens a popover, and the quick-style // swatch `Picker` beside it is not an action. .accessibilityActions { boardActions(openCard: openCard) } .popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) { StyleEditorPopover(store: store, recents: appModel.styleRecents) } case let .trash(confirmations): face .contextMenu { trashMenu(confirmations: confirmations) } // The trash's two rows and **no third** — "there is no Open" // (10-accessibility.md ▸ Trash lane; 03-board-ui.md's no-editing-in-the-trash). The // absence is structural on this side too: `openCard` is the board case's payload, so // there is nothing here an Open action could even call. .accessibilityActions { trashActions(confirmations: confirmations) } } } /// Everything the two containers share — which, after the pivot, is the face itself. private var face: some View { plateContent .background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate)) .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. // // **Increase Contrast strengthens both the ring and the plate's edge** (10-accessibility.md): // the stroke goes a point heavier, and an *unselected* card — which normally floats on its // fill alone — gains a separator hairline, because "this is one card and that is another" is // exactly the distinction the setting exists to rescue (`Accommodations`). .overlay( RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(plateStroke, lineWidth: plateStrokeWidth) ) // 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. It // genuinely fires on the trash side too: "⌘X works — cut in the trash, paste into a lane is // the keyboard-native restore" (04 ▸ The trash, resettled 2026-07-28). .cutTreatment(of: card.id, in: store) // The face being dragged out dims the same way while the session is in flight, wherever the // source stays visible — which is now two places rather than one. On the **trash** side a // restore is not a removal until the write lands, so the row never leaves. On the **board** // side a drag whose effective operation is `.copy` leaves its originals in the resting // layout, because the copy leaves them there (`DragSession.hiddenMembers`), and this is what // marks them as the source of the drag rather than as ordinary neighbours. A `.move`'s // originals are lifted out entirely and this has nothing to act on // (`LaneView.renderedCards`). .opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1) .contentShape(Rectangle()) // **A card is one flattened accessibility element** (10-accessibility.md ▸ The board through // VoiceOver): "label = title (or the untitled placeholder), value carries the attachment // count when present, selected state via trait. Face icon and chips are decorative — folded // into the element, never separately focusable". So the icon, the accent stripe and the // paperclip contribute nothing of their own — the count they stood for rides the value below. // // `.contain` while a rename is open, `LaneView`'s header rule for its reason: flattening // would swallow the text field the user is typing into. Board-only by construction, since // `isRenaming` is (`CardFaceRole`). .accessibilityElement(children: isRenaming ? .contain : .ignore) .accessibilityLabel(AccessibilityPhrases.cardLabel(title: card.title.value)) // The attachment count, the comment count, the deferred cut's "cut, pending paste", or any // mix of the three — and the empty string when none apply, which speaks as nothing (see // `AccessibilityPhrases.cardValue` for why it is not a conditional modifier). .accessibilityValue(AccessibilityPhrases.cardValue( attachments: card.attachments.count, comments: card.commentCount, isCutPending: store.transient.pendingCut.ids.contains(card.id) )) // "Selection state is always readable from the element (trait)" — the other half of "state // is never colour-alone", whose visible half is the accent stroke above. .accessibilityAddTraits(isSelected ? [.isSelected] : []) // **VO-Space toggles this card's selection** — "moving the VoiceOver cursor never mutates // selection. VO-Space on a card toggles its selection (the ⌘-click analogue — a toggle, // never plain click's replace)". Routed through the same `BoardStore.click` funnel the // pointer uses, with the ⌘ modifier, so the homogeneity rule and the container boundary are // `SelectionGrammar`'s single answer rather than a second one written here. .accessibilityAction { toggleSelection() } // **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. // // **The container travels with the click**, and that is what keeps the one remaining // homogeneity boundary true: a ⌘-click across it replaces rather than mixing // (04-interactions.md ▸ The trash). A double click in the trash is two of these and nothing // more — no editor, no card window, no timer. .onTapGesture { store.click(SelectionTarget(id: card.id, kind: .card, container: role.container), modifier: .current) } // **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(startDrag, preview: { dragReplica }) // The card's drawn size. Its **height** goes to 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`). The trash side registers too: a trash card // dragged out is an ordinary card session, and the shadow it opens in the destination lane // should be its real footprint rather than the nominal guess. // // Its **width** stays here, for the drag replica: a face is as wide as its lane's interior // column, which no metric can derive and only the laid-out face can report // (`BoardMetrics.cardReplicaWidth(measured:bodyPointSize:)`). .onGeometryChange(for: CGSize.self) { $0.size } action: { size in drops.registry.update(height: size.height, for: card.id) measuredWidth = size.width } .onDisappear { drops.registry.removeHeight(card.id) } .marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry) } /// What sits on the plate: the hero band, then the padded title row (03-board-ui.md § Card face). /// /// **The band is full-bleed and the title row is not**, which is the whole layout call. A hero is /// a picture of what the card is about, so it takes the plate's own width and rounds its top /// corners to the plate's radius (`CardHeroImage`); the row below keeps every inset it has always /// had, including the stripe's reserved leading padding, so a card's title sits on exactly the /// grid it sat on before whether or not the card has a hero. /// /// **Zero spacing, and a band with no height when there is no picture** — so a card with no /// `hero`, or one whose hero names a file that is missing or unreadable, lays out identically to /// the face as it was: the stack's first element contributes nothing at all. /// /// Everything else the face draws is attached *outside* this stack and is therefore untouched by /// the band: the accent stripe still runs the plate's full leading edge (over the band's leading /// corner — the stripe is the card's edge, and a picture does not interrupt it), the selection and /// file-hover strokes still ring the whole plate, the cut and drag dims still cover it, and the /// geometry the drop model registers is still the plate's — a hero card is simply a taller card, /// which the masonry already understands. private var plateContent: some View { VStack(alignment: .leading, spacing: 0) { heroBanner titleRow .frame(maxWidth: .infinity, alignment: .leading) .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) // 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) } } /// The hero band, for a card that names a readable one — and nothing whatever for a card that /// does not, which is the ruling's degrade stated as a branch that is simply not taken. @ViewBuilder private var heroBanner: some View { if let hero { CardHeroImage( url: hero, height: BoardMetrics.cardHeroHeight(bodyPointSize: pointSize), cornerRadius: cornerRadius ) } } // MARK: - The card drag /// Begins this card's system drag session — in **its own container**, which is the whole of what /// makes a trash card's drag a restore (04-interactions.md ▸ The trash; `DragLocality.operation`). private func startDrag() -> NSItemProvider { switch role.container { case .board: startBoardCardDrag() case .trash: startTrashCardDrag() } } /// A lane card's drag (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 startBoardCardDrag() -> NSItemProvider { guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } let snapshot = store.snapshot let ids = draggedIDs // 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] ?? drops.registry.nominalCardHeight }, container: .board, source: store, seed: ownSlotSeed(dragging: ids, laneID: lanesByCard[card.id], in: snapshot) ) return payload.itemProvider() } /// The pickup's own-slot proposal (`DragSession.begin`): the grabbed card's position among its /// lane's rendered cards with the dragged run lifted out — the same resting-layout index space /// the retargets and `moveCards` count in, filtered by the same `LaneView.rendered` rules, so /// the seeded shadow run stands exactly where the lifted cards stood and pickup moves nothing. /// /// **A ⌥-pickup seeds nothing.** A copy's resting layout keeps the originals in place, so there /// is no vacated space for a shadow to hold; the first `dropUpdated` resolves the copy and opens /// the run beside the originals, exactly as it does today (DRAG-REORDER.md § Resting-layout /// zones — the modifier reflow is the feedback being asked for). private func ownSlotSeed( dragging ids: Set, laneID: ItemID?, in snapshot: BoardModel ) -> DropTarget? { guard !NSEvent.modifierFlags.contains(.option), let laneID, let laneCards = snapshot.lanes.first(where: { $0.id == laneID })?.cards else { return nil } let index = LaneView.rendered( Array(laneCards.prefix { $0.id != card.id }), hiddenByDrag: ids, filter: store.searchFilter, renaming: store.transient.renameEditor?.targetID ).count return DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: index) } /// A trash card's drag out — **the restore**, and deliberately not special: an ordinary `.cards` /// session in the `.trash` container, which `BoardDropContext.commitDrop` hands to the same /// `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an ordinary move /// out … there is no restore-specific machinery and no Put Back" (03 § Trash). /// /// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the /// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic /// every other card drop uses, committed by the same `moveCards`. /// /// **Multi-drag carries the whole trash selection**, in the column's own order — the order the /// rows are drawn in, which since 2026-07-31 is `modified` descending (03 § Trash). /// /// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸ /// The trash, ruled 2026-07-31): the lane rows in it cannot ride a `.cards` session, so rather /// than let them fall silently out of the payload the flag travels and the *drop* refuses with /// the notice (`DragSession.mixesKinds`). Pickup stays allowed — the selection is legal. private func startTrashCardDrag() -> NSItemProvider { guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } let ids = draggedIDs let rows = store.snapshot.trash.filter { ids.contains($0.id) } guard !rows.isEmpty else { return NSItemProvider() } let mixesKinds = SelectionGrammar.mixesKinds( ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot) let root = store.rootURL let payload = DragPayload( boardRoot: root, kind: .cards, container: .trash, items: rows.map { DragPayload.Item( id: $0.id.rawValue, folder: ItemPath.trashCard($0.id).folder(under: root).path, title: $0.title.value ) } ) drops.session.beginCards( rows.map(\.id), folders: payload.folders, heights: rows.map { drops.registry.heights[$0.id] ?? drops.registry.nominalCardHeight }, container: .trash, source: store, mixesKinds: mixesKinds ) return payload.itemProvider() } /// What travels: the whole selection when this card is in it, else this card alone — the drag's /// half of the context-menu targeting rule, and container-scoped like everything else. private var draggedIDs: Set { let selection = store.selection guard selection.container == role.container, selection.ids.contains(card.id), selection.ids.count > 1 else { return [card.id] } return selection.ids } /// 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). /// /// The count is `selectedCount`, not a fresh read of the selection: `.onDrag(_:preview:)`'s /// preview builder is **non-escaping**, so it evaluates while the body does, and a `store.selection` /// read here would have kept every face on the board subscribed no matter what the rest of this /// view did. The parent already normalizes the figure to 1 for an unselected face, so the branch /// that used to compute it is gone rather than moved (see `isSelected`'s note); the `max` is belt /// over those braces, because a zero here would draw a badge reading nothing. private var dragReplica: some View { let count = max(1, selectedCount) 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(BoardMetrics.replicaPadding(bodyPointSize: pointSize)) } /// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no /// editor and no geometry observers, and crucially no marquee registration (one built out of the /// live face would re-register the card's frame from inside the preview's geometry and then /// deregister it when the image went away, quietly stealing the card from the rubber band and the /// arrow keys). private var replicaFace: some View { VStack(alignment: .leading, spacing: 0) { // **The band is drawn from the cache alone, synchronously.** A preview builder is // non-escaping — it runs while the body does, and the system snapshots the result // immediately — so there is no task to await a decode in. A hero the face is already // showing is in the cache by definition, which is the only case that matters: you cannot // drag a card whose banner has not drawn yet without having looked at it first. A miss // draws no band, and the replica is then exactly the face a hero-less card lifts. if let hero, let image = CardHeroCache.image(forFileAt: hero) { Color.clear .frame(height: BoardMetrics.cardHeroHeight(bodyPointSize: pointSize)) .overlay { Image(decorative: image, scale: 1).resizable().aspectRatio(contentMode: .fill) } .clipShape(UnevenRoundedRectangle( topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)) } HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) { Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card)) .foregroundStyle(iconTint) .imageScale(.medium) Text(card.title.value ?? "Untitled") .boardFont(.body) .lineLimit(4) .frame(maxWidth: .infinity, alignment: .leading) attachmentsIndicator commentsIndicator } .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) .padding(.leading, stripeWidth) } // **The size of the face it was lifted from**, taken from that face's own measurement rather // than from a representative figure: a card is as wide as its lane's interior column, so a // replica drawn at a nominal width is visibly a different card from the one under the cursor, // and — since the system centres a preview on the view the drag started from — leaves the // pointer sitting beside the image instead of on it. The width is the only frame this needs: // the replica lays the same band and the same row out with the same paddings and the same // `lineLimit`, so at the face's width it comes out at the face's height (`BoardMetrics`). .frame( width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize), alignment: .leading ) .background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate)) .overlay(alignment: .leading) { accentStripe } .dragReplicaShadow(zoom: zoom) } // MARK: - Context menus /// **The card context menu, redesigned** (2026-08-09 ▸ "redesign context menu for cards", card /// fe66c461): four groups, a divider between each, the owner's shape verbatim — /// /// 1. Open, Copy Link, Rename, Style ▸ (Symbol, Color) /// 2. Copy, Cut, Paste, Paste Special ▸ (Paste Image into Card, … more tbd) /// 3. Navigation ▸ (Move Left, Move Right) /// 4. Send to Trash /// /// Every row below routes through an *existing* write primitive it twins — nothing here invents a /// new way to touch disk, only new arrangements and, for one group, a new targeting rule over ones /// the app already has (`OpenCardCommand`, `BoardRenameCommand`, `StyleCommand`/`StyleEditorSession`, /// `ClipboardStore`, `store.delete`/`TrashCommands`). **Navigation is the one exception to "new /// arrangement, not new capability"**: this group shipped structurally in fe66c461 (wired but /// unconditionally disabled — no per-card cross-lane move predicate existed yet) and gained the /// real one in 06322636, `CardMoveTarget`, over the same `BoardStore.moveCards(_:toLane:at:)` a /// released drag already calls — see that section below for the full story. /// /// ### Two deviations from the card's literal list, both kept and both journaled /// /// **Copy Link stays.** The owner's list does not mention it, but it shipped the same day this /// card was filed (design ruling 2026-08-09, card 737a949f) and nothing here was asked to retire /// it — dropping a same-day feature would be a behavior change, not a structure one. It rides in /// the first group, immediately after Open (its original neighbor), ahead of Rename. /// /// **The row reads "Send to Trash", not "Delete".** The owner's own word for group 4, and the more /// accurate one for what this button does on the board side — it is the staged move into /// `/.trash/`, not a permanent delete (that word is reserved for the trash lane's own menu, /// `trashMenu` below, which this rename does not touch). The action underneath is unchanged: /// `store.delete(targetIDs)`, `TrashCommands`'s own twin. /// /// ### Style ▸ Symbol / Color — one popover, not two (v1, flagged for owner review) /// /// `StyleEditorSession` carries only a `StyleTarget`, no notion of "arrived here for the symbol /// section" versus "the color section" (`StyleModel.swift`), and adding one would mean teaching /// `StyleEditorView` to pre-focus a section — internals this card is explicitly told not to touch /// (another agent is concurrently redesigning the pickers themselves). So both rows open the same /// existing popover, showing both sections, exactly as the single "Style…" row always has. This is /// the SCOPE's own named fallback ("if it doesn't [support pre-focus], opening the existing /// popover for both rows is acceptable v1") — kept, and flagged: a future pass that adds /// section-focus to the editor should aim these two rows at it. /// /// **The quick-style recents row is gone from this menu.** `StyleMenuItems` used to bundle /// "Style…" with a horizontal recents `Picker` beside it; the owner's list names only Symbol and /// Color under Style, so the recents row — never mentioned — is dropped here to match the shape /// exactly. It is untouched on the **lane** menu (`LaneView.laneMenu`, out of this card's scope, /// which only asked for the *card* menu). Flagged for owner review: easy to bring back as a third /// row under Style if the drop was not intended. /// /// ### Navigation ▸ Move Left / Move Right — a real per-card cross-lane move (2026-08-09, card /// 06322636) /// /// **Not `LaneMoveTarget.destination`** — that predicate reads the **board's live selection** and /// answers `nil` for any card id unconditionally ("a card id is in no lane order" — its own doc /// comment), because it is Board ▸ Move Left/Right's *lane*-selection predicate, shared with the /// menu-bar row. These two rows validate against `CardMoveTarget.destination` instead — the /// cousin built for exactly this card, over this face's own `targetIDs` (Copy/Cut's widening: /// the clicked card, or the live selection when the clicked card is a member of it) rather than /// the live selection's lane membership. /// /// **Destination is the adjacent live lane**, skipping nothing (owner ruling): the trash is never /// a candidate (it is not a `Lane`) and a collapsed lane is a perfectly good landing — a fold /// hides cards, it does not close the lane. **Position is index-preserving** — the clicked card's /// own index in its lane's display order, carried over to the destination and clamped to its card /// count, with the rest of a widened group riding along in their existing relative order /// (`CardMoveTarget`'s own type comment has the full reasoning). The write is the ordinary /// `BoardStore.moveCards(_:toLane:at:)` — the exact call a released drag makes — so rank-minting, /// the undo step, the watcher echo and the banner all come free from that one path. /// /// **Enablement stays render-safe the same way every other row here does.** `CardMoveTarget` /// needs `store.snapshot` (for the neighbouring lane) and, when the clicked card is itself /// selected, `store.selection` (for the multi-lane-spread refusal) — both of which are forbidden /// inside this face's own `.disabled` (this struct's top-of-file note; `.contextMenu`'s builder is /// not lazy). So neither is read here: `hasLeftNeighbor`/`hasRightNeighbor` and /// `selectionSpansLanes` are lane-hoisted compared parameters, `isSelected`'s exact pattern — /// `LaneView.scrollableCards` reads `store.snapshot`/`store.selection` **once per lane** and hands /// every face in it the answer, so a click costs `O(lane count)`, never `O(board cards)` /// (`moveLeftEnabled`/`moveRightEnabled`; `BoardRenderPerformanceTests` stays green throughout). /// /// **A selection spanning more than one lane disables both rows** (owner ruling: "no coherent /// left for a spread") — `selectionSpansLanes`, consulted only when `isSelected` is true, since an /// unselected clicked card's own target is always itself alone. /// /// ### Copy / Cut / Paste / Paste Special — targeting /// /// **Copy and Cut widen exactly as Delete and Style do** — `clipboardTarget`, `targetIDs`'s own /// `ItemReferenceSet` wrapper — "right-clicking something outside the selection acts on what was /// clicked" (`targetIDs`'s doc comment), extended to the clipboard for the first time /// (`ClipboardStore.copy(from:targeting:)`/`cut(from:targeting:)`). Multi-selection semantics are /// `ClipboardStore`'s own, unchanged: a multi-card selection copies/cuts every member, in flatten /// order, exactly as ⌘C/⌘X already do. /// /// **Paste does not retarget to the clicked card.** Unlike Copy/Cut/Delete/Style, Paste has no /// per-item widening precedent anywhere in this codebase — it is a *destination* operation /// (`PasteTarget`), not an item operation, and its target has always been "wherever the live /// selection anchors" (`PasteTarget.cards`), the same rule ⌘V and Edit ▸ Paste already use. This /// row calls that exact rule (`ClipboardStore.paste(into:)`) rather than inventing a /// click-anchored variant. Flagged for owner review if a click-anchored paste ("paste after the /// card I right-clicked, regardless of the live selection") turns out to be what was wanted. /// /// **Paste Image into Card is genuinely per-card** — "into card" is the row's own wording, and it /// is `ClipboardStore.pasteImage(intoCard:in:)`, the *same* method the card window's own /// attachments-header affordance calls for its picture (the ⌘V image branch it once rode retired /// 2026-08-09 for that visible control). It always targets **this** card, never the widened /// selection: like Open, Rename and Style's anchor, "single-card by nature" — the existing /// precedent this SCOPE asks new rows to follow for such rows. "… more tbd" is left as the SCOPE /// asks: one row today, the submenu built to grow. /// /// ### Render-safety: what `.disabled` is allowed to read here /// /// Every `.disabled` in this menu reads only plain, rarely-changing `BoardStore` flags /// (`acceptsBoardMutations`, `isReadOnly`, `isEditingInline`) or the clipboard's own observable /// state (`appModel.clipboard.payload`/`imagePayload`, which this file's own top note already /// accepts reading directly, `appModel.styleRecents`' own precedent) — **never** `store.selection` /// or `store.snapshot` directly, because `.contextMenu`'s builder is not lazy and either read would /// re-subscribe every card face's body to board-wide state, the O(board) regression this file was /// rebuilt to shed. Two rows lean on a proof rather than a literal call to make that hold: /// /// - **Paste**: `PasteTarget.cards`'s only `nil` case is a board with no *renderable* lane /// (`NewCardTarget.resolve`'s "a board whose every lane is folded has no target at all"). A card /// face exists only because its own lane rendered it, so that case is already false by the time /// this menu can even open — `canPaste(into:)` reduces to `acceptsBoardMutations && payload != /// nil` here, with no selection read needed (`pasteEnabled`). /// - **Paste Image into Card**: `canPasteImage(intoCard:in:)`'s snapshot lookup /// (`BoardStore.boardItem`) only ever answers `false` for a card that is not a live board item — /// which, again, this very face rendering already rules out. Reduces to `!isReadOnly && /// imagePayload != nil` (`pasteImageEnabled`). /// /// Copy and Cut lean on the same style of proof: `targetIDs` is always non-empty (at minimum this /// card alone) and always homogeneous cards on the board side (`SelectionGrammar.mixesKinds` /// answers `true` only for the trash — see its own doc comment), so `canCopy`/`canCut`'s /// kind-checks are always satisfied and the rows reduce to `isEditingInline`/`acceptsBoardMutations` /// (`copyEnabled`/`store.acceptsBoardMutations`) without reading the target at all for `.disabled` /// — the target is still read, correctly, inside each action (`clipboardTarget`). /// /// **Navigation ▸ Move Left / Move Right take the third route**: unlike Paste and Copy/Cut, there /// is no algebraic reduction that makes `CardMoveTarget.destination` free of `store.snapshot`/ /// `store.selection` — a neighbouring lane and a cross-lane selection spread are both genuinely /// board-wide facts. So the parent resolves them instead, **once per lane**, and hands this face /// the answer as three more compared parameters (`hasLeftNeighbor`, `hasRightNeighbor`, /// `selectionSpansLanes`) — `isSelected`/`selectedCount`'s own precedent, not a new one /// (`moveLeftEnabled`/`moveRightEnabled`). @ViewBuilder private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View { // Group 1 — Open, Copy Link, Rename, Style ▸ (Symbol, Color). One run, no divider inside it: // the owner's "open/rename+style" group is one group, not two (unlike the prior menu, which // split Open/Copy Link from Rename/Style with a divider). Button("Open") { openCard(card.id) } Button("Copy Link") { copyLink() } .disabled(!copyLinkEnabled) Button("Rename") { beginRename() } .disabled(!store.acceptsBoardMutations) Menu("Style") { // Both rows open the identical popover — see the type comment's "one popover, not two". Button("Symbol") { store.transient.beginStyleEditor(for: styleTarget) } .disabled(!store.acceptsBoardMutations) Button("Color") { store.transient.beginStyleEditor(for: styleTarget) } .disabled(!store.acceptsBoardMutations) } // Both rows share this exact condition, so the submenu itself greys out with them rather than // opening to reveal two disabled rows. .disabled(!store.acceptsBoardMutations) Divider() // Group 2 — Copy, Cut, Paste, Paste Special ▸ Paste Image into Card. Button("Copy") { appModel.clipboard.copy(from: store, targeting: clipboardTarget) } .disabled(!copyEnabled) Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) } .disabled(!store.acceptsBoardMutations) Button("Paste") { appModel.clipboard.paste(into: store) } .disabled(!pasteEnabled) Menu("Paste Special") { Button("Paste Image into Card") { appModel.clipboard.pasteImage(intoCard: card.id, in: store) } .disabled(!pasteImageEnabled) } Divider() // Group 3 — Navigation ▸ Move Left / Move Right, a real per-card cross-lane move — see the // type comment's own section for the destination/position rules and the render-safety proof. Menu("Navigation") { Button("Move Left") { moveCardAcrossLane(by: -1) } .disabled(!moveLeftEnabled) Button("Move Right") { moveCardAcrossLane(by: 1) } .disabled(!moveRightEnabled) } // The submenu greys out only when *both* rows would — a card in the leftmost lane can still // move right, so the submenu itself has to stay open to it. .disabled(!moveLeftEnabled && !moveRightEnabled) Divider() // Group 4 — Send to Trash: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s // twin), on the widened target set (`targetIDs`) — the successor-selection rule is // `delete(_:)`'s own, so this row gets it for free. Relabeled from "Delete" — see the type // comment. Button("Send to Trash") { deleteTargets() } .disabled(!store.acceptsBoardMutations) } /// `boardMenu`'s plain (non-submenu) rows as VoiceOver custom actions — every one calling the /// *same* private method its menu row does, so the two surfaces cannot come to mean different /// things. Style, Paste Special and Navigation are absent for `LaneView`'s own reason (its Style… /// note): each opens its own accessible surface — a popover, or the submenu itself, which the /// context menu already exposes reachably (VO-⇧-M) — and is "not an action" in the flat sense this /// list carries. @ViewBuilder private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View { Button("Open") { openCard(card.id) } Button("Copy Link") { copyLink() } .disabled(!copyLinkEnabled) Button("Rename") { beginRename() } .disabled(!store.acceptsBoardMutations) Button("Copy") { appModel.clipboard.copy(from: store, targeting: clipboardTarget) } .disabled(!copyEnabled) Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) } .disabled(!store.acceptsBoardMutations) Button("Paste") { appModel.clipboard.paste(into: store) } .disabled(!pasteEnabled) Button("Send to Trash") { deleteTargets() } .disabled(!store.acceptsBoardMutations) } /// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no /// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder"). /// /// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash). /// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays /// enabled on trash selections", read-only lock included — inspecting a folder before a purge is /// exactly the errand it exists for. /// /// The Delete row goes through the window's confirmation host rather than straight to the store, /// because this delete is the **permanent** one and the alert is what stands between it and an /// unrecoverable loss (03 § Trash; `TrashConfirmations.requestTrashDelete`). @ViewBuilder private func trashMenu(confirmations: TrashConfirmations) -> some View { Button("Delete") { requestPurge(confirmations) } .disabled(!store.acceptsBoardMutations) Divider() Button("Reveal in Finder") { revealInFinder() } } /// `trashMenu`'s rows as VoiceOver custom actions — `boardActions`' twin, two rows and no Open. @ViewBuilder private func trashActions(confirmations: TrashConfirmations) -> some View { Button("Delete") { requestPurge(confirmations) } .disabled(!store.acceptsBoardMutations) Button("Reveal in Finder") { revealInFinder() } } // MARK: - The rows' bodies /// Board ▸ Rename's store path, seeded with the card's live title. private func beginRename() { store.transient.beginRename(of: card.id, currentTitle: card.title.value) } /// File ▸ Delete's store path over the context-menu target set. private func deleteTargets() { store.delete(targetIDs) } /// The trash's **permanent** delete, through the window's confirmation host — never straight to /// the store, because the alert is what stands between this row and an unrecoverable loss. private func requestPurge(_ confirmations: TrashConfirmations) { confirmations.requestTrashDelete(of: targetIDs, in: store) } private func revealInFinder() { NSWorkspace.shared.activateFileViewerSelecting(targetFolders) } /// Copy Link's context-menu enablement — **`selectedCount`, never `targetIDs`**. `targetIDs` /// reads `store.selection` directly, and `.contextMenu`'s content closure is not lazy: SwiftUI /// evaluates `boardMenu` (and therefore any `.disabled(...)` inside it) on every ordinary body /// pass, not only when the menu opens, exactly as building this row against `targetIDs` first /// proved the hard way — every face's body re-ran on every selection change, the precise O(board) /// regression `isSelected`/`selectedCount` exist to prevent (this struct's own top-of-file note; /// `BoardRenderPerformanceTests.selectionStillRepaints`, which caught it). /// /// `selectedCount` is the render-safe answer to the same question: the parent (`LaneView`) already /// computes "the size of the selection this face belongs to, else 1" as a **plain, non-Observable /// parameter** — `targetIDs.count`'s exact widening, paid for once per lane instead of once per /// card-menu-construction. "A link is singular" (design ruling 2026-08-09, card 737a949f), so a /// count above 1 disables rather than guessing which member was meant — the same reading Style… /// and Delete's `targetIDs` give their own action, just checked here instead of only inside it, /// because unlike them this row's *enabled state itself* has to say so. private var copyLinkEnabled: Bool { store.acceptsBoardMutations && selectedCount == 1 } /// Copy Link's write — `copyLinkEnabled`'s one caller. Resolves **this card's own folder** /// directly rather than through `targetIDs`: `copyLinkEnabled == true` already guarantees the /// widened target is `card.id` alone (either this card sits outside the live selection, or it is /// the selection's sole member), so there is no widened set left to read `store.selection` for. /// `store.copyLinkTarget` is deliberately not reused either — that property reads the *live* /// selection for the menu-bar row (`CopyLinkCommand`), which would answer wrongly for a card /// clicked outside the current selection, exactly the case `TrashWriteTests`' /// `contextMenuDeleteIgnoresTheSelection` pins for Delete. private func copyLink() { guard copyLinkEnabled, let folder = ItemPath.resolve([card.id], in: role.container, snapshot: store.snapshot) .first?.folder(under: store.rootURL) else { return } SystemFolderLinkPasteboard().write(link: folder) } /// VO-Space's landing: the ⌘-click funnel, on this card, **in this face's container** — so a /// trash card's toggle can no more mix with a board selection than a ⌘-click could. private func toggleSelection() { store.click( SelectionTarget(id: card.id, kind: .card, container: role.container), modifier: .command ) } /// 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 { .items(targetIDs) } /// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set` /// because `store.delete(_:)` takes one directly. Container-scoped, so a trash row's menu never /// widens to a board selection and vice versa. private var targetIDs: Set { guard store.selection.container == role.container, store.selection.ids.contains(card.id) else { return [card.id] } return store.selection.ids } /// The folders Reveal in Finder points at — resolved in this face's container, so a trash row /// reveals `/.trash/` and never a lane path that no longer holds the card. private var targetFolders: [URL] { ItemPath.resolve(targetIDs, in: role.container, snapshot: store.snapshot) .map { $0.folder(under: store.rootURL) } } // MARK: - Copy / Cut / Paste /// Copy's and Cut's own widened target, as the `ItemReferenceSet` `ClipboardStore`'s targeted /// overloads take — `targetIDs` plus this face's container, `styleTarget`'s exact pairing one type /// over. Read only inside an **action** closure (never inside a `.disabled`), `styleTarget`'s own /// reason: it reads `store.selection`, and `.contextMenu`'s builder is not lazy. private var clipboardTarget: ItemReferenceSet { ItemReferenceSet(ids: targetIDs, container: role.container) } /// Copy's context-menu enablement — render-safe by construction, never by re-deriving /// `targetIDs`/`store.selection` inline (`copyLinkEnabled`'s own reason). /// /// `ClipboardStore.canCopy(from:targeting:)` is three clauses: not mid-edit, the target does not /// mix kinds, and the target names a kind at all. On this face's own container (`.board`) the /// latter two are always satisfied for `clipboardTarget`: it is never empty (at minimum this card /// alone) and never mixes kinds (`SelectionGrammar.mixesKinds` answers `true` only on the trash — /// its own doc comment: "only the trash can answer true"). So the whole predicate reduces to /// `!store.isEditingInline`, which costs no selection read at all. private var copyEnabled: Bool { !store.isEditingInline } /// Paste's context-menu enablement, reduced the same render-safe way — see the type comment's /// "Render-safety" section for the proof that `PasteTarget.cards` cannot answer `nil` from a card /// face that exists at all, which is what lets this skip `store.selection` entirely. private var pasteEnabled: Bool { store.acceptsBoardMutations && appModel.clipboard.payload != nil } /// Paste Image into Card's enablement, `pasteEnabled`'s own reduction: `canPasteImage(intoCard:in:)` /// only ever answers `false` on its snapshot lookup for a card that is not a live board item, which /// this face rendering at all already rules out — see the type comment's "Render-safety" section. private var pasteImageEnabled: Bool { !store.isReadOnly && appModel.clipboard.imagePayload != nil } // MARK: - Navigation (Move Left / Move Right) /// Navigation ▸ Move Left's render-safe enablement — `hasLeftNeighbor` (this face's own lane /// against the live board order, lane-hoisted the way `isSelected` is), `acceptsBoardMutations` /// (a narrow, rarely-changing `BoardStore` flag — every other row here reads it directly, the same /// proof applies), and the multi-lane-spread refusal, consulted only when this card is itself /// selected (an unselected clicked card's target is always itself alone, trivially confined to /// this lane whatever `selectionSpansLanes` says — see the type comment's own section). private var moveLeftEnabled: Bool { store.acceptsBoardMutations && hasLeftNeighbor && !(isSelected && selectionSpansLanes) } /// `moveLeftEnabled`'s mirror, for Move Right. private var moveRightEnabled: Bool { store.acceptsBoardMutations && hasRightNeighbor && !(isSelected && selectionSpansLanes) } /// Navigation ▸ Move Left/Right's action — `CardMoveTarget.destination`, the per-card cousin of /// `LaneMoveTarget.destination` (`MoveLaneCommands.move(by:)`'s own shape one type over), over /// this face's own widened target (`targetIDs`, Copy/Cut's precedent). `destination` re-derives /// exactly what `moveLeftEnabled`/`moveRightEnabled` already checked — a card menu names its /// target by where it was invoked, never by a lingering `.disabled` read, `copyLink()`'s reason — /// so a stale enablement can only ever make this a no-op, never a wrong move. The write is the /// ordinary `moveCards`: rank-minting, the undo step, the watcher echo and the banner all come /// free from that one call, exactly as a released drag's do. private func moveCardAcrossLane(by delta: Int) { guard store.acceptsBoardMutations, let target = CardMoveTarget.destination( clicked: card.id, targeting: targetIDs, snapshot: store.snapshot, delta: delta) else { return } store.moveCards(targetIDs, toLane: target.laneID, at: target.index) } // MARK: - Title row private var titleRow: some View { HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) { Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card)) .foregroundStyle(iconTint) .imageScale(.medium) titleOrEditor // The title takes the row's width so the chips sit hard against the trailing // edge — and so the rename field fills the same span the title occupied. .frame(maxWidth: .infinity, alignment: .leading) attachmentsIndicator commentsIndicator } } /// The title, or the rename editor when this card is the rename target. 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() if case let .board(openCard) = role { openCard(id) } } ) .boardFont(.body) } else { Text(card.title.value ?? "Untitled") .boardFont(.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) } } /// One of the two face chips 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 *value* rather than onto the face: it is useful to know, not to look at. /// /// **Decorative, and hidden outright** (10-accessibility.md): "face icon and chips are /// decorative — folded into the element, never separately focusable … the flattened element /// carries the attachment count in its value". The flattening above would drop a label here /// anyway; saying it explicitly is what keeps the chip inert in the replica too, which is drawn /// outside the flattened face. @ViewBuilder private var attachmentsIndicator: some View { if !card.attachments.isEmpty { Image(systemName: "paperclip") .boardFont(.caption) .foregroundStyle(.secondary) .accessibilityHidden(true) } } /// The comments chip — attachments' twin, joined 2026-08-09 (design ruling, card e729e30a): /// shown only when the card has one comment or more, same secondary-tinted, decorative, /// present-only vocabulary as `attachmentsIndicator`. **Carries a visible count**, unlike the /// paperclip, because the ruling asks for "a quiet indicator (bubble-style SF Symbol + count)" — /// still quiet (caption size, secondary tint, no pill background), just not icon-only; the count /// answers "how many" the way a lane's own card-count badge does one level up, without spending a /// tap to find out. /// /// **`card.commentCount` is the one and only source** — a snapshot field the loader fills with a /// readdir (`BoardLoader.commentCount(in:)`, `Card.commentCount`'s own doc comment), never a /// per-face parse — so drawing this chip costs nothing beyond reading a field already on the /// compared `card` parameter (RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies: /// no new Observable read joins this body, and no new `CardFaceView` parameter was needed either, /// since the count already rides inside `card`). /// /// **Decorative and hidden outright**, `attachmentsIndicator`'s exact reasons: the flattened /// element carries the comment count in its value (`AccessibilityPhrases.cardValue`), so a label /// here would be redundant even before the flattening drops it. @ViewBuilder private var commentsIndicator: some View { if card.commentCount > 0 { HStack(spacing: BoardMetrics.chipGlyphSpacing(bodyPointSize: pointSize)) { Image(systemName: "bubble") Text("\(card.commentCount)") } .boardFont(.caption) .foregroundStyle(.secondary) .accessibilityHidden(true) } } /// 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 /// What the plate's edge is painted with: the accent when this card is selected or a Finder file /// drag is hovering it, a separator hairline under Increase Contrast, and nothing otherwise. /// /// The three-way branch is the whole of "state is never colour-alone, and Increase Contrast /// strengthens borders" meeting on one shape: the resting border is *chrome* (every card gets /// one, so it says nothing), and the accent ring stays the only thing that means "this one". private var plateStroke: AnyShapeStyle { if isSelected || isFileHovered { AnyShapeStyle(Color.accentColor) } else if Accommodations.drawsRestingBorder(contrast: contrast) { AnyShapeStyle(.separator) } else { AnyShapeStyle(.clear) } } private var plateStrokeWidth: CGFloat { guard isSelected || isFileHovered else { return Accommodations.borderWidth(1, contrast: contrast) } return Accommodations.borderWidth(isFileHovered ? 2.5 : 1.5, contrast: contrast) } /// 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`). /// /// **Never on the trash side**: "Finder file drops on trash cards are inert" (04-interactions.md /// ▸ The trash), and the trash column's own delegate clears the file highlight rather than /// proposing one — so this is a second, structural statement of the same rule. private var isFileHovered: Bool { role.container == .board && drops.session.fileAttachTarget(onBoardRooted: store.rootKey) == card.id } /// **Board-only** — "everything edit-shaped is disabled on trash selections … Rename" /// (04-interactions.md ▸ The trash). No path opens a rename on a trashed card, and this makes a /// stray one render nothing rather than putting a live field over a card that cannot be edited. private var isRenaming: Bool { role.container == .board && store.transient.renameEditor?.targetID == card.id } private var draft: Binding { Binding( get: { store.transient.renameEditor?.draftTitle ?? "" }, set: { store.transient.updateRenameDraft($0) } ) } }