From e1ffe71e5bc545ac5bfde16aba79b81eb3c0fd4a Mon Sep 17 00:00:00 2001 From: rzen Date: Thu, 6 Aug 2026 20:48:45 -0400 Subject: [PATCH] =?UTF-8?q?A=20refused=20trash=20hover=20withdraws=20the?= =?UTF-8?q?=20trash's=20own=20promise=20=E2=80=94=20the=20fall-through=20h?= =?UTF-8?q?olds=20only=20keepable=20ones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gap #12 ruled into 04 ▸ the trash-drop bullet: the refusal fall-through's honest reading (the column declines to be a target, it does not cancel the held drag) covers only promises the refusal leaves keepable — lane shadows keep drawing and an ⌥-copy lands where they are. A standing proposal naming the trash itself is the promise the refusal just broke, so it withdraws: the drag goes proposal-less over the column, release with no proposal cancels in agreement with the empty picture, and lifting ⌥ re-asks at the same address. Implementation filed on the Implementation board. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy --- DESIGN/03-board-ui.md | 1 + DESIGN/04-interactions.md | 2 +- Kanban/UI/Accommodations.swift | 17 +++++++--- Kanban/UI/Board/BoardSurface.swift | 21 ++++++++++++ Kanban/UI/Board/BoardView.swift | 5 +-- Kanban/UI/Board/CardFaceView.swift | 4 +-- Kanban/UI/Board/LaneView.swift | 45 +++++++++++++++++++------- Kanban/UI/Board/TrashLaneRowView.swift | 2 +- 8 files changed, 76 insertions(+), 21 deletions(-) create mode 100644 Kanban/UI/Board/BoardSurface.swift diff --git a/DESIGN/03-board-ui.md b/DESIGN/03-board-ui.md index 1ddb264..6005553 100644 --- a/DESIGN/03-board-ui.md +++ b/DESIGN/03-board-ui.md @@ -34,6 +34,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i - **`background`** on board / lane / card: palette name (kebab-case, hand-editable) or `#RRGGBB[AA]` hex. Board color paints the board window's content background (the surface behind and between lanes). Lane and card color are **edge accents, not fills** (settled in the pathfinder's treatment shootout — its settings matrix of C-series lane / K-series card variants landed on **C7 · full-column top edge** and **K1 · left edge stripe**): a lane's color paints a full-width band along its top edge, a card's a stripe along its left edge; the surfaces themselves keep the standard chrome, so colored title text never sits on a colored fill. - **`icon`**: SF Symbol per item with per-level defaults (board `rectangle.split.3x1`, lane `square.stack`, card `doc.text`). +- **The standard chrome is the pathfinder's surface stack** (settled 2026-08-06): the window keeps the neutral system background; every lane wears a quiet quaternary-wash plate (the trash plate's own figure — translucent, so a board-chosen color shows through and the board-level ink rule keeps its premise; opaque under Reduce Transparency, the trash precedent); every card sits on an opaque `controlBackgroundColor` plate — white over the washed lane in light appearance, a step *darker* than the window in dark. One plate value for every face a card draws (resting, replicas, placeholder, arriving), so a card is the same object wherever it renders. - **`iconColor`**: resolved — **schema yes, control no**. The field renders when hand-written (tint palette name or hex); the app offers no control for it (Controls below). - The pathfinder's palettes (12 icon tints, 12 backgrounds) carry over as the starting point. **The 12+12 split is a picker split, not a name split** (settled): color resolution searches foregrounds then backgrounds, so a hand-written `background: carnation` (an icon-tint name) resolves and paints — the split governs what the grids offer, never what a name means. diff --git a/DESIGN/04-interactions.md b/DESIGN/04-interactions.md index 18d4408..dd80fd3 100644 --- a/DESIGN/04-interactions.md +++ b/DESIGN/04-interactions.md @@ -62,7 +62,7 @@ Every command is a menu item. The full inventory — every command and action, i The trash lane (03-board-ui.md ▸ Trash — cards and lanes moved into `/.trash/`; lanes rejoined 2026-07-29 as opaque-unit rows) speaks the board's ordinary keyboard language when shown; hidden, it is invisible to every gesture — and **hiding it clears a trash selection** (nothing invisible stays selected, so the toggle-off drops the selection rather than leave commands enabled against rows nobody can see). Trash cards are ordinary cards; a trashed lane is one opaque row (title + card count) — the old liveness machinery stays retired: no ancestor walks, no entry-vs-universe split, one container boundary plus the board's own kind rule. Rules: - **Navigation**: the shown trash is the **last container for card navigation** — arrows walk into and out of it, and ⌥→ jumps to it; inside, plain arrows walk every row, card and lane row alike (navigation crosses kinds). The trash lane itself is never selectable *as a lane* (no lane op applies to it): with a lane selected, ←/→ and ⌥→ stop at the last real lane. -- **Dropping a live card — or lane — on the shown trash deletes it** (lanes extended 2026-07-29): the drag is the pointer's delete gesture — release moves the dragged item(s) into `.trash/`; a lane drag over the shown trash proposes the delete alongside its strip slots. The drop diverges from positional drops in one way: **the shadow always takes the topmost position** — honest, not arbitrary: every trash arrival stamps `modified` and the trash sorts newest-first by that stamp (03 ▸ Trash), so a fresh delete genuinely lands on top. The trash takes no drops while hidden, like every gesture. Cross-board arrivals and ⌥-copies refuse too (a transfer-and-delete compound and a copy-into-the-trash are operations the design doesn't name), and a refusal falls through to the strip retarget rather than cancelling the held drag. +- **Dropping a live card — or lane — on the shown trash deletes it** (lanes extended 2026-07-29): the drag is the pointer's delete gesture — release moves the dragged item(s) into `.trash/`; a lane drag over the shown trash proposes the delete alongside its strip slots. The drop diverges from positional drops in one way: **the shadow always takes the topmost position** — honest, not arbitrary: every trash arrival stamps `modified` and the trash sorts newest-first by that stamp (03 ▸ Trash), so a fresh delete genuinely lands on top. The trash takes no drops while hidden, like every gesture. Cross-board arrivals and ⌥-copies refuse too (a transfer-and-delete compound and a copy-into-the-trash are operations the design doesn't name), and a refusal falls through to the strip retarget rather than cancelling the held drag. **A refusal arriving over a standing trash proposal withdraws it** (ruled 2026-08-06 — the stationary ⌥ flip made the state reachable without a mouse move): the fall-through's honest reading — the column declines to be a target, it does not cancel the drag the user is still holding — covers only promises the refusal leaves keepable: shadows standing in a lane keep drawing, and an ⌥-copy released over the column lands where they are. A standing proposal naming the trash itself is the one promise the refusal just broke — tombstone rows promising a delete the release will refuse — so it withdraws rather than holds: the drag goes proposal-less over the column (the foreign-reload rule's shape — the shadow withdraws, no proposal stands until the pointer reaches a live target, and a release with no valid proposal cancels, which now agrees with the empty picture). Lifting ⌥ re-asks at the same address (the stationary flip's re-read) and the trash's proposal returns. - **Selection keeps one container boundary — and goes kind-blind inside the trash** (re-ruled 2026-07-31, superseding the lanes-rejoin pass's kind-homogeneous trash grammar): a selection never mixes trash items with board items, but *within* the trash cards and lane rows select together — clicks, ⇧-click ranges, ⇧-arrow extension, and the rubber band all sweep every row (the band's full-height backdrop covers both kinds), and Select All with a non-empty trash selection selects **all visible trash rows**. The live board keeps cards XOR lanes, and its Select All stays card-scoped, as everywhere. The guard moves to the exits (the mixed-payload drop refusal and ⌘C/⌘X validation below) — inside the trash the only verbs are Delete and the restore paths, so upstream homogeneity bought nothing the exits don't. ⇧-arrow extension still stops at the container boundary. Menu validation stays binary by container: Delete = move to trash on board selections, Delete = permanent on trash selections (03 ▸ Trash) — and Delete works on a mixed selection, the alert counting both kinds. An external move observed by reload re-resolves the selection by presence, as everywhere (02-architecture.md). - **Within-trash moves are inert**: no move or paste ever targets the trash (deleting is ⌫/⌘⌫ or the drag above), and ⌥⌘↑/⌥⌘↓ are inert on trash rows — the trash's order is its arrival order, not a workspace to arrange. - **Clipboard: the restore path.** ⌘C copies a trash card (a live copy lands wherever pasted — like copying out of Finder's Trash); **⌘X works** (resettled — it was disabled under the tombstone model): cut in the trash, paste is the keyboard-native restore, an ordinary folder move (10-accessibility.md's drag-free contract) — a card pastes into a lane, a trashed lane pastes after the anchor lane (the lane-paste rule above, verbatim). **⌘C and ⌘X validate against mixed selections** (ruled 2026-07-31, with kind-blind selection): the pasteboard's payload types are per-kind, so Cut and Copy grey out via ordinary menu validation while a trash selection mixes kinds — no failed gesture, no beep; the drag path's drop-time explanation (Drag-to-restore below) is where the rule teaches itself. **An item entering the trash voids its pending cut** (the deliberate-removal rule): a cut card — or lane — that gets deleted drops out of the pending cut, as under the old model. diff --git a/Kanban/UI/Accommodations.swift b/Kanban/UI/Accommodations.swift index f437439..cc37ae9 100644 --- a/Kanban/UI/Accommodations.swift +++ b/Kanban/UI/Accommodations.swift @@ -113,8 +113,8 @@ enum Accommodations { } /// A **translucent wash** — a tint laid over whatever happens to be behind it, and the shape - /// every non-material translucency on the board takes: the trash column's plate and hatched - /// header, and the drag shadow's fill. + /// every non-material translucency on the board takes: every lane's plate, the trash column's + /// plate and hatched header, and the drag shadow's fill. /// /// These are not glass, and the distinction matters enough to keep two types: a material samples /// and blurs its backdrop, a wash simply composites at an alpha. But they fail the same way for @@ -136,8 +136,17 @@ enum Accommodations { } } - /// The trash column's plate — the quietest of the three, since the header above it carries the - /// column's identity. + /// Every lane's resting plate (03-board-ui.md § Styling ▸ Capabilities — the standard chrome's + /// middle step, the pathfinder's lane surface carried over). Translucent so a board-chosen + /// colour shows through it, which is what keeps `BoardTextInk`'s premise true for the header + /// text sitting on it. + static func lanePlateWash(reduceTransparency: Bool) -> Wash { + reduceTransparency ? .opaque : .translucent(opacity: 0.35) + } + + /// The trash column's plate — the same figure as an ordinary lane's, kept as its own knob + /// because the trash answers to its own rendering spec (03-board-ui.md § Trash ▸ Rendering), + /// and the quietest of its treatments: the hatched header above carries the column's identity. static func trashPlateWash(reduceTransparency: Bool) -> Wash { reduceTransparency ? .opaque : .translucent(opacity: 0.35) } diff --git a/Kanban/UI/Board/BoardSurface.swift b/Kanban/UI/Board/BoardSurface.swift new file mode 100644 index 0000000..518f4bf --- /dev/null +++ b/Kanban/UI/Board/BoardSurface.swift @@ -0,0 +1,21 @@ +import SwiftUI + +/// The board's **standard chrome** — the neutral surface stack under every lane and card, +/// carried over from the pathfinder (03-board-ui.md § Styling: user colour is an edge accent; +/// these are the fills underneath, and they never carry the user's colour). +/// +/// The stack is three steps deep, and each step has its own home: the window keeps the standard +/// background (`BoardView.boardBackground` paints over it only when the board *chooses* a colour), +/// every lane lays a quiet quaternary wash over that (`Accommodations.lanePlateWash`), and every +/// card sits on the opaque plate below — which is what makes a card pop against the washed lane in +/// both appearances: white on grey in light, a step *darker* than the window in dark. +enum BoardSurface { + + /// A card's opaque plate — `controlBackgroundColor`, the pathfinder's card surface. Every face + /// on the board fills with this one value (the resting face, the drag replica, the lane + /// replica's miniatures, the placeholder editor and the arriving face), so a card is the same + /// object wherever it is drawn. Opaque deliberately: a card's title never sees the board + /// colour, which is why the ≥ 4.5:1 rule binds to the board level and not here + /// (10-accessibility.md; `BoardView.boardBackground`). + static var cardPlate: Color { Color(nsColor: .controlBackgroundColor) } +} diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index c8f3b04..c5e7c42 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -423,8 +423,9 @@ struct BoardView: View { /// hand-written hex, they reach the same place — has its text colour computed against the /// threshold by `BoardTextInk`, composited over the window background in the active appearance /// and recomputed on an appearance flip; the two subtrees that sit on this fill - /// (`LaneView.header` and `TrashLaneView.header` — every other surface on the board carries its - /// own opaque plate) take the answer as a `\.colorScheme` override. + /// (`LaneView.header` and `TrashLaneView.header` — their plates are translucent washes the + /// colour shows through, where every card carries its own opaque plate, + /// `BoardSurface.cardPlate`) take the answer as a `\.colorScheme` override. /// /// **One path, two verification stories** (`ContrastMath`): the twelve palette pairs are checked /// statically, by a test over the ink this seam chooses for each of them (03-board-ui.md § diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 4438013..d882c80 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -249,7 +249,7 @@ struct CardFaceView: View, Equatable { // 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)) + .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 @@ -510,7 +510,7 @@ struct CardFaceView: View, Equatable { width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize), alignment: .leading ) - .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary)) + .background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate)) .overlay(alignment: .leading) { accentStripe } .dragReplicaShadow() } diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 72c8ecb..202fed9 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -82,6 +82,10 @@ struct LaneView: View, Equatable { /// what "increased" does to a stroke. @Environment(\.colorSchemeContrast) private var contrast + /// Reduce Transparency, for the lane plate's wash (`Accommodations.lanePlateWash` — translucent + /// normally, the standard secondary background under the setting; the trash plate's rule). + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + /// The window's appearance — the input to 10-accessibility.md's runtime-contrast rule, and the /// answer for every board whose background is not a hand-written hex (`headerInk`). /// @@ -167,6 +171,10 @@ struct LaneView: View, Equatable { // lane is cut cards and all. .cutTreatment(of: lane.id, in: store) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 } + // Behind the selection wash, not composed into it: the plate is the lane's resting + // surface, the wash above it is the selection's own layer, and stacked `.background`s + // put the later one further back. + .background(lanePlate) // **This lane's drop target**, on the whole body. It accepts *every* session type and routes // internally — card sessions against this lane's masonry zones, lane sessions forwarded to // the strip's logic, external Finder file sessions against those same zones — because @@ -248,12 +256,13 @@ struct LaneView: View, Equatable { // since the ink they need is the same question and only the *verification* differs // (`ContrastMath`). // - // A lane draws *no plate*: its background is the selection wash, which is `.clear` at - // rest (`selectionBackground`), so the title, the icon, the count badge and the rename - // field all land directly on the board's `background` — the surface the design binds the - // threshold to ("text does sit on it", `BoardView.boardBackground`). The card faces below - // are a different matter and deliberately untouched: they carry their own opaque plate - // (`CardFaceView`'s `.background.secondary`), so their titles never see the board colour. + // A lane's plate is a *translucent wash* (`lanePlate`), so the title, the icon, the + // count badge and the rename field still land, effectively, on the board's `background` + // — the surface the design binds the threshold to ("text does sit on it", + // `BoardView.boardBackground`); the wash shifts it too little to change the answer. The + // card faces below are a different matter and deliberately untouched: they carry their + // own opaque plate (`BoardSurface.cardPlate`), so their titles never see the board + // colour. // // **Placed here, not at the end of the chain**, which is the modifier order doing real // work: everything above — including the new-card button in the overlay — is text on the @@ -639,7 +648,7 @@ struct LaneView: View, Equatable { .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize)) - .fill(.background.secondary) + .fill(BoardSurface.cardPlate) ) } Spacer(minLength: 0) @@ -663,6 +672,10 @@ struct LaneView: View, Equatable { private var replicaHeight: CGFloat { max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize)) + // The lane's own wash over an opaque base — the replica floats over whatever the cursor is + // above, so unlike the resting lane it cannot let the wash composite against the board; the + // opaque `.background` behind it stands in for the window's neutral surface. + .background(lanePlate) } /// Where the title bar sits inside the replica — its **centre**, measured down from the replica's @@ -977,13 +990,23 @@ struct LaneView: View, Equatable { /// (10-accessibility.md: "Increase Contrast strengthens borders and the selection indicator"; /// `Accommodations`, and `CardFaceView.plateStroke` for the same three-way branch on a card). /// - /// A lane is otherwise bounded by nothing but the gap between it and its neighbour, which is the - /// distinction the setting most needs to restore here. + /// A lane is otherwise bounded by its quiet wash and the gap between it and its neighbour, + /// which is the distinction the setting most needs to restore here. private var selectionStroke: some View { RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(plateStroke, lineWidth: plateStrokeWidth) } + /// The lane's resting plate — the standard chrome's middle step (03-board-ui.md § Styling ▸ + /// Capabilities: window, washed lane, opaque card), the same quaternary wash the trash column + /// wears (`Accommodations.lanePlateWash`). Translucent deliberately: a board-chosen colour + /// shows through, so the header ink computed against the board's background (`headerInk`) + /// keeps its premise on a painted board. + private var lanePlate: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Accommodations.lanePlateWash(reduceTransparency: reduceTransparency).style) + } + private var plateStroke: AnyShapeStyle { if isSelected { AnyShapeStyle(Color.accentColor) @@ -1171,7 +1194,7 @@ private struct NewCardStubView: View { .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) .background( RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize)) - .fill(.background.secondary) + .fill(BoardSurface.cardPlate) ) .overlay( RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize)) @@ -1207,7 +1230,7 @@ private struct NewCardStubView: View { .padding(.leading, BoardMetrics.cardStripeWidth(bodyPointSize: pointSize)) .background( RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize)) - .fill(.background.secondary) + .fill(BoardSurface.cardPlate) ) } diff --git a/Kanban/UI/Board/TrashLaneRowView.swift b/Kanban/UI/Board/TrashLaneRowView.swift index c97a5e4..b52e2fc 100644 --- a/Kanban/UI/Board/TrashLaneRowView.swift +++ b/Kanban/UI/Board/TrashLaneRowView.swift @@ -105,7 +105,7 @@ struct TrashLaneRowView: View { .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) .frame(maxWidth: .infinity, alignment: .leading) // Quieter than a card's plate, which is what "dimmed" is here: the cards beside it keep the - // ordinary secondary background, and this row sits a step further back. + // ordinary card plate (`BoardSurface.cardPlate`), and this row sits a step further back. .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.tertiary)) .overlay( RoundedRectangle(cornerRadius: cornerRadius)