View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
117 lines
5.9 KiB
Swift
117 lines
5.9 KiB
Swift
import SwiftUI
|
|
|
|
/// The **shadow placeholder** — the outline occupying an item's proposed landing spot
|
|
/// (DRAG-REORDER.md § The pieces).
|
|
///
|
|
/// One shape for both the drag's shadows and the lane resize's resting footprint, because they are
|
|
/// the same thing said twice: "here is the space this item occupies in the layout". They differ only
|
|
/// in whether the space is a *proposal* — the drag's is dashed, because a dashed outline is what
|
|
/// 03-board-ui.md's placeholder vocabulary asks for and what makes "the drop lands exactly here"
|
|
/// legible; the resize's is a plain fill, because the lane is already there.
|
|
///
|
|
/// **Hit-transparent, always.** The strip's own drop target has to stay live beneath the shadows
|
|
/// (DRAG-REORDER.md § Single-target dispatch: "shadow placeholders are hit-transparent, so the strip
|
|
/// target stays live beneath them"), and a shadow that swallowed the release would strand the drop.
|
|
struct DragShadow: View {
|
|
|
|
/// Matched to the surface it stands in for — the lane's plate radius or the card's, both
|
|
/// font-derived (`BoardMetrics`) and both supplied by the caller, which is the one that knows
|
|
/// which surface this shadow is standing in for.
|
|
let cornerRadius: CGFloat
|
|
|
|
/// A drop proposal (dashed) or a resting footprint (plain).
|
|
var dashed: Bool = true
|
|
|
|
/// Increase Contrast and Reduce Transparency, for the outline and the fill below
|
|
/// (10-accessibility.md; `Accommodations`). A shadow's whole job is to say "here", and both
|
|
/// halves of how it says so are alpha-based by default.
|
|
@Environment(\.colorSchemeContrast) private var contrast
|
|
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
|
|
|
var body: some View {
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.fill(Accommodations.dragShadowWash(reduceTransparency: reduceTransparency).style)
|
|
.overlay {
|
|
if dashed {
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.strokeBorder(
|
|
Color.accentColor.opacity(Accommodations.accentOpacity(0.55, contrast: contrast)),
|
|
style: StrokeStyle(
|
|
lineWidth: Accommodations.borderWidth(1.5, contrast: contrast),
|
|
dash: [6]
|
|
)
|
|
)
|
|
}
|
|
}
|
|
.allowsHitTesting(false)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
/// The count badge a multi-drag's replica wears — Finder's own affordance for "this many are coming
|
|
/// with me" (04-interactions.md ▸ Drag and drop: "dragging any member of a multi-selection drags the
|
|
/// whole selection").
|
|
///
|
|
/// Draws nothing for a single item, so every replica can carry it unconditionally.
|
|
struct DragCountBadge: View {
|
|
|
|
let count: Int
|
|
|
|
/// The board's ruler (`BoardZoom`) — the badge sits over the shadow of a card drawn at this zoom,
|
|
/// so it has to be sized on the same one.
|
|
@Environment(\.boardZoom) private var zoom
|
|
|
|
/// The badge is a disc around a numeral, so every figure in it follows the numeral's font
|
|
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule) — at the standard body size
|
|
/// they are the 6, 3 and 10 points it has always drawn.
|
|
private var pointSize: CGFloat { zoom.bodyPointSize }
|
|
|
|
var body: some View {
|
|
if count > 1 {
|
|
Text("\(count)")
|
|
.boardFont(.caption2, weight: .bold)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, BoardMetrics.em(0.45, bodyPointSize: pointSize))
|
|
.padding(.vertical, BoardMetrics.em(0.25, bodyPointSize: pointSize))
|
|
.background(Circle().fill(Color.accentColor))
|
|
.offset(
|
|
x: BoardMetrics.em(0.75, bodyPointSize: pointSize),
|
|
y: -BoardMetrics.em(0.75, bodyPointSize: pointSize)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The pickup lift's shadow figures — 03-board-ui.md § Motion's "the pickup lift (scale + shadow as
|
|
/// it detaches from the card)": the system drag session supplies the scale and the tracking, but
|
|
/// paints no shadow onto its preview image, so the shadow can only come from the replica view
|
|
/// itself. Values are the pathfinder's own constant ambient shadow, reused verbatim rather than
|
|
/// re-derived (`~/Documents/indie/Kanban` CardView.swift:623) — its drags read subtly raised, and
|
|
/// that read is the point.
|
|
enum DragReplicaStyle {
|
|
static let shadowColor = Color.black.opacity(0.16)
|
|
static let shadowRadius: CGFloat = 2.5
|
|
static let shadowY: CGFloat = 1.5
|
|
}
|
|
|
|
extension View {
|
|
/// The pickup lift's shadow (`DragReplicaStyle`), for a drag replica's face — shared by the
|
|
/// card, lane, and trashed-lane replicas so the figures are spelled once.
|
|
///
|
|
/// **It re-asserts the board's zoom, and that is not decoration.** A `.onDrag(_:preview:)`
|
|
/// preview is hosted for the drag *image*, outside the strip's view hierarchy, so the environment
|
|
/// the replica's own `.boardFont(_:)` modifiers resolve against is not reliably the board's. The
|
|
/// replica must be drawn on the same ruler as the face it was lifted from — otherwise the image
|
|
/// under the cursor is a card the board does not contain, which is the exact failure
|
|
/// `BoardMetrics.cardReplicaWidth(measured:)` exists to prevent on the width axis. Passing the
|
|
/// level in explicitly closes the type axis the same way.
|
|
///
|
|
/// It rides on this modifier rather than being a fourth line in each replica for the reason the
|
|
/// shadow does: three replicas, one rule, spelled once.
|
|
func dragReplicaShadow(zoom: BoardZoomContext) -> some View {
|
|
environment(\.boardZoom, zoom)
|
|
.shadow(color: DragReplicaStyle.shadowColor, radius: DragReplicaStyle.shadowRadius, y: DragReplicaStyle.shadowY)
|
|
}
|
|
}
|