A refused trash hover withdraws the trash's own promise — the fall-through holds only keepable ones

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
This commit is contained in:
2026-08-06 20:48:45 -04:00
parent 531ca2c595
commit e1ffe71e5b
8 changed files with 76 additions and 21 deletions
+21
View File
@@ -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) }
}
+3 -2
View File
@@ -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 §
+2 -2
View File
@@ -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()
}
+34 -11
View File
@@ -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)
)
}
+1 -1
View File
@@ -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)