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:
@@ -0,0 +1,181 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// The system's **visual accommodations**, as one named surface — `Motion`'s sibling
|
||||
/// (10-accessibility.md ▸ Text scaling & visual accommodations). Motion owns Reduce Motion; this owns
|
||||
/// the other two settings the design commits to, plus the one that decides whether a focus ring is
|
||||
/// furniture or a lifeline:
|
||||
///
|
||||
/// - **Increase Contrast** — "strengthens borders and the selection indicator";
|
||||
/// - **Reduce Transparency** — "glass underlays go solid, wherever they appear";
|
||||
/// - **Full Keyboard Access** — "the board is one tab stop with arrow-key navigation within".
|
||||
///
|
||||
/// The rule this type exists to enforce is `Motion`'s, transplanted: **no call site anywhere decides
|
||||
/// for itself what an accommodation means.** A view that draws a border asks for a border width by
|
||||
/// meaning and passes in what the environment reports; what "increased" does to that width is
|
||||
/// decided once, here, so a new bordered surface inherits the answer instead of inventing one.
|
||||
///
|
||||
/// ### Why the decisions are pure functions of an environment value
|
||||
///
|
||||
/// Same reason `Motion.reloadAnimates` is: so a test can hold them still. Nothing below renders, and
|
||||
/// the claims 10-accessibility.md actually makes — a heavier ring under Increase Contrast, a solid
|
||||
/// underlay under Reduce Transparency — are assertable only if the decision is separable from the
|
||||
/// drawing. The two `AnyShapeStyle`-producing families therefore go through small `Equatable` enums
|
||||
/// (`Underlay`, `Wash`) exactly as the transitions go through `Motion.Appearance`, because
|
||||
/// `AnyShapeStyle` is opaque and a claim about it would be untestable.
|
||||
///
|
||||
/// ### Where the values come from
|
||||
///
|
||||
/// Views read `@Environment(\.colorSchemeContrast)` and `@Environment(\.accessibilityReduceTransparency)`
|
||||
/// and pass them in. Code with no environment to read asks AppKit the same questions
|
||||
/// (`prefersIncreasedContrast`, `prefersReducedTransparency`) — `Motion.prefersReducedMotion`'s
|
||||
/// pattern, for its reason.
|
||||
enum Accommodations {
|
||||
|
||||
// MARK: - Increase Contrast
|
||||
|
||||
/// A stroke's width: `base` normally, **one point heavier** when the user has asked for stronger
|
||||
/// borders (10-accessibility.md: "Increase Contrast strengthens borders and the selection
|
||||
/// indicator").
|
||||
///
|
||||
/// One point rather than a multiplier, deliberately. The board's strokes span 1pt (a well's
|
||||
/// separator hairline) to 3pt (the template chooser's selection frame), and a factor that made
|
||||
/// the hairline legible would turn the chooser's frame into a slab. A flat point is what the
|
||||
/// system's own controls do under the setting, and it is monotone: a heavier stroke stays
|
||||
/// heavier than a lighter one, so the visual hierarchy the widths encode survives the setting.
|
||||
///
|
||||
/// It is *not* scaled by the text size, and that is a ruling rather than an oversight: a border
|
||||
/// is a hairline against a background, not a glyph — AppKit's own controls keep their stroke
|
||||
/// weights across text sizes, and a 3pt selection ring at a large text size would read as a
|
||||
/// fill.
|
||||
static func borderWidth(_ base: CGFloat, contrast: ColorSchemeContrast) -> CGFloat {
|
||||
contrast == .increased ? base + 1 : base
|
||||
}
|
||||
|
||||
/// Whether a plate that normally floats on its fill alone draws an **outline** at all.
|
||||
///
|
||||
/// This is the other half of "strengthens borders", and the half that is easy to miss: a card
|
||||
/// face and a lane plate carry no resting border — they are a fill against the board background,
|
||||
/// which is exactly the distinction Increase Contrast exists to rescue for a user who cannot see
|
||||
/// it. So under the setting they gain a hairline in the separator colour, and the selection ring
|
||||
/// above stays what it always was: the *accent*-coloured one, still unambiguous against it.
|
||||
///
|
||||
/// Chrome, never information: nothing about the board's meaning changes, so nothing has to be
|
||||
/// said differently to VoiceOver when this flips.
|
||||
static func drawsRestingBorder(contrast: ColorSchemeContrast) -> Bool {
|
||||
contrast == .increased
|
||||
}
|
||||
|
||||
/// A decorative accent drawn at reduced alpha — the marquee band's border, the drag shadow's
|
||||
/// dashes, the new-card editor's well — taken to **full strength** under Increase Contrast.
|
||||
///
|
||||
/// Alpha is the other way a border can be weak, and a width bump alone would leave a 55%-alpha
|
||||
/// dashed outline just as hard to see two points wider.
|
||||
static func accentOpacity(_ base: Double, contrast: ColorSchemeContrast) -> Double {
|
||||
contrast == .increased ? 1 : base
|
||||
}
|
||||
|
||||
/// Increase Contrast, asked of AppKit rather than of the SwiftUI environment — for callers built
|
||||
/// outside a rendered hierarchy, where the environment's accessibility values are not reliably
|
||||
/// populated (`Motion.prefersReducedMotion`'s constituency).
|
||||
@MainActor
|
||||
static var prefersIncreasedContrast: Bool {
|
||||
NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast
|
||||
}
|
||||
|
||||
// MARK: - Reduce Transparency
|
||||
|
||||
/// What a **glass underlay** is made of — a real material, or the solid the setting replaces it
|
||||
/// with (10-accessibility.md: "Reduce Transparency: glass underlays go solid, wherever they
|
||||
/// appear").
|
||||
///
|
||||
/// The design's own example (the card face carousel's page dots) died with the carousel
|
||||
/// (03-board-ui.md § Card face's no-carousel resettlement), so the rule's one surviving subject
|
||||
/// on the board is the transient search bar's `.bar` material. It is stated as a type anyway
|
||||
/// rather than inlined at that one call site, because "wherever they appear" is a standing rule
|
||||
/// and the next material to arrive should find the answer already written.
|
||||
enum Underlay: Equatable {
|
||||
/// `Material.bar` — the find-bar's own backdrop, translucent over the board beneath it.
|
||||
case glass
|
||||
/// The window's own background colour, opaque.
|
||||
case solid
|
||||
|
||||
var style: AnyShapeStyle {
|
||||
switch self {
|
||||
case .glass: AnyShapeStyle(.bar)
|
||||
case .solid: AnyShapeStyle(Color(nsColor: .windowBackgroundColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func underlay(reduceTransparency: Bool) -> Underlay {
|
||||
reduceTransparency ? .solid : .glass
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
/// the same user — the board's `background` is a colour the *user* chose (03-board-ui.md §
|
||||
/// Styling), so a 35%-alpha plate over a saturated board is exactly the "what is behind this"
|
||||
/// problem Reduce Transparency exists to remove. Under the setting each one takes the standard
|
||||
/// secondary background instead, which is opaque and appearance-aware.
|
||||
enum Wash: Equatable {
|
||||
/// `.quaternary` at `opacity`, over whatever is behind.
|
||||
case translucent(opacity: Double)
|
||||
/// The standard secondary background — opaque, so nothing shows through.
|
||||
case opaque
|
||||
|
||||
var style: AnyShapeStyle {
|
||||
switch self {
|
||||
case let .translucent(opacity): AnyShapeStyle(HierarchicalShapeStyle.quaternary.opacity(opacity))
|
||||
case .opaque: AnyShapeStyle(.background.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trash column's plate — the quietest of the three, since the header above it carries the
|
||||
/// column's identity.
|
||||
static func trashPlateWash(reduceTransparency: Bool) -> Wash {
|
||||
reduceTransparency ? .opaque : .translucent(opacity: 0.35)
|
||||
}
|
||||
|
||||
/// The trash column's hatched header. Heavier than the plate, because it is the whole of "you
|
||||
/// are looking at the trash" (03-board-ui.md § Trash ▸ Rendering).
|
||||
static func trashHeaderWash(reduceTransparency: Bool) -> Wash {
|
||||
reduceTransparency ? .opaque : .translucent(opacity: 0.5)
|
||||
}
|
||||
|
||||
/// A drop shadow's fill — the outline occupying an item's proposed landing spot (`DragShadow`).
|
||||
static func dragShadowWash(reduceTransparency: Bool) -> Wash {
|
||||
reduceTransparency ? .opaque : .translucent(opacity: 0.5)
|
||||
}
|
||||
|
||||
/// Reduce Transparency, asked of AppKit — `prefersIncreasedContrast`'s twin, same constituency.
|
||||
@MainActor
|
||||
static var prefersReducedTransparency: Bool {
|
||||
NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency
|
||||
}
|
||||
|
||||
// MARK: - Full Keyboard Access
|
||||
|
||||
/// Whether the system's **Full Keyboard Access** is on.
|
||||
///
|
||||
/// It has one caller and one purpose: the board strip suppresses its focus ring, because "the
|
||||
/// strip is the window's content, not a control, and a rectangle around the whole board would
|
||||
/// read as an error state" (`BoardView`) — and that reasoning inverts completely under FKA,
|
||||
/// where 10-accessibility.md makes the board **one tab stop** and a tab stop nobody can see is
|
||||
/// not one. So the ring comes back exactly when Tab can land on it.
|
||||
///
|
||||
/// There is no SwiftUI environment value for this and no change notification to observe, so it
|
||||
/// is read at body evaluation like any other system query here. That is honest for what it is: a
|
||||
/// setting a user turns on once (⌃F7, or System Settings ▸ Keyboard), not one that flips during
|
||||
/// a gesture — and a board window re-renders on nearly every interaction, so a flip is picked up
|
||||
/// almost immediately rather than never.
|
||||
@MainActor
|
||||
static var isFullKeyboardAccessEnabled: Bool {
|
||||
NSApp?.isFullKeyboardAccessEnabled ?? false
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,15 @@ public struct BannerStripView: View {
|
||||
/// How many rows show before the disclosure takes over.
|
||||
private static let collapseThreshold = 3
|
||||
|
||||
/// The strip's own rhythm, font-derived like everything else the app lays out
|
||||
/// (`BoardMetrics`, 10-accessibility.md ▸ Text scaling: "no fixed point sizes"). It matters here
|
||||
/// because a banner row *wraps* rather than truncating — the cause tail is the half that says
|
||||
/// what to do — so a fixed inset around growing text crowds fast.
|
||||
@MainActor static var rowHorizontalPadding: CGFloat { BoardMetrics.em(0.9, bodyPointSize: pointSize) }
|
||||
@MainActor static var rowVerticalPadding: CGFloat { BoardMetrics.em(0.6, bodyPointSize: pointSize) }
|
||||
@MainActor static var rowSpacing: CGFloat { BoardMetrics.em(0.6, bodyPointSize: pointSize) }
|
||||
@MainActor private static var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
|
||||
@State private var isExpanded = false
|
||||
|
||||
public init(rows: [BannerRow], onDismiss: @escaping (UUID) -> Void) {
|
||||
@@ -101,15 +110,15 @@ public struct BannerStripView: View {
|
||||
Button {
|
||||
isExpanded.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: BannerStripView.rowSpacing) {
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.imageScale(.small)
|
||||
Text(isExpanded ? "Show fewer" : "+\(hiddenCount) more")
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.callout)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, BannerStripView.rowHorizontalPadding)
|
||||
.padding(.vertical, BannerStripView.rowSpacing)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -127,7 +136,7 @@ private struct BannerRowView: View {
|
||||
let onDismiss: (UUID) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BannerStripView.rowVerticalPadding) {
|
||||
leading
|
||||
|
||||
Text(row.headline)
|
||||
@@ -139,8 +148,8 @@ private struct BannerRowView: View {
|
||||
|
||||
trailingControls
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, BannerStripView.rowHorizontalPadding)
|
||||
.padding(.vertical, BannerStripView.rowVerticalPadding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(row.tone.fill)
|
||||
// One element per row, tone included: a VoiceOver user must hear *that* this is an error
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -55,7 +55,10 @@ struct CardStyleSection: View {
|
||||
store: store,
|
||||
recents: recents,
|
||||
target: Self.target(forCard: cardID),
|
||||
layout: .sidebar(contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize))
|
||||
layout: .sidebar(
|
||||
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
|
||||
bodyPointSize: pointSize
|
||||
)
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
+151
-46
@@ -104,12 +104,37 @@ enum CuratedSymbols {
|
||||
/// that makes the first one narrow enough to sit beside a card would overflow the second by 70
|
||||
/// points. Nothing about *behavior* is in here: every well, every write, the batch display and the
|
||||
/// keyboard grammar are the editor's, identical at every anchor. Only the geometry moves.
|
||||
///
|
||||
/// ### Everything here scales with the body font
|
||||
///
|
||||
/// A well is a **container for a glyph**, and the glyph inside it is drawn at `.imageScale(.medium)`
|
||||
/// — a relative size. So a well fixed at 20 points would be overrun by its own symbol at a large
|
||||
/// system text size, and 10-accessibility.md's "every well Tab-reachable and labeled by name" would
|
||||
/// be true of controls the user could no longer read. Every figure below is therefore a multiple of
|
||||
/// the body point size, chosen to reproduce today's numbers at the standard 13pt body — the same
|
||||
/// derivation, and the same rationale, as `BoardMetrics` and `CardWindowMetrics`.
|
||||
struct StyleEditorLayout: Equatable {
|
||||
|
||||
/// One well's side, and the gap between two — the numbers the grids are laid out on, named once
|
||||
/// so the fit rule below and the wells themselves cannot drift apart.
|
||||
static let wellSide: CGFloat = 20
|
||||
static let wellSpacing: CGFloat = 6
|
||||
/// One well's side, and the gap between two — the numbers the grids are laid out on, derived
|
||||
/// once so the fit rule below and the wells themselves cannot drift apart.
|
||||
///
|
||||
/// 1.55 em and 0.45 em: 20pt and 6pt at the standard 13pt body, which is what the grids have
|
||||
/// always drawn.
|
||||
static func wellSide(bodyPointSize: CGFloat) -> CGFloat {
|
||||
max(1, (bodyPointSize * 1.55).rounded())
|
||||
}
|
||||
|
||||
static func wellSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
||||
max(1, (bodyPointSize * 0.45).rounded())
|
||||
}
|
||||
|
||||
/// The gap between the editor's two sections — and, at the popover anchor, its inset too, which
|
||||
/// is why it is one figure rather than two that happen to agree. The *sidebar* anchor brings no
|
||||
/// inset of its own (its column is already gutted) but still wants the sections apart, so the
|
||||
/// spacing has to survive `padding` going to zero.
|
||||
static func sectionSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
||||
max(1, (bodyPointSize * 1.08).rounded())
|
||||
}
|
||||
|
||||
/// A fixed width, or `nil` to take whatever the anchor proposes.
|
||||
var width: CGFloat?
|
||||
@@ -120,6 +145,11 @@ struct StyleEditorLayout: Equatable {
|
||||
/// How tall the symbol grid may grow before it scrolls inside itself, or `nil` for "never" —
|
||||
/// the grid then draws whole and the anchor scrolls it.
|
||||
var symbolGridMaximumHeight: CGFloat?
|
||||
/// The well geometry this layout's grids draw on — carried on the value rather than read from
|
||||
/// the statics above, so a view has one thing to consult and the two can never disagree about
|
||||
/// which text size they were computed for.
|
||||
var wellSide: CGFloat
|
||||
var wellSpacing: CGFloat
|
||||
|
||||
/// The Style… popover and the board popover's styling area: a fixed frame, its own padding, and
|
||||
/// a symbol grid that scrolls within it.
|
||||
@@ -128,13 +158,21 @@ struct StyleEditorLayout: Equatable {
|
||||
/// enough to sit beside a card without covering the lane it came from; the symbol grid's cap is
|
||||
/// eight rows or so — enough that it reads as a set rather than as a strip, short enough that the
|
||||
/// popover fits beside a card on a laptop screen.
|
||||
static let popover = StyleEditorLayout(
|
||||
width: 268,
|
||||
padding: 14,
|
||||
backgroundColumns: 7,
|
||||
symbolColumns: 8,
|
||||
symbolGridMaximumHeight: 168
|
||||
)
|
||||
///
|
||||
/// The column counts are the design's own and stay fixed at every text size — 03-board-ui.md
|
||||
/// names the 7 + 6 fall — while the *frame* around them grows, which is what keeps the 7 wells
|
||||
/// inside it (20.6 em is 268pt at the standard body size, the number 03 settled on).
|
||||
static func popover(bodyPointSize: CGFloat) -> StyleEditorLayout {
|
||||
StyleEditorLayout(
|
||||
width: (bodyPointSize * 20.6).rounded(),
|
||||
padding: sectionSpacing(bodyPointSize: bodyPointSize),
|
||||
backgroundColumns: 7,
|
||||
symbolColumns: 8,
|
||||
symbolGridMaximumHeight: (bodyPointSize * 12.9).rounded(),
|
||||
wellSide: wellSide(bodyPointSize: bodyPointSize),
|
||||
wellSpacing: wellSpacing(bodyPointSize: bodyPointSize)
|
||||
)
|
||||
}
|
||||
|
||||
/// The card window's sidebar section (05-card-window.md ▸ Style).
|
||||
///
|
||||
@@ -146,21 +184,29 @@ struct StyleEditorLayout: Equatable {
|
||||
/// a grid with wells the pointer cannot reach.
|
||||
/// - **The symbol grid does not scroll.** The sidebar is already a scroll view, and a scroll view
|
||||
/// inside a scroll view is a scroll view that fights (`CardWindowView`'s rule, for its reason).
|
||||
static func sidebar(contentWidth: CGFloat) -> StyleEditorLayout {
|
||||
let columns = columns(fitting: contentWidth)
|
||||
static func sidebar(contentWidth: CGFloat, bodyPointSize: CGFloat) -> StyleEditorLayout {
|
||||
let columns = columns(fitting: contentWidth, bodyPointSize: bodyPointSize)
|
||||
return StyleEditorLayout(
|
||||
width: nil,
|
||||
padding: 0,
|
||||
backgroundColumns: columns,
|
||||
symbolColumns: columns,
|
||||
symbolGridMaximumHeight: nil
|
||||
symbolGridMaximumHeight: nil,
|
||||
wellSide: wellSide(bodyPointSize: bodyPointSize),
|
||||
wellSpacing: wellSpacing(bodyPointSize: bodyPointSize)
|
||||
)
|
||||
}
|
||||
|
||||
/// How many wells fit across `width` — `n` wells and `n - 1` gaps, floored, and never less than
|
||||
/// one. Pure, and the whole of "the grid never overflows the column it was given".
|
||||
static func columns(fitting width: CGFloat) -> Int {
|
||||
max(1, Int((width + wellSpacing) / (wellSide + wellSpacing)))
|
||||
///
|
||||
/// Both the column and the wells grow with the text size, so the count stays roughly stable
|
||||
/// across text sizes rather than collapsing to one: `CardWindowMetrics`' sidebar is 26 body
|
||||
/// *characters* wide and a well is 1.55 body *ems*, and the ratio between those does not move.
|
||||
static func columns(fitting width: CGFloat, bodyPointSize: CGFloat) -> Int {
|
||||
let side = wellSide(bodyPointSize: bodyPointSize)
|
||||
let spacing = wellSpacing(bodyPointSize: bodyPointSize)
|
||||
return max(1, Int((width + spacing) / (side + spacing)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,20 +233,28 @@ struct StyleEditorView: View {
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
let target: StyleTarget
|
||||
/// The anchor's geometry, and nothing else (`StyleEditorLayout`). Defaulted to the popover's, so
|
||||
/// the two anchors that were here first say nothing about it.
|
||||
var layout: StyleEditorLayout = .popover
|
||||
/// The anchor's geometry, and nothing else (`StyleEditorLayout`). `nil` takes the popover's, so
|
||||
/// the two anchors that were here first say nothing about it — resolved in `body` rather than
|
||||
/// defaulted in the declaration, because the popover's geometry now depends on the live text
|
||||
/// size and a default argument cannot read one.
|
||||
var layout: StyleEditorLayout?
|
||||
|
||||
/// The live body metric, read here rather than passed in — `CardStyleSection`'s pattern, so
|
||||
/// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling
|
||||
/// rule).
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
|
||||
var body: some View {
|
||||
let layout = self.layout ?? .popover(bodyPointSize: pointSize)
|
||||
let subjects = store.styleSubjects(of: target)
|
||||
let background = StyleFieldState.resolve(subjects.map(\.background))
|
||||
let icon = StyleFieldState.resolve(subjects.map(\.icon))
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: StyleEditorLayout.sectionSpacing(bodyPointSize: pointSize)) {
|
||||
targetCaption(count: subjects.count)
|
||||
backgroundSection(background)
|
||||
backgroundSection(background, layout: layout)
|
||||
Divider()
|
||||
symbolSection(icon)
|
||||
symbolSection(icon, layout: layout)
|
||||
}
|
||||
.padding(layout.padding)
|
||||
.frame(width: layout.width)
|
||||
@@ -231,12 +285,13 @@ struct StyleEditorView: View {
|
||||
|
||||
/// The twelve palette wells and their leading None (03-board-ui.md § Styling ▸ Controls:
|
||||
/// "palette-only in-app … plus a leading **None** well that removes the `background` key").
|
||||
private func backgroundSection(_ state: StyleFieldState) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionHeader("Background", current: backgroundCurrent(state))
|
||||
private func backgroundSection(_ state: StyleFieldState, layout: StyleEditorLayout) -> some View {
|
||||
VStack(alignment: .leading, spacing: layout.wellSpacing) {
|
||||
sectionHeader("Background", current: backgroundCurrent(state), layout: layout)
|
||||
StyleWellGrid(
|
||||
wells: backgroundWells(state),
|
||||
columns: layout.backgroundColumns,
|
||||
layout: layout,
|
||||
apply: { change in
|
||||
StyleCommand.apply(background: change, to: target, in: store, recents: recents)
|
||||
}
|
||||
@@ -272,12 +327,12 @@ struct StyleEditorView: View {
|
||||
|
||||
/// The curated grid and its leading default well — "its leading well is the level's default
|
||||
/// symbol and removes the `icon` key" (§ Controls).
|
||||
private func symbolSection(_ state: StyleFieldState) -> some View {
|
||||
private func symbolSection(_ state: StyleFieldState, layout: StyleEditorLayout) -> some View {
|
||||
let level = store.styleLevel(of: target)
|
||||
let fallback = ItemSymbol.default(for: level)
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback))
|
||||
symbolGrid(state, fallback: fallback)
|
||||
return VStack(alignment: .leading, spacing: layout.wellSpacing) {
|
||||
sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback), layout: layout)
|
||||
symbolGrid(state, fallback: fallback, layout: layout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,10 +340,11 @@ struct StyleEditorView: View {
|
||||
/// (`StyleEditorLayout.symbolGridMaximumHeight`), and the one shape difference between the
|
||||
/// popover and the card sidebar.
|
||||
@ViewBuilder
|
||||
private func symbolGrid(_ state: StyleFieldState, fallback: String) -> some View {
|
||||
private func symbolGrid(_ state: StyleFieldState, fallback: String, layout: StyleEditorLayout) -> some View {
|
||||
let grid = StyleWellGrid(
|
||||
wells: symbolWells(state, fallback: fallback),
|
||||
columns: layout.symbolColumns,
|
||||
layout: layout,
|
||||
apply: { change in
|
||||
StyleCommand.apply(icon: change, to: target, in: store, recents: recents)
|
||||
}
|
||||
@@ -331,13 +387,15 @@ struct StyleEditorView: View {
|
||||
|
||||
// MARK: - Section chrome
|
||||
|
||||
private func sectionHeader(_ title: String, current: CurrentValue) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
private func sectionHeader(_ title: String, current: CurrentValue, layout: StyleEditorLayout) -> some View {
|
||||
HStack(spacing: layout.wellSpacing) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer(minLength: 8)
|
||||
Spacer(minLength: layout.wellSpacing)
|
||||
if let face = current.face {
|
||||
StyleWellFace(face: face, size: 14)
|
||||
// A touch smaller than a well: this is a *statement* of the current value, not a
|
||||
// control, and it must not read as a fourteenth swatch that can be clicked.
|
||||
StyleWellFace(face: face, size: (layout.wellSide * 0.7).rounded())
|
||||
}
|
||||
Text(current.text)
|
||||
.font(.caption)
|
||||
@@ -398,7 +456,12 @@ private struct StyleWellFace: View {
|
||||
}
|
||||
|
||||
let face: Face
|
||||
var size: CGFloat = StyleEditorLayout.wellSide
|
||||
/// The well's side, supplied by the caller because it is font-derived and the caller is the one
|
||||
/// holding the layout it came from (`StyleEditorLayout.wellSide`).
|
||||
let size: CGFloat
|
||||
|
||||
/// Increase Contrast, for the swatch's border below (10-accessibility.md; `Accommodations`).
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var body: some View {
|
||||
switch face {
|
||||
@@ -417,13 +480,30 @@ private struct StyleWellFace: View {
|
||||
/// invisible control on a light popover (10-accessibility.md's contrast stance turned on the
|
||||
/// app's own chrome). A `nil` colour adds the diagonal strike that means "none".
|
||||
private func swatch(_ color: Color?) -> some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(color ?? Color(nsColor: .textBackgroundColor))
|
||||
.overlay { if color == nil { NoValueStrike().stroke(.secondary, lineWidth: 1) } }
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(.separator, lineWidth: 1))
|
||||
.overlay {
|
||||
if color == nil {
|
||||
NoValueStrike(inset: strikeInset)
|
||||
.stroke(.secondary, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
|
||||
}
|
||||
}
|
||||
// A point heavier under Increase Contrast — this hairline is the *only* thing separating
|
||||
// a `chalk` well from the popover it sits on, which is the same reason it is drawn at all
|
||||
// (10-accessibility.md's contrast stance turned on the app's own chrome).
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
|
||||
)
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
|
||||
/// The swatch's radius and its "none" strike's inset, as fractions of the well — so both follow
|
||||
/// the well when the text size grows it (10-accessibility.md's full-relative-scaling rule).
|
||||
private var cornerRadius: CGFloat { max(1, (size * 0.2).rounded()) }
|
||||
|
||||
private var strikeInset: CGFloat { max(1, (size * 0.15).rounded()) }
|
||||
|
||||
private func glyph(_ name: String, tint: AnyShapeStyle) -> some View {
|
||||
Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
|
||||
.imageScale(.medium)
|
||||
@@ -435,10 +515,14 @@ private struct StyleWellFace: View {
|
||||
/// The corner-to-corner slash on the None well — the pathfinder's swatch vocabulary, kept because it
|
||||
/// is also the system's (an empty colour well slashes in Finder's own tag editor).
|
||||
private struct NoValueStrike: Shape {
|
||||
/// How far in from each corner the stroke starts — a fraction of the well, supplied by the
|
||||
/// caller, so it follows the well when the text size grows it.
|
||||
let inset: CGFloat
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX + 3, y: rect.maxY - 3))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - 3, y: rect.minY + 3))
|
||||
path.move(to: CGPoint(x: rect.minX + inset, y: rect.maxY - inset))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - inset, y: rect.minY + inset))
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -456,31 +540,44 @@ private struct StyleWellGrid: View {
|
||||
|
||||
let wells: [StyleWell]
|
||||
let columns: Int
|
||||
/// The anchor's geometry — the well side and spacing this grid lays out on
|
||||
/// (`StyleEditorLayout`, all font-derived).
|
||||
let layout: StyleEditorLayout
|
||||
let apply: (StyleChange) -> Void
|
||||
|
||||
@FocusState private var focused: Int?
|
||||
|
||||
/// Increase Contrast, for the selection ring below — 10-accessibility.md names the selection
|
||||
/// indicator specifically, and this is the style editor's ("the current value is stated by
|
||||
/// trait", whose visible half is this ring).
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(.flexible(minimum: StyleEditorLayout.wellSide), spacing: StyleEditorLayout.wellSpacing),
|
||||
repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
|
||||
count: columns
|
||||
),
|
||||
spacing: StyleEditorLayout.wellSpacing
|
||||
spacing: layout.wellSpacing
|
||||
) {
|
||||
ForEach(wells) { well in
|
||||
Button {
|
||||
apply(well.change)
|
||||
} label: {
|
||||
StyleWellFace(face: well.face)
|
||||
StyleWellFace(face: well.face, size: layout.wellSide)
|
||||
.overlay(selectionRing(well.isSelected))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// **Every well is Tab-reachable and labeled by name** (10-accessibility.md ▸ Style
|
||||
// editor). The focus ring is deliberately *not* disabled here, unlike the board
|
||||
// strip's: a well is a control, and Full Keyboard Access has to be able to show
|
||||
// which one Tab landed on.
|
||||
.focusable()
|
||||
.focused($focused, equals: well.id)
|
||||
.help(well.label)
|
||||
.accessibilityLabel(well.label)
|
||||
// "The current value is stated by trait" — never by the highlight alone.
|
||||
.accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
@@ -489,10 +586,18 @@ private struct StyleWellGrid: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected well's ring — a point heavier under Increase Contrast, which is 10's
|
||||
/// "strengthens … the selection indicator" landing on the one selection indicator this component
|
||||
/// has (`Accommodations`). The trait beside it is what makes the state readable without it.
|
||||
private func selectionRing(_ isSelected: Bool) -> some View {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 2)
|
||||
.padding(-2)
|
||||
RoundedRectangle(cornerRadius: max(1, (layout.wellSide * 0.25).rounded()))
|
||||
.strokeBorder(
|
||||
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||||
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
|
||||
)
|
||||
// Drawn *outside* the well, by the ring's own half-width, so a heavier ring under
|
||||
// Increase Contrast grows outward instead of eating into the swatch it is marking.
|
||||
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
|
||||
}
|
||||
|
||||
/// One step per press, clamped at the ends rather than wrapped: a grid whose last row is short
|
||||
|
||||
Reference in New Issue
Block a user