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). /// /// **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. enum CardFaceRole { /// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin /// (04-interactions.md ▸ Selection). case board(openCard: (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 } } } // 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). /// /// ### 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, "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). struct CardFaceView: View { 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 /// 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 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 { BoardMetrics.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 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 { 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 { 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) .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. // // **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 deferred cut's "cut, pending paste", or both — and the empty // string when neither, which speaks as nothing (see `AccessibilityPhrases.cardValue` for why // it is not a conditional modifier). .accessibilityValue(AccessibilityPhrases.cardValue( attachments: card.attachments.count, 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) } // 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] ?? LaneDropRegistry.nominalCardHeight }, container: .board, source: store ) return payload.itemProvider() } /// 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] ?? LaneDropRegistry.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). private var dragReplica: some View { let count = store.selection.container == role.container && 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(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 { 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") .font(.body) .lineLimit(4) .frame(maxWidth: .infinity, alignment: .leading) attachmentsIndicator } .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 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(.background.secondary)) .overlay(alignment: .leading) { accentStripe } } // MARK: - Context menus /// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context /// menus' Card row, in its order. @ViewBuilder private func boardMenu(openCard: @escaping (ItemID) -> Void) -> 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") { beginRename() } .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") { deleteTargets() } .disabled(!store.acceptsBoardMutations) } /// `boardMenu`'s plain 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. @ViewBuilder private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View { Button("Open") { openCard(card.id) } Button("Rename") { beginRename() } .disabled(!store.acceptsBoardMutations) Button("Delete") { 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) } /// 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: - 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 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. 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) } } ) .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 *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") .font(.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 private var isSelected: Bool { store.selection.container == role.container && store.selection.ids.contains(card.id) } /// 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.rootURL) == 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) } ) } }