Implement visual accommodations and Full Keyboard Access
Full relative text scaling per DESIGN/10: BoardMetrics is the board strip's geometry as a pure function of the body point size (CardWindowMetrics' twin) — lane plate/header/band, card corner/stripe/padding, masonry spacing, the drop model's nominal card height, resize-handle geometry, trash hatch pitch, and both window floors all derive from an em; CardFaceMetrics folded in. The two fixed font sizes (welcome brand/glyph) went relative; the toolbar search field is 17 ems like the transient bar's. The no-horizontal-scroll invariant is pinned by test at six text sizes by twelve lane counts. Accommodations is Motion's sibling for the visual settings: Increase Contrast adds a flat point to strokes (monotone, hierarchy-preserving), gives borderless card/lane plates a resting separator hairline, and takes faded accents to full alpha; Reduce Transparency turns the transient search bar's glass solid and does the same for the alpha washes that composite over a user-chosen board background (trash plate, hatched header, drag shadow). Reduce Motion audited — every animated surface already routes through Motion with a reduced variant; no gaps. Full Keyboard Access: the template chooser's tiles were pointer-only — now focusable, arrow-navigable (clamped, StyleWellGrid's rule), Space picks, Return stays the sheet's default action, focus names the selection one-way. The board's single tab stop shows its focus ring under FKA (focusEffectDisabled inverts). Style editor verified already conformant. Edge accents verified text-free; trash hatch pitch now font-derived so it still reads as hatching at large text. 1549 unit tests green, both schemes build. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -48,7 +48,17 @@ final class LaneDropRegistry {
|
||||
|
||||
/// The height a card with no registered measurement is assumed to have — a lane whose faces have
|
||||
/// not laid out yet. Nominal rather than zero, so the resting rows still tile.
|
||||
static let nominalCardHeight: CGFloat = 44
|
||||
///
|
||||
/// **Font-derived** (`BoardMetrics.nominalCardHeight`, 10-accessibility.md's full-relative-scaling
|
||||
/// rule), and it matters more here than at most sites: this is the stand-in the drop model tiles
|
||||
/// rows with before anything has measured itself, so a figure fixed at 13pt's 44 points would put
|
||||
/// every un-measured slot boundary in the wrong place at a large system text size — and the drop
|
||||
/// model is forbidden from reading measured frames mid-flight (03-board-ui.md § Motion), so this
|
||||
/// guess is all it has until the lane lays out.
|
||||
@MainActor
|
||||
static var nominalCardHeight: CGFloat {
|
||||
BoardMetrics.nominalCardHeight(bodyPointSize: BoardMetrics.bodyPointSize)
|
||||
}
|
||||
|
||||
/// The board strip's own frame in the window's SwiftUI global space — the origin the strip
|
||||
/// coordinates `DropSlotMath.laneExtents` is written in are measured from. It lives here rather
|
||||
|
||||
@@ -138,8 +138,12 @@ struct BoardInfoView: View {
|
||||
private let hasGitDirectory: Bool
|
||||
|
||||
/// The style editor brings its own padding, so the sections around it carry the same number by
|
||||
/// hand instead of an outer padding that would double up on it.
|
||||
private let inset: CGFloat = 14
|
||||
/// hand instead of an outer padding that would double up on it — **the editor's own figure**
|
||||
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
|
||||
/// with the grids inside it (10-accessibility.md's full-relative-scaling rule).
|
||||
private var inset: CGFloat {
|
||||
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||
}
|
||||
|
||||
init(store: BoardStore, recents: StyleRecents) {
|
||||
self.store = store
|
||||
@@ -178,7 +182,10 @@ struct BoardInfoView: View {
|
||||
.padding(inset)
|
||||
}
|
||||
}
|
||||
.frame(width: 268)
|
||||
// The style editor's popover width, taken from the editor rather than restated: the embed
|
||||
// below must lay out here exactly as it does at its other two anchors, and that number is
|
||||
// now font-derived.
|
||||
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
|
||||
}
|
||||
|
||||
/// The section titles, matching the style editor's own headers so the popover reads as one
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
|
||||
/// The board strip's geometry — **derived from font metrics, never written down in points**
|
||||
/// (10-accessibility.md ▸ Text scaling & visual accommodations: "relative text styles everywhere, no
|
||||
/// fixed point sizes. Card face, lane header, and masonry metrics derive from font metrics, so
|
||||
/// layout survives the largest system text sizes").
|
||||
///
|
||||
/// This is `CardWindowMetrics`' twin on the board side, and deliberately the same shape: a pure
|
||||
/// arithmetic surface parameterised on the body font's point size, plus one impure read of what that
|
||||
/// point size currently is. Everything the strip draws that is not a piece of text — the inter-lane
|
||||
/// gap, a lane's plate inset, a card's corner radius and stripe, the masonry's card spacing, the
|
||||
/// trash column's header — is a multiple of that size, so the board grows with the system text size
|
||||
/// instead of squeezing text into chrome sized for 13pt.
|
||||
///
|
||||
/// ### Why a point size and not a `Font`
|
||||
///
|
||||
/// Because layout takes numbers. SwiftUI's relative text styles handle the *text* (and every `Text`
|
||||
/// on the board wears one — `.body`, `.headline`, `.caption`); what they cannot do is tell an
|
||||
/// `.padding()` how much room the text will need. So the two halves of "full relative scaling" are
|
||||
/// split by mechanism: type styles for glyphs, this surface for everything between them, both keyed
|
||||
/// to the same system font.
|
||||
///
|
||||
/// ### The em, and what the multiples mean
|
||||
///
|
||||
/// Every figure below is a multiple of the body point size — an *em*, roughly — chosen so that at
|
||||
/// the standard 13pt system body font it reproduces the numbers the board already drew. That is
|
||||
/// deliberate: this milestone is meant to make the board *scale*, not to redesign it, so the default
|
||||
/// text size must render pixel-for-pixel what it rendered before. The multiples are what carries the
|
||||
/// design to 18pt, 24pt and beyond.
|
||||
///
|
||||
/// Results are rounded to whole points (SwiftUI will happily lay out on halves, but a hairline
|
||||
/// border on a half-point boundary blurs) and floored at 1 for anything that is a width or a height,
|
||||
/// so no proposal is ever zero or negative.
|
||||
///
|
||||
/// ### The no-horizontal-scroll invariant is untouched
|
||||
///
|
||||
/// 03-board-ui.md § Layout — full visibility divides the window's width across the lanes' width
|
||||
/// units, and `LaneLayoutMath` takes the gap as a parameter. A larger text size therefore means a
|
||||
/// larger gap and *narrower* lanes, never a wider strip: "the degenerate case is accepted, not
|
||||
/// floored … titles and cards truncate gracefully". The truncation rules are the views' own
|
||||
/// (`lineLimit(1)` + `.tail` on a lane title, `lineLimit(4)` on a card title), and they hold at every
|
||||
/// scale because they are stated in lines rather than in points.
|
||||
enum BoardMetrics {
|
||||
|
||||
// MARK: - The unit
|
||||
|
||||
/// `multiple` ems of the body font, rounded to a whole point and floored at one.
|
||||
///
|
||||
/// Floored rather than clamped to zero because every caller is a length: a spacing of zero is a
|
||||
/// legitimate design choice, but none of the figures below is one, and a rounding that produced
|
||||
/// zero would silently collapse a stripe or a band rather than shrink it.
|
||||
static func em(_ multiple: CGFloat, bodyPointSize: CGFloat) -> CGFloat {
|
||||
max(1, (bodyPointSize * multiple).rounded())
|
||||
}
|
||||
|
||||
// MARK: - The strip
|
||||
|
||||
/// The inter-lane gap **and** the strip's outer margin — one number, because the standard-width
|
||||
/// formula counts `units + 1` of them (`LaneLayoutMath.standardWidth`).
|
||||
///
|
||||
/// 0.9 em: 12pt at the standard 13pt body, which is what the strip has always drawn.
|
||||
static func stripGap(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.9, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
// MARK: - The lane
|
||||
|
||||
/// The lane plate's corner radius — shared by the selection treatment and the accent band, whose
|
||||
/// top corners round to exactly this so the band reads as the lane's own edge.
|
||||
static func laneCornerRadius(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.75, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The lane plate's inset around its header and its masonry.
|
||||
static func lanePlatePadding(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.45, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// Between the lane's header and its card stack.
|
||||
static func laneStackSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.6, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// Between the header's glyph, its title and its count badge.
|
||||
static func laneHeaderSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.45, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The header row's own horizontal inset inside the plate.
|
||||
static func laneHeaderInset(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.3, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities) — the lane accent band's
|
||||
/// height.
|
||||
static func laneAccentBandHeight(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.4, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The trailing room the header reserves for the new-card button, so **a long title truncates
|
||||
/// before it collides with the button** rather than running under it.
|
||||
///
|
||||
/// This is the one figure that is not decoration: the button is an `Image` at
|
||||
/// `.imageScale(.small)`, which *is* a relative size, so a reserve fixed at 22pt would be
|
||||
/// overrun by the glyph itself at a large system text size and the truncation rule would stop
|
||||
/// being true. 1.7 em is the button plus its breathing room, measured in the same unit the glyph
|
||||
/// grows in.
|
||||
static func newCardButtonReserve(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(1.7, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The count badge's capsule inset — horizontal and vertical, which are deliberately different:
|
||||
/// a capsule around a single digit wants width, not height.
|
||||
static func badgeHorizontalPadding(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.45, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
static func badgeVerticalPadding(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.08, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
// MARK: - The card face
|
||||
|
||||
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
|
||||
/// of the card's edge rather than a bar laid over it.
|
||||
static func cardCornerRadius(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.6, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities). Reserved as padding whether
|
||||
/// or not a stripe paints, so colouring a card never shifts its title.
|
||||
static func cardStripeWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.3, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The card plate's inset around its content.
|
||||
static func cardContentPadding(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.75, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// Between the icon, the title and the attachments chip.
|
||||
static func cardRowSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.45, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The masonry's spacing — between interior columns and between stacked cards within a column.
|
||||
///
|
||||
/// The lane registers this into `LaneDropRegistry.Grid`, so the drop model's analytic resting
|
||||
/// grid replays the same number the layout drew with and no second derivation exists to drift
|
||||
/// (DRAG-REORDER.md § The card masonry).
|
||||
static func cardSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.6, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The height a card with no registered measurement is assumed to have — a lane whose faces have
|
||||
/// not laid out yet, and the shadow a Finder file drop opens for a card that does not exist.
|
||||
///
|
||||
/// 3.4 em: one body line of title inside two content paddings, plus the plate's own rhythm.
|
||||
/// Nominal rather than zero, so the resting rows still tile.
|
||||
static func nominalCardHeight(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(3.4, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
// MARK: - The drag replica
|
||||
|
||||
/// The width the card drag's replica is drawn at — a card at a representative lane width, since
|
||||
/// the image under the cursor has no lane to measure itself against.
|
||||
static func cardReplicaWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(17, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The transparent margin around a drag replica, which is what keeps its shadow from being
|
||||
/// clipped by the drag image's bounds.
|
||||
static func replicaPadding(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.9, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The lane replica's floor dimensions, for the frame a lane that has not measured itself yet
|
||||
/// would otherwise be drawn at.
|
||||
static func laneReplicaMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(6, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
static func laneReplicaMinimumHeight(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(9, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
// MARK: - The lane resize handle
|
||||
|
||||
/// The invisible grab strip at a lane's trailing edge, and how far right it is shifted so most
|
||||
/// of it hangs into the inter-lane gap rather than sitting over the lane's own scrollbar
|
||||
/// (`LaneResizeHandle`). Both scale, because the gap they live in does.
|
||||
static func resizeHandleWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.9, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
static func resizeHandleOverhang(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.6, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
// MARK: - The trash column
|
||||
|
||||
/// The trash header's inset — horizontal and vertical (03-board-ui.md § Trash ▸ Rendering).
|
||||
static func trashHeaderHorizontalPadding(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.75, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
static func trashHeaderVerticalPadding(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.6, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The gap between the trash header's diagonal hatch strokes.
|
||||
///
|
||||
/// It scales for a reason the other figures do not share: the hatch is **the trash's non-colour
|
||||
/// distinction** (10-accessibility.md's never-colour-alone rule — "the trash header is hatched
|
||||
/// plus labeled"), and a fixed 7pt pitch behind text twice its usual size reads as a texture
|
||||
/// rather than as hatching.
|
||||
static func trashHatchSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(0.55, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
// MARK: - The board window
|
||||
|
||||
/// The board window's minimum content size — two standard lanes' worth of width and enough
|
||||
/// height for a header and a few cards. Derived so a large system text size cannot leave the
|
||||
/// window smaller than one lane's own header.
|
||||
static func windowMinimumSize(bodyPointSize: CGFloat) -> CGSize {
|
||||
CGSize(
|
||||
width: em(49, bodyPointSize: bodyPointSize),
|
||||
height: em(31, bodyPointSize: bodyPointSize)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - The live metric
|
||||
|
||||
/// The body font's point size as the system currently reports it.
|
||||
///
|
||||
/// **The app asks the system exactly once, in `CardWindowMetrics.bodyPointSize`**, and this
|
||||
/// forwards to it: the board and the card window must agree about what "the body font" is, or a
|
||||
/// card face and the window it opens into would scale on two different rulers.
|
||||
@MainActor
|
||||
static var bodyPointSize: CGFloat {
|
||||
CardWindowMetrics.bodyPointSize
|
||||
}
|
||||
}
|
||||
@@ -447,16 +447,26 @@ struct BoardSearchBar: View {
|
||||
let store: BoardStore
|
||||
let presentation: BoardSearchPresentation
|
||||
|
||||
/// Reduce Transparency — **this bar is the board's one glass underlay** ("glass underlays go
|
||||
/// solid, wherever they appear", 10-accessibility.md; the design's own example, the card face
|
||||
/// carousel's page dots, died with the carousel). `.bar` is a material, so under the setting it
|
||||
/// becomes the opaque window background (`Accommodations.Underlay`).
|
||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
BoardSearchFieldView(store: store, presentation: presentation)
|
||||
.frame(width: 220)
|
||||
// A field wide enough for a query, in characters rather than points, so it grows
|
||||
// with the text it holds (10-accessibility.md's full-relative-scaling rule).
|
||||
.frame(width: BoardMetrics.em(17, bodyPointSize: pointSize))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.bar)
|
||||
.padding(.horizontal, BoardMetrics.stripGap(bodyPointSize: pointSize))
|
||||
.padding(.vertical, BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
.background(Accommodations.underlay(reduceTransparency: reduceTransparency).style)
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
@@ -101,7 +101,11 @@ enum BoardToolbar {
|
||||
"Search",
|
||||
identifier: .boardSearch,
|
||||
symbol: nil,
|
||||
behavior: .control(width: 220) { [weak store] willBeInserted in
|
||||
// The field's width in *characters* rather than points (10-accessibility.md ▸ Text
|
||||
// scaling: "no fixed point sizes") — the same 17 ems the transient bar's field takes
|
||||
// (`BoardSearchBar`), so ⌘F's two homes are one width whichever the user is in.
|
||||
behavior: .control(width: BoardMetrics.em(17, bodyPointSize: BoardMetrics.bodyPointSize)) {
|
||||
[weak store] willBeInserted in
|
||||
guard willBeInserted, let store else {
|
||||
return BoardSearchFieldController.makePaletteField()
|
||||
}
|
||||
|
||||
@@ -103,6 +103,9 @@ struct BoardView: View {
|
||||
/// from the environment here and handed to `Motion`, which owns what "reduced" means for each.
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
|
||||
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
|
||||
/// must go back to meaning create/rename rather than nothing at all.
|
||||
@@ -110,7 +113,13 @@ struct BoardView: View {
|
||||
|
||||
/// The inter-lane gap, and the strip's outer margin — one number, because the standard-width
|
||||
/// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`).
|
||||
private let spacing: CGFloat = 12
|
||||
///
|
||||
/// **Font-derived** (`BoardMetrics.stripGap`), which is what keeps the no-horizontal-scroll
|
||||
/// invariant honest under 10-accessibility.md's full-relative-scaling rule: a larger system text
|
||||
/// size widens the gap and therefore *narrows* every lane, since the window's width still divides
|
||||
/// across `units + 1` gaps. The strip never grows and never scrolls; the lanes compress, "the
|
||||
/// degenerate case accepted, not floored" (03-board-ui.md § Layout — full visibility).
|
||||
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: BoardMetrics.bodyPointSize) }
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
@@ -169,11 +178,20 @@ struct BoardView: View {
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
// The board is a focus target so the grammar keys reach it at all. The focus *ring* is off:
|
||||
// the strip is the window's content, not a control, and a rectangle around the whole board
|
||||
// would read as an error state.
|
||||
// **The board is one tab stop** (10-accessibility.md ▸ Full Keyboard Access: "the board is
|
||||
// one tab stop with arrow-key navigation within"), and this is it: one focusable strip,
|
||||
// whose interior movement is the arrow grammar below rather than a focus stop per card.
|
||||
// The controls *around* the cards — each lane's new-card button, the popovers, the toolbar —
|
||||
// are ordinary `Button`s and stay Tab-reachable in their own right, which is the other half
|
||||
// of the same sentence ("every control … is Tab-reachable").
|
||||
//
|
||||
// The focus *ring* is normally off: the strip is the window's content, not a control, and a
|
||||
// rectangle around the whole board would read as an error state. **Under Full Keyboard
|
||||
// Access it comes back**, because that reasoning inverts completely there — FKA's premise is
|
||||
// that the user can see where Tab landed, and an invisible tab stop is not one
|
||||
// (`Accommodations.isFullKeyboardAccessEnabled`).
|
||||
.focusable()
|
||||
.focusEffectDisabled()
|
||||
.focusEffectDisabled(!Accommodations.isFullKeyboardAccessEnabled)
|
||||
.focused($isBoardFocused)
|
||||
.onAppear {
|
||||
isBoardFocused = true
|
||||
@@ -270,7 +288,7 @@ struct BoardView: View {
|
||||
// One of the drag's N contiguous shadows, at the exact width the arriving lane
|
||||
// will occupy — its units measured against *this* strip's standard, which is
|
||||
// what makes the drop land precisely where the shadow shows.
|
||||
DragShadow()
|
||||
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize))
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
@@ -335,7 +353,15 @@ struct BoardView: View {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor.opacity(0.12))
|
||||
.frame(width: rect.width, height: rect.height)
|
||||
.overlay(Rectangle().strokeBorder(Color.accentColor.opacity(0.5), lineWidth: 1))
|
||||
// Increase Contrast takes the band's edge to full strength and a point heavier: the
|
||||
// fill is a 12%-alpha wash by design, so the border is the only thing that says where
|
||||
// the sweep actually reaches (`Accommodations`).
|
||||
.overlay(
|
||||
Rectangle().strokeBorder(
|
||||
Color.accentColor.opacity(Accommodations.accentOpacity(0.5, contrast: contrast)),
|
||||
lineWidth: Accommodations.borderWidth(1, contrast: contrast)
|
||||
)
|
||||
)
|
||||
.offset(x: rect.minX, y: rect.minY)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
@@ -396,9 +422,12 @@ struct BoardView: View {
|
||||
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
|
||||
ZStack(alignment: .topLeading) {
|
||||
if resizing {
|
||||
DragShadow(dashed: false)
|
||||
.frame(width: slotWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
DragShadow(
|
||||
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize),
|
||||
dashed: false
|
||||
)
|
||||
.frame(width: slotWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
// Interior columns follow the SNAPPED unit count while this lane is being resized — a
|
||||
// column count is integral, so it tracks k (which ticks and animates), not the live
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The card plate's metrics
|
||||
|
||||
/// The card plate's geometry, spelled once because **three views draw it**: the real face
|
||||
/// (`CardFaceView`), the placeholder standing in for a card on its way (`NewCardStubView`'s
|
||||
/// `.awaitingArrival` face), and — since the face itself is what the trash column renders — nothing
|
||||
/// else at all. 02-architecture.md ▸ TransientBoardState ▸ overlays makes the create handoff "read as
|
||||
/// one arrival — the placeholder renders at the arriving card's exact geometry/chrome", and exact is
|
||||
/// only checkable if there is one set of numbers rather than two that happen to agree.
|
||||
enum CardFaceMetrics {
|
||||
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
|
||||
/// of the card's edge rather than a bar laid over it.
|
||||
static let cornerRadius: CGFloat = 8
|
||||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities). Reserved as padding whether
|
||||
/// or not a stripe paints, so colouring a card never shifts its title.
|
||||
static let stripeWidth: CGFloat = 4
|
||||
/// The plate's inset around its content.
|
||||
static let contentPadding: CGFloat = 10
|
||||
/// Between the icon, the title and the attachments chip.
|
||||
static let rowSpacing: CGFloat = 6
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -130,15 +109,25 @@ struct CardFaceView: View {
|
||||
/// 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 `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
|
||||
/// 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 { CardFaceMetrics.cornerRadius }
|
||||
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 { CardFaceMetrics.stripeWidth }
|
||||
private var stripeWidth: CGFloat { BoardMetrics.cardStripeWidth(bodyPointSize: pointSize) }
|
||||
|
||||
/// **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
|
||||
@@ -188,7 +177,7 @@ struct CardFaceView: View {
|
||||
private var face: some View {
|
||||
titleRow
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(CardFaceMetrics.contentPadding)
|
||||
.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)
|
||||
@@ -198,12 +187,14 @@ struct CardFaceView: View {
|
||||
// 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(
|
||||
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||||
lineWidth: isFileHovered ? 2.5 : 1.5
|
||||
)
|
||||
.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
|
||||
@@ -402,7 +393,7 @@ struct CardFaceView: View {
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
||||
@@ -411,7 +402,7 @@ struct CardFaceView: View {
|
||||
/// 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: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
@@ -421,9 +412,12 @@ struct CardFaceView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
.padding(10)
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.padding(.leading, stripeWidth)
|
||||
.frame(width: 220, alignment: .leading)
|
||||
// The replica has no lane to measure itself against, so it takes a representative card
|
||||
// width — font-derived like everything else here, so the image under the cursor is the size
|
||||
// the cards on the board actually are at this text size (`BoardMetrics`).
|
||||
.frame(width: BoardMetrics.cardReplicaWidth(bodyPointSize: pointSize), alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
}
|
||||
@@ -563,7 +557,7 @@ struct CardFaceView: View {
|
||||
// MARK: - Title row
|
||||
|
||||
private var titleRow: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
@@ -659,6 +653,29 @@ struct CardFaceView: View {
|
||||
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
|
||||
|
||||
@@ -14,21 +14,32 @@ import SwiftUI
|
||||
/// 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 — a lane's plate is 10, a card's is 8.
|
||||
var cornerRadius: CGFloat = 10
|
||||
/// 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(.quaternary.opacity(0.5))
|
||||
.fill(Accommodations.dragShadowWash(reduceTransparency: reduceTransparency).style)
|
||||
.overlay {
|
||||
if dashed {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(
|
||||
Color.accentColor.opacity(0.55),
|
||||
style: StrokeStyle(lineWidth: 1.5, dash: [6])
|
||||
Color.accentColor.opacity(Accommodations.accentOpacity(0.55, contrast: contrast)),
|
||||
style: StrokeStyle(
|
||||
lineWidth: Accommodations.borderWidth(1.5, contrast: contrast),
|
||||
dash: [6]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -46,16 +57,24 @@ struct DragCountBadge: View {
|
||||
|
||||
let count: Int
|
||||
|
||||
/// 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 { BoardMetrics.bodyPointSize }
|
||||
|
||||
var body: some View {
|
||||
if count > 1 {
|
||||
Text("\(count)")
|
||||
.font(.caption2.bold())
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, BoardMetrics.em(0.45, bodyPointSize: pointSize))
|
||||
.padding(.vertical, BoardMetrics.em(0.25, bodyPointSize: pointSize))
|
||||
.background(Circle().fill(Color.accentColor))
|
||||
.offset(x: 10, y: -10)
|
||||
.offset(
|
||||
x: BoardMetrics.em(0.75, bodyPointSize: pointSize),
|
||||
y: -BoardMetrics.em(0.75, bodyPointSize: pointSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,14 @@ struct LaneResizeHandle: View {
|
||||
/// cursor.
|
||||
@State private var cursorPushed = false
|
||||
|
||||
private let handleWidth: CGFloat = 12
|
||||
/// The grab strip's width and its rightward shift — both font-derived, because the inter-lane
|
||||
/// gap they are proportioned against is (`BoardMetrics.stripGap`, 10-accessibility.md's
|
||||
/// full-relative-scaling rule). At the standard body size they are the 12pt and 8pt the strip
|
||||
/// has always used: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
|
||||
/// into the gap (clear of the lane's own scrollbar).
|
||||
private var handleWidth: CGFloat { BoardMetrics.resizeHandleWidth(bodyPointSize: BoardMetrics.bodyPointSize) }
|
||||
|
||||
/// Rightward shift: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
|
||||
/// into the gap (clear of the scrollbar).
|
||||
private let overhang: CGFloat = 8
|
||||
private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: BoardMetrics.bodyPointSize) }
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
|
||||
@@ -69,15 +69,24 @@ struct LaneView: View {
|
||||
/// and handed to `Motion`, which owns what "reduced" means.
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Increase Contrast, for the selection stroke and the plate's resting edge below
|
||||
/// (10-accessibility.md). 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 lane lays out on is a multiple of it
|
||||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule).
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
|
||||
/// Spacing between cards, and between the interior columns.
|
||||
private let cardSpacing: CGFloat = 8
|
||||
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
|
||||
|
||||
/// The lane plate's corner radius — shared by the selection treatment and the accent band, whose
|
||||
/// top corners round to exactly this so the band reads as the lane's own edge.
|
||||
private let cornerRadius: CGFloat = 10
|
||||
private var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) }
|
||||
|
||||
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities).
|
||||
private let bandHeight: CGFloat = 5
|
||||
private var bandHeight: CGFloat { BoardMetrics.laneAccentBandHeight(bodyPointSize: pointSize) }
|
||||
|
||||
/// The lane's drawn height, for the replica. Measured rather than derived, because a lane is as
|
||||
/// tall as the strip gives it.
|
||||
@@ -92,11 +101,11 @@ struct LaneView: View {
|
||||
// lane's top edge, so it must sit outside the content inset rather than in it.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
accentBand
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||||
header
|
||||
cardStack
|
||||
}
|
||||
.padding(6)
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
}
|
||||
.background(selectionBackground)
|
||||
.overlay(selectionStroke)
|
||||
@@ -331,7 +340,7 @@ struct LaneView: View {
|
||||
}
|
||||
|
||||
private var headerContent: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
@@ -340,9 +349,12 @@ struct LaneView: View {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
// Reserves the button's width so a long title truncates before it collides, and keeps the
|
||||
// button out of the gestured region.
|
||||
.padding(.trailing, 22)
|
||||
.padding(.horizontal, 4)
|
||||
// button out of the gestured region. **Font-derived** rather than a fixed 22pt: the button
|
||||
// is an `Image` at a relative image scale, so a fixed reserve would be overrun by the glyph
|
||||
// itself at a large system text size and 03-board-ui.md's graceful-truncation rule would
|
||||
// quietly stop holding (`BoardMetrics.newCardButtonReserve`).
|
||||
.padding(.trailing, BoardMetrics.newCardButtonReserve(bodyPointSize: pointSize))
|
||||
.padding(.horizontal, BoardMetrics.laneHeaderInset(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
/// The title, or the rename editor when this lane is the one being renamed.
|
||||
@@ -393,8 +405,8 @@ struct LaneView: View {
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
|
||||
.padding(.vertical, BoardMetrics.badgeVerticalPadding(bodyPointSize: pointSize))
|
||||
.background(Capsule().fill(.quaternary))
|
||||
}
|
||||
|
||||
@@ -485,7 +497,7 @@ struct LaneView: View {
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
private var draggedLaneCount: Int {
|
||||
@@ -497,11 +509,12 @@ struct LaneView: View {
|
||||
private var replicaFace: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
accentBand
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||||
headerContent
|
||||
VStack(alignment: .leading, spacing: cardSpacing) {
|
||||
ForEach(renderedCards.prefix(12)) { card in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline,
|
||||
spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
@@ -510,16 +523,23 @@ struct LaneView: View {
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(10)
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.fill(.background.secondary)
|
||||
)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
}
|
||||
.frame(width: max(slotWidth, 80), height: max(measuredHeight, 120), alignment: .topLeading)
|
||||
.frame(
|
||||
width: max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize)),
|
||||
height: max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize)),
|
||||
alignment: .topLeading
|
||||
)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
@@ -567,7 +587,7 @@ struct LaneView: View {
|
||||
case let .shadow(_, height):
|
||||
// One of the drag's N contiguous shadows, at the dragged card's frozen
|
||||
// height — the run's real footprint, so the drop lands exactly here.
|
||||
DragShadow(cornerRadius: 8)
|
||||
DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.frame(height: height)
|
||||
}
|
||||
}
|
||||
@@ -806,13 +826,33 @@ struct LaneView: View {
|
||||
/// 03-board-ui.md gives lane *colour* to the top-edge accent band, so selection must not read as
|
||||
/// a fill that would compete with it once that lands.
|
||||
private var selectionBackground: some View {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.08)) : AnyShapeStyle(.clear))
|
||||
}
|
||||
|
||||
/// The selection ring — and, under Increase Contrast, the plate's resting edge as well
|
||||
/// (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.
|
||||
private var selectionStroke: some View {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
|
||||
}
|
||||
|
||||
private var plateStroke: AnyShapeStyle {
|
||||
if isSelected {
|
||||
AnyShapeStyle(Color.accentColor)
|
||||
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
|
||||
AnyShapeStyle(.separator)
|
||||
} else {
|
||||
AnyShapeStyle(.clear)
|
||||
}
|
||||
}
|
||||
|
||||
private var plateStrokeWidth: CGFloat {
|
||||
Accommodations.borderWidth(isSelected ? 1.5 : 1, contrast: contrast)
|
||||
}
|
||||
|
||||
// MARK: - Rename plumbing
|
||||
@@ -933,7 +973,7 @@ enum LaneSlot: Identifiable {
|
||||
///
|
||||
/// The editing face is an editor — the accent-stroked well the user is typing into. The awaiting
|
||||
/// face is **a card**: the same plate, inset, stripe gutter, icon, font and title position a default
|
||||
/// new card gets (`CardFaceView`, via the `CardFaceMetrics` both read). That is the second half of
|
||||
/// new card gets (`CardFaceView`, via the `BoardMetrics` both read). That is the second half of
|
||||
/// 02-architecture.md ▸ TransientBoardState ▸ overlays' one-arrival rule — the first half is
|
||||
/// `LaneSlot` keying a committed placeholder by the arriving card's identity, which makes the echo
|
||||
/// reload a content swap inside one persistent element; drawing that content identically is what
|
||||
@@ -953,6 +993,13 @@ private struct NewCardStubView: View {
|
||||
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`).
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
/// The live body metric — the same one the real face reads, which is what makes "the numbers are
|
||||
/// the same numbers rather than equal ones" survive the move to font-derived metrics.
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
|
||||
var body: some View {
|
||||
switch phase {
|
||||
case .editing: editor
|
||||
@@ -978,23 +1025,32 @@ private struct NewCardStubView: View {
|
||||
)
|
||||
.font(.body)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(CardFaceMetrics.contentPadding)
|
||||
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.fill(.background.secondary)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius)
|
||||
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5)
|
||||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
// Increase Contrast takes the well to full strength and a point heavier — a 60%-alpha
|
||||
// outline is exactly the kind of border the setting exists to rescue
|
||||
// (`Accommodations`).
|
||||
.strokeBorder(
|
||||
Color.accentColor.opacity(Accommodations.accentOpacity(0.6, contrast: contrast)),
|
||||
lineWidth: Accommodations.borderWidth(1.5, contrast: contrast)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// **The arriving card, drawn a round trip early.** Every line below has a counterpart in
|
||||
/// `CardFaceView.body`/`titleRow`, and the numbers are the same numbers rather than equal ones
|
||||
/// (`CardFaceMetrics`) — when the echo reload swaps this view for the real face inside the one
|
||||
/// (`BoardMetrics`) — when the echo reload swaps this view for the real face inside the one
|
||||
/// slot they share, nothing about the plate, the icon, the font or the title's position changes.
|
||||
///
|
||||
/// No stripe overlay and no selection stroke: both would be `.clear` for a default, unselected
|
||||
/// new card, and a shape that paints nothing is better left unwritten than written and disabled.
|
||||
private var arrivingFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.card)
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
@@ -1004,9 +1060,12 @@ private struct NewCardStubView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(CardFaceMetrics.contentPadding)
|
||||
.padding(.leading, CardFaceMetrics.stripeWidth)
|
||||
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.padding(.leading, BoardMetrics.cardStripeWidth(bodyPointSize: pointSize))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.fill(.background.secondary)
|
||||
)
|
||||
}
|
||||
|
||||
/// Commits, then **re-selects the lane** — "Return commits and re-selects the lane (next Return
|
||||
|
||||
@@ -90,12 +90,23 @@ struct TrashLaneView: View {
|
||||
/// specifically ("and trash animations all get reduced variants").
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Reduce Transparency, for the column's two washes below — "glass underlays go solid, wherever
|
||||
/// they appear" (10-accessibility.md). The trash plate and its hatched header are the board's
|
||||
/// only translucent surfaces, and they sit over a background the *user* chose (03-board-ui.md §
|
||||
/// Styling), which is exactly the case the setting exists for (`Accommodations.Wash`).
|
||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||
|
||||
/// The live body metric — this column's geometry is `LaneView`'s, derived from the same font
|
||||
/// (`BoardMetrics`), because these are the same cards in a column that must read as their
|
||||
/// sibling.
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
|
||||
/// The lane plate's corner radius — matched to `LaneView`'s so the column reads as a sibling of
|
||||
/// the lanes rather than as a different kind of object.
|
||||
private let cornerRadius: CGFloat = 10
|
||||
private var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) }
|
||||
|
||||
/// Between the cards — `LaneView.cardSpacing`, because these are the same cards.
|
||||
private let cardSpacing: CGFloat = 8
|
||||
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -104,7 +115,7 @@ struct TrashLaneView: View {
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(.quaternary.opacity(0.35))
|
||||
.fill(Accommodations.trashPlateWash(reduceTransparency: reduceTransparency).style)
|
||||
)
|
||||
// **The delete gesture's drop target**, over the whole column — the header included, since
|
||||
// the ruling is "dropping a live card on the shown trash", not on one of its rows. It
|
||||
@@ -186,7 +197,7 @@ struct TrashLaneView: View {
|
||||
/// It carries no gesture at all: no selection (the column "is never selectable as a lane"), no
|
||||
/// reorder drag, no context menu.
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
@@ -198,13 +209,16 @@ struct TrashLaneView: View {
|
||||
countBadge
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: pointSize))
|
||||
.padding(.vertical, BoardMetrics.trashHeaderVerticalPadding(bodyPointSize: pointSize))
|
||||
.background {
|
||||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
|
||||
.fill(.quaternary.opacity(0.5))
|
||||
.fill(Accommodations.trashHeaderWash(reduceTransparency: reduceTransparency).style)
|
||||
.overlay {
|
||||
DiagonalHatch()
|
||||
// The hatch pitch scales with the text (`BoardMetrics.trashHatchSpacing`): this
|
||||
// is the trash's **non-colour** distinction, and a fixed 7pt pitch behind a
|
||||
// headline twice its usual size reads as a texture rather than as hatching.
|
||||
DiagonalHatch(spacing: BoardMetrics.trashHatchSpacing(bodyPointSize: pointSize))
|
||||
.stroke(.quaternary, lineWidth: 1)
|
||||
.clipShape(UnevenRoundedRectangle(
|
||||
topLeadingRadius: cornerRadius,
|
||||
@@ -224,8 +238,8 @@ struct TrashLaneView: View {
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
|
||||
.padding(.vertical, BoardMetrics.badgeVerticalPadding(bodyPointSize: pointSize))
|
||||
.background(Capsule().fill(.quaternary))
|
||||
}
|
||||
|
||||
@@ -274,7 +288,7 @@ struct TrashLaneView: View {
|
||||
// The delete gesture's shadow, holding the topmost row open
|
||||
// (04-interactions.md ▸ The trash). At the nominal card height: the cards
|
||||
// being proposed have no face here yet to be measured.
|
||||
DragShadow(cornerRadius: CardFaceMetrics.cornerRadius)
|
||||
DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: LaneDropRegistry.nominalCardHeight)
|
||||
}
|
||||
@@ -306,7 +320,7 @@ struct TrashLaneView: View {
|
||||
// column's gesture surface is full height" (04-interactions.md ▸ The trash, settled)
|
||||
// needs that blank space to actually belong to the view the gesture below is on.
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(6)
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
.contentShape(Rectangle())
|
||||
// The band's trash-side surface. It arms from the column's empty space, full height
|
||||
// (above) included, so a drag can start from the blank area below the last card exactly
|
||||
@@ -354,8 +368,12 @@ private enum TrashSlot: Identifiable {
|
||||
///
|
||||
/// The lines start a full header-height to the left of the leading edge so the first stroke reaches
|
||||
/// the top-left corner instead of beginning partway across.
|
||||
///
|
||||
/// The pitch is the caller's (`BoardMetrics.trashHatchSpacing`), because it scales with the text:
|
||||
/// the hatch is the trash's non-colour distinction (10-accessibility.md's never-colour-alone rule),
|
||||
/// and a distinction that stops reading at a large text size is not one.
|
||||
private struct DiagonalHatch: Shape {
|
||||
var spacing: CGFloat = 7
|
||||
var spacing: CGFloat
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
|
||||
Reference in New Issue
Block a user