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:
2026-07-29 08:48:25 -04:00
parent c339b4cecf
commit 8564814754
21 changed files with 1419 additions and 218 deletions
+8 -1
View File
@@ -72,7 +72,14 @@ struct BoardWindowHost: View {
var body: some View {
content
.frame(minWidth: 640, minHeight: 400)
// Font-derived like everything else the board lays out (`BoardMetrics.windowMinimumSize`,
// 10-accessibility.md's full-relative-scaling rule): at a large system text size a
// 640×400 floor would be narrower than two lane headers, and "every lane is always on
// screen" would degrade into a strip of truncation.
.frame(
minWidth: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).width,
minHeight: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).height
)
.background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle)
.task { await start() }
+120 -18
View File
@@ -76,6 +76,19 @@ struct TemplateChooserView: View {
@State private var selection: TemplateRow.ID?
/// Which tile holds the keyboard.
///
/// **The grid's Full Keyboard Access wiring** (10-accessibility.md Full Keyboard Access: "every
/// control template chooser is Tab-reachable"). Before this the tiles were bare
/// `onTapGesture`s: the chooser could be Tabbed as far as Cancel and Choose, but the *choice*
/// itself was pointer-only, so a keyboard user could only ever create the default template.
///
/// Focus and selection are deliberately the same thing here, unlike the style editor's grids
/// where "selection is never implied by focus" because a well writes to disk. A tile writes
/// nothing it names what Choose will act on so moving focus onto one *is* choosing it, which
/// is how every list and icon grid on the system behaves.
@FocusState private var focusedRow: TemplateRow.ID?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")
/// The selected row, defaulting to the first which is Basic, the bundled tier's lowest order,
@@ -84,6 +97,24 @@ struct TemplateChooserView: View {
rows.first { $0.id == selection } ?? rows.first
}
// MARK: - Geometry
//
// Every figure the sheet lays out on, as a multiple of the body font `BoardMetrics`' rule
// applied to a window rather than to the board (10-accessibility.md Text scaling & visual
// accommodations). At the standard 13pt body they reproduce the numbers the chooser has always
// drawn: a 620 × 480 sheet, a 20pt inset, and tiles at least 170 points across.
@MainActor private static var pointSize: CGFloat { BoardMetrics.bodyPointSize }
@MainActor static var windowWidth: CGFloat { BoardMetrics.em(47.7, bodyPointSize: pointSize) }
@MainActor static var windowHeight: CGFloat { BoardMetrics.em(37, bodyPointSize: pointSize) }
@MainActor static var inset: CGFloat { BoardMetrics.em(1.55, bodyPointSize: pointSize) }
@MainActor static var tileMinimumWidth: CGFloat { BoardMetrics.em(13, bodyPointSize: pointSize) }
/// The width the grid actually gets the sheet minus its two insets. Used only by the arrow
/// handler, which needs a column count `.adaptive` never tells it.
@MainActor static var gridWidth: CGFloat { windowWidth - 2 * inset }
var body: some View {
VStack(spacing: 0) {
header
@@ -92,7 +123,11 @@ struct TemplateChooserView: View {
Divider()
footer
}
.frame(width: 620, height: 480)
// Font-derived, like every other frame in the app (10-accessibility.md Text scaling: "no
// fixed point sizes"). This is a *fixed* sheet the user cannot resize their way out of a
// clipped one so a 620×480 literal would put the header's two lines and the footer's blurb
// outside the window at a large system text size.
.frame(width: Self.windowWidth, height: Self.windowHeight)
.task {
rescan()
// Returning to the foreground is when a folder dropped into the revealed store becomes
@@ -135,27 +170,80 @@ struct TemplateChooserView: View {
}
.help("Reveal your templates folder in the Finder. Any board folder you put there becomes a template.")
}
.padding(20)
.padding(Self.inset)
}
// MARK: Grid
private var grid: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 170), spacing: 20)], spacing: 20) {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: Self.tileMinimumWidth), spacing: Self.inset)],
spacing: Self.inset
) {
ForEach(rows) { row in
TemplateCard(row: row, isSelected: row.id == selected?.id)
.onTapGesture { selection = row.id }
// The list convention welcome's recents use, for the same reason: a
// double click is how a chooser is answered without reaching for a button.
.onTapGesture(count: 2) { choose() }
.accessibilityAddTraits(row.id == selected?.id ? [.isSelected] : [])
// **Tab-reachable, and a button to the accessibility tree** the tile is
// the chooser's one act of choosing, so it has to be a control rather than a
// decorated rectangle that happens to answer clicks (10-accessibility.md
// Full Keyboard Access).
.focusable()
.focused($focusedRow, equals: row.id)
.accessibilityAddTraits(row.id == selected?.id ? [.isButton, .isSelected] : [.isButton])
// Space picks the focused tile the keyboard face of the single click above.
// Return is deliberately *not* handled here: it is the sheet's default action
// (Choose), and a tile that swallowed it would leave a keyboard user focused
// on their choice with no way to answer the chooser.
.onKeyPress(.space) {
selection = row.id
return .handled
}
}
.padding(20)
}
.padding(Self.inset)
// The arrows walk the tiles `StyleWellGrid`'s handler on the container, for its
// reason: a focused control does not consume arrow keys, so the press bubbles here and
// moving focus is all it does.
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
move(press.key)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color(nsColor: .controlBackgroundColor))
// Focus *is* selection in this grid (see `focusedRow`), so the two are kept in step in one
// direction only: moving focus names the choice, and a pointer click that named a choice
// leaves focus alone rather than yanking it out from under the keyboard.
.onChange(of: focusedRow) { _, focused in
guard let focused else { return }
selection = focused
}
}
/// One step per press, clamped at the ends rather than wrapped `StyleWellGrid.move`'s rule,
/// for its reason: a grid whose last row is short would wrap into a hole.
///
/// The vertical step is the grid's own column count, which `.adaptive` decides at layout time
/// and no one here can read. It is recomputed from the same two numbers the `GridItem` was built
/// from, so / land a row away rather than an arbitrary distance.
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
guard !rows.isEmpty else { return .ignored }
let columns = max(1, Int(Self.gridWidth / (Self.tileMinimumWidth + Self.inset)))
let delta: Int
switch key {
case .leftArrow: delta = -1
case .rightArrow: delta = 1
case .upArrow: delta = -columns
case .downArrow: delta = columns
default: return .ignored
}
let current = rows.firstIndex { $0.id == (focusedRow ?? selected?.id) } ?? 0
let next = min(max(0, current + delta), rows.count - 1)
focusedRow = rows[next].id
return .handled
}
// MARK: Footer
@@ -187,7 +275,7 @@ struct TemplateChooserView: View {
// An unloadable row "can't be instantiated or previewed" (09), which is this line.
.disabled(selected?.template == nil)
}
.padding(20)
.padding(Self.inset)
}
// MARK: - Reveal
@@ -297,16 +385,25 @@ private struct TemplateCard: View {
let row: TemplateRow
let isSelected: Bool
/// Increase Contrast, for the tile's frame below 10-accessibility.md names both halves of it
/// ("strengthens borders and the selection indicator"), and this one shape is both
/// (`Accommodations`).
@Environment(\.colorSchemeContrast) private var contrast
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
private var cornerRadius: CGFloat { BoardMetrics.em(0.6, bodyPointSize: pointSize) }
var body: some View {
VStack(spacing: 8) {
VStack(spacing: BoardMetrics.em(0.6, bodyPointSize: pointSize)) {
content
.frame(height: 96)
.frame(height: BoardMetrics.em(7.4, bodyPointSize: pointSize))
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 8).fill(Color(nsColor: .textBackgroundColor)))
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(Color(nsColor: .textBackgroundColor)))
.overlay(
RoundedRectangle(cornerRadius: 8)
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isSelected ? Color.accentColor : Color(nsColor: .separatorColor),
lineWidth: isSelected ? 3 : 1)
lineWidth: Accommodations.borderWidth(isSelected ? 3 : 1, contrast: contrast))
)
Label(row.name, systemImage: icon)
@@ -375,23 +472,28 @@ private struct TemplatePreview: View {
private static let laneLimit = 6
private static let cardLimit = 4
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
var body: some View {
HStack(alignment: .top, spacing: 5) {
// Every mark is a fraction of the body font, like the tile that holds it the preview is a
// miniature of the board, and the board scales (10-accessibility.md's full-relative-scaling
// rule). A fixed 5pt lane band inside a tile that grew would read as a hairline.
HStack(alignment: .top, spacing: BoardMetrics.em(0.4, bodyPointSize: pointSize)) {
ForEach(template.lanes.prefix(Self.laneLimit)) { lane in
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 2)
VStack(spacing: BoardMetrics.em(0.3, bodyPointSize: pointSize)) {
RoundedRectangle(cornerRadius: BoardMetrics.em(0.15, bodyPointSize: pointSize))
.fill(Self.tint(of: lane))
.frame(height: 5)
.frame(height: BoardMetrics.laneAccentBandHeight(bodyPointSize: pointSize))
ForEach(0 ..< min(lane.cards.count, Self.cardLimit), id: \.self) { _ in
RoundedRectangle(cornerRadius: 3)
RoundedRectangle(cornerRadius: BoardMetrics.em(0.25, bodyPointSize: pointSize))
.fill(.quaternary)
.frame(height: 12)
.frame(height: BoardMetrics.em(0.9, bodyPointSize: pointSize))
}
Spacer(minLength: 0)
}
}
}
.padding(10)
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.accessibilityHidden(true)
}
+50 -15
View File
@@ -38,6 +38,20 @@ struct WelcomeView: View {
/// Whether the recents list holds the keyboard, so Return can mean "open the selected row".
@FocusState private var listFocused: Bool
// MARK: - Geometry
//
// Every figure this window lays out on, as a multiple of the body font `BoardMetrics`' rule
// applied to the welcome window (10-accessibility.md Text scaling & visual accommodations). At
// the standard 13pt body they reproduce the numbers welcome has always drawn.
@MainActor static var pointSize: CGFloat { BoardMetrics.bodyPointSize }
@MainActor static var brandingColumnWidth: CGFloat { BoardMetrics.em(23, bodyPointSize: pointSize) }
@MainActor static var brandingInset: CGFloat { BoardMetrics.em(2.5, bodyPointSize: pointSize) }
@MainActor static var appIconSide: CGFloat { BoardMetrics.em(7.4, bodyPointSize: pointSize) }
@MainActor static var minimumWidth: CGFloat { BoardMetrics.em(58.5, bodyPointSize: pointSize) }
@MainActor static var minimumHeight: CGFloat { BoardMetrics.em(35.4, bodyPointSize: pointSize) }
private var derivation: WelcomeRow.Derivation {
WelcomeRow.derive(recents: appModel.recents, failures: appModel.launchFailures)
}
@@ -49,16 +63,20 @@ struct WelcomeView: View {
var body: some View {
HStack(spacing: 0) {
branding
.frame(width: 300)
.frame(width: Self.brandingColumnWidth)
.frame(maxHeight: .infinity)
.padding(32)
.padding(Self.brandingInset)
Divider()
recents
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minWidth: 760, minHeight: 460)
// Font-derived, like the board window's floor (10-accessibility.md Text scaling: "no fixed
// point sizes"): at a large system text size a 300-point branding column would clip the app
// name it exists to show, and a 760 × 460 floor would leave the recents list too narrow for
// the three lines each row carries. At the standard body size these are those numbers.
.frame(minWidth: Self.minimumWidth, minHeight: Self.minimumHeight)
// The window has no title bar, so the background is the drag handle. `.gesture` rather than
// `.highPriorityGesture`: a click on a row or a button belongs to the row or the button.
.gesture(WindowDragGesture())
@@ -76,12 +94,16 @@ struct WelcomeView: View {
VStack(alignment: .leading, spacing: 0) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 96, height: 96)
.frame(width: Self.appIconSide, height: Self.appIconSide)
.accessibilityHidden(true)
Text("Lanework")
.font(.system(size: 34, weight: .light))
.padding(.top, 12)
// A **relative** style, not a 34pt literal "relative text styles everywhere, no
// fixed point sizes" (10-accessibility.md Text scaling & visual accommodations).
// `.largeTitle` is the app name's register and it grows with the system text size;
// a fixed size would have stayed put while every line beneath it grew past it.
.font(.largeTitle.weight(.light))
.padding(.top, BoardMetrics.em(0.9, bodyPointSize: Self.pointSize))
Text(versionSummary)
.font(.callout)
@@ -90,11 +112,11 @@ struct WelcomeView: View {
Text("Folders and Markdown, on your terms.")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.top, 4)
.padding(.top, BoardMetrics.em(0.3, bodyPointSize: Self.pointSize))
Spacer(minLength: 24)
Spacer(minLength: BoardMetrics.em(1.85, bodyPointSize: Self.pointSize))
VStack(spacing: 8) {
VStack(spacing: BoardMetrics.em(0.6, bodyPointSize: Self.pointSize)) {
// The menu-bar twin of this button is File New Board (N) same action, and
// deliberately the same words, because a button and a menu item that differ read as
// two features.
@@ -216,7 +238,7 @@ struct WelcomeView: View {
}
.controlSize(.small)
}
.padding(16)
.padding(BoardMetrics.em(1.25, bodyPointSize: Self.pointSize))
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -249,7 +271,7 @@ private struct WelcomeActionButton: View {
Button(action: action) {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
.padding(.vertical, BoardMetrics.em(0.15, bodyPointSize: WelcomeView.pointSize))
}
.buttonStyle(.bordered)
.controlSize(.large)
@@ -271,9 +293,13 @@ private struct RecentBoardRow: View {
// lenient-fallback shape every other icon site in the app uses (`ItemSymbol`,
// `Palette`), applied here to the registry's cached string instead of a live snapshot.
Image(systemName: iconName)
.font(.system(size: 22))
// Relative, like every other size in this window (10-accessibility.md's
// full-relative-scaling rule): `.title` is the register a 22pt glyph occupied at the
// standard text size, and the well around it is derived from the body font so the
// glyph never outgrows it.
.font(.title)
.foregroundStyle(iconTint)
.frame(width: 34, height: 34)
.frame(width: iconWell, height: iconWell)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
@@ -292,13 +318,19 @@ private struct RecentBoardRow: View {
Spacer(minLength: 0)
}
.padding(.vertical, 4)
.padding(.vertical, BoardMetrics.em(0.3, bodyPointSize: WelcomeView.pointSize))
// Dimmed when the board cannot be reached the row stays, with Forget, rather than
// disappearing (02 § Graceful orphaning).
.opacity(row.isAvailable ? 1 : 0.55)
.accessibilityElement(children: .combine)
}
/// The square the row's glyph sits in font-derived so the icon column stays proportionate to
/// the three lines of text beside it at every system text size.
private var iconWell: CGFloat {
BoardMetrics.em(2.6, bodyPointSize: BoardMetrics.bodyPointSize)
}
/// `row.icon`'s symbol if it names one this system can draw, the board default otherwise
/// `ItemSymbol.name(_:fallback:)`'s rule, restated for a plain cached string rather than a
/// `FieldValue`: a record carries no `FieldValue`, so `missing`/`malformed`/`unrecognized`
@@ -365,7 +397,10 @@ struct SettingsView: View {
}
}
.formStyle(.grouped)
.frame(width: 420)
// Font-derived: this pane is `.fixedSize()`, so a 420-point literal would clip its one
// toggle's footer sentence at a large system text size with no way to resize out of it
// (10-accessibility.md Text scaling).
.frame(width: BoardMetrics.em(32.3, bodyPointSize: BoardMetrics.bodyPointSize))
.fixedSize()
}
}
+181
View File
@@ -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
}
}
+15 -6
View File
@@ -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
+11 -1
View File
@@ -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
+10 -3
View File
@@ -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
+246
View File
@@ -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
}
}
+14 -4
View File
@@ -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()
}
+5 -1
View File
@@ -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()
}
+37 -8
View File
@@ -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,7 +422,10 @@ struct BoardView: View {
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
ZStack(alignment: .topLeading) {
if resizing {
DragShadow(dashed: false)
DragShadow(
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize),
dashed: false
)
.frame(width: slotWidth)
.frame(maxHeight: .infinity)
}
+51 -34
View File
@@ -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
+27 -8
View File
@@ -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)
)
}
}
}
+7 -4
View File
@@ -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
+91 -32
View File
@@ -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
+31 -13
View File
@@ -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()
+4 -1
View File
@@ -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)
+148 -43
View File
@@ -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,
///
/// 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: 168
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
+55 -20
View File
@@ -335,26 +335,50 @@ struct CardStyleAnchorTests {
@Suite("Card sidebar ▸ style editor layout")
struct StyleEditorLayoutTests {
/// How wide `columns` wells and the gaps between them actually draw.
private func gridWidth(columns: Int) -> CGFloat {
CGFloat(columns) * StyleEditorLayout.wellSide + CGFloat(columns - 1) * StyleEditorLayout.wellSpacing
/// How wide `columns` wells and the gaps between them actually draw, at a given text size.
private func gridWidth(columns: Int, bodyPointSize: CGFloat) -> CGFloat {
CGFloat(columns) * StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize)
+ CGFloat(columns - 1) * StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
}
@Test("The popover anchor is unchanged by the sidebar's arrival")
@Test("The popover anchor keeps its settled geometry at the standard text size")
func thePopoverKeepsItsSettledGeometry() {
// 268 points and 7 + 6 background wells are 03-board-ui.md's own numbers ("narrow enough to
// sit beside a card"), and the two anchors that were here first must not have moved because a
// third one needed different ones.
#expect(StyleEditorLayout.popover.width == 268)
#expect(StyleEditorLayout.popover.padding == 14)
#expect(StyleEditorLayout.popover.backgroundColumns == 7)
#expect(StyleEditorLayout.popover.symbolColumns == 8)
#expect(StyleEditorLayout.popover.symbolGridMaximumHeight == 168)
// sit beside a card"). Making the anchor font-derived (10-accessibility.md Text scaling)
// must not have moved them at the size they were settled for the point of the multiples is
// that the default text size renders exactly what it always did.
let popover = StyleEditorLayout.popover(bodyPointSize: 13)
#expect(popover.width == 268)
#expect(popover.padding == 14)
#expect(popover.backgroundColumns == 7)
#expect(popover.symbolColumns == 8)
#expect(popover.symbolGridMaximumHeight == 168)
#expect(popover.wellSide == 20)
#expect(popover.wellSpacing == 6)
}
/// The other half of the same claim: **the popover grows with the text**, so its seven wells
/// still fit at a large system text size instead of overflowing a frame frozen at 268 points.
@Test("The popover's frame grows with the text, and its wells keep fitting inside it")
func thePopoverScales() {
var previousWidth: CGFloat = 0
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
let popover = StyleEditorLayout.popover(bodyPointSize: size)
#expect(popover.width! > previousWidth, "the popover must widen with the text at \(size)pt")
previousWidth = popover.width!
// The column counts are the design's and never move; what has to hold is that they
// still fit, insets included.
#expect(popover.backgroundColumns == 7)
let content = popover.width! - 2 * popover.padding
#expect(gridWidth(columns: 7, bodyPointSize: size) <= content, "7 wells overflow at \(size)pt")
#expect(gridWidth(columns: 8, bodyPointSize: size) <= content, "8 wells overflow at \(size)pt")
}
}
@Test("The sidebar anchor takes the column it is given and adds nothing to it")
func theSidebarBringsNoGeometryOfItsOwn() {
let layout = StyleEditorLayout.sidebar(contentWidth: 169)
let layout = StyleEditorLayout.sidebar(contentWidth: 169, bodyPointSize: 13)
// No width: the sidebar's is `CardWindowMetrics`' one decision. No padding: the section stack
// is already inset by a gutter, and insetting twice would narrow the grids for nothing.
@@ -369,15 +393,22 @@ struct StyleEditorLayoutTests {
func theGridsFitTheSidebar() {
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
let available = CardWindowMetrics.sidebarContentWidth(bodyPointSize: size)
let layout = StyleEditorLayout.sidebar(contentWidth: available)
let layout = StyleEditorLayout.sidebar(contentWidth: available, bodyPointSize: size)
#expect(layout.backgroundColumns == layout.symbolColumns, "one column count for one column")
// Fits a grid wider than its column puts the last well of every row out of reach of
// the pointer, at a text size nobody checks by eye.
#expect(gridWidth(columns: layout.backgroundColumns) <= available, "overflows at \(size)pt")
#expect(gridWidth(columns: layout.backgroundColumns, bodyPointSize: size) <= available,
"overflows at \(size)pt")
// And is maximal: one more well would not have fitted, so the wells are as large a set as
// the column can show rather than an arbitrary count that happened to be safe.
#expect(gridWidth(columns: layout.backgroundColumns + 1) > available, "under-packed at \(size)pt")
#expect(gridWidth(columns: layout.backgroundColumns + 1, bodyPointSize: size) > available,
"under-packed at \(size)pt")
// And the count is *stable* across text sizes, because the column and the wells scale on
// the same ruler the sidebar is 26 body characters wide and a well is 1.55 body ems,
// and neither of those ratios moves. Without that the grid would collapse to a strip at
// a large text size, which is a palette the user can no longer scan.
#expect(layout.backgroundColumns >= 4, "the grid collapsed to a strip at \(size)pt")
}
}
@@ -385,8 +416,11 @@ struct StyleEditorLayoutTests {
func theSidebarIsTheNarrowerAnchor() {
// Which is the entire reason this type exists: the popover's 7 wells across do not fit a
// 26-character column, so an editor with one hard-coded frame could not have both anchors.
let layout = StyleEditorLayout.sidebar(contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: 13))
#expect(layout.backgroundColumns < StyleEditorLayout.popover.backgroundColumns)
let layout = StyleEditorLayout.sidebar(
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: 13),
bodyPointSize: 13
)
#expect(layout.backgroundColumns < StyleEditorLayout.popover(bodyPointSize: 13).backgroundColumns)
#expect(layout.backgroundColumns >= 4, "a grid this narrow would be a strip, not a palette")
}
@@ -395,9 +429,10 @@ struct StyleEditorLayoutTests {
// Total over any width, because the caller is a layout system: a zero proposal during a
// window's first frame must not produce a grid of zero columns, which is a division by zero
// waiting in `LazyVGrid`.
#expect(StyleEditorLayout.columns(fitting: 0) == 1)
#expect(StyleEditorLayout.columns(fitting: -100) == 1)
#expect(StyleEditorLayout.columns(fitting: StyleEditorLayout.wellSide) == 1)
#expect(StyleEditorLayout.columns(fitting: 0, bodyPointSize: 13) == 1)
#expect(StyleEditorLayout.columns(fitting: -100, bodyPointSize: 13) == 1)
#expect(StyleEditorLayout.columns(fitting: StyleEditorLayout.wellSide(bodyPointSize: 13),
bodyPointSize: 13) == 1)
}
@Test("The sidebar's content width is the column minus its two gutters")
+302
View File
@@ -0,0 +1,302 @@
import CoreGraphics
import SwiftUI
import Testing
@testable import Kanban
/// The two seams m11's visual-accommodations card added, and the one it made font-derived
/// (10-accessibility.md Text scaling & visual accommodations):
///
/// 1. **`BoardMetrics`** the board strip's geometry as a pure function of the body point size, so
/// "Card face, lane header, and masonry metrics derive from font metrics, so layout survives the
/// largest system text sizes" is a fact a suite can assert rather than something to be verified by
/// eye at three text sizes. Nothing here renders; what is pinned is that the numbers *move*, that
/// they move in the right direction, and that they land on the board's settled figures at the
/// standard 13pt body which is the whole claim that making the board scale did not redesign it.
/// 2. **`Accommodations`** Increase Contrast and Reduce Transparency as decisions separable from
/// the drawing, `Motion`'s pattern for its reason (`AnyShapeStyle` is opaque, so a claim about a
/// fill is only testable through a small `Equatable` enum).
/// 3. **`LaneLayoutMath` under a scaled gap** the no-horizontal-scroll invariant, re-asserted at
/// the text sizes the gap now varies over. It is 03-board-ui.md's headline layout rule and the one
/// thing a font-derived gap could plausibly have broken.
// MARK: - The board's font-derived geometry
@Suite("Board metrics ▸ the standard text size is unchanged")
struct BoardMetricsSettledFiguresTests {
/// The system body font is 13pt at the standard macOS text size, and every multiple in
/// `BoardMetrics` was chosen to reproduce the board's existing numbers there.
///
/// This is the load-bearing test of the whole conversion: the milestone's mandate was to make the
/// board *scale*, not to move it, so a default-text-size board must lay out on exactly the
/// figures it laid out on before. A drift here is a visual regression nothing else would catch.
@Test("Every figure lands on the board's settled number at 13pt")
func theStandardSizeReproducesTheSettledFigures() {
let size: CGFloat = 13
#expect(BoardMetrics.stripGap(bodyPointSize: size) == 12)
#expect(BoardMetrics.laneCornerRadius(bodyPointSize: size) == 10)
#expect(BoardMetrics.lanePlatePadding(bodyPointSize: size) == 6)
#expect(BoardMetrics.laneStackSpacing(bodyPointSize: size) == 8)
#expect(BoardMetrics.laneHeaderSpacing(bodyPointSize: size) == 6)
#expect(BoardMetrics.laneAccentBandHeight(bodyPointSize: size) == 5)
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) == 22)
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
#expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
#expect(BoardMetrics.cardContentPadding(bodyPointSize: size) == 10)
#expect(BoardMetrics.cardRowSpacing(bodyPointSize: size) == 6)
#expect(BoardMetrics.cardSpacing(bodyPointSize: size) == 8)
#expect(BoardMetrics.nominalCardHeight(bodyPointSize: size) == 44)
#expect(BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: size) == 10)
#expect(BoardMetrics.trashHeaderVerticalPadding(bodyPointSize: size) == 8)
#expect(BoardMetrics.trashHatchSpacing(bodyPointSize: size) == 7)
#expect(BoardMetrics.resizeHandleWidth(bodyPointSize: size) == 12)
#expect(BoardMetrics.resizeHandleOverhang(bodyPointSize: size) == 8)
}
/// The board window's floor is the one 02/03 never named in points but that the host has always
/// carried 640 × 400, reproduced at the standard size.
@Test("The window minimum reproduces its settled size at 13pt")
func theWindowMinimumIsUnchanged() {
let minimum = BoardMetrics.windowMinimumSize(bodyPointSize: 13)
#expect(minimum.width == 637)
#expect(minimum.height == 403)
}
}
@Suite("Board metrics ▸ everything scales")
struct BoardMetricsScalingTests {
/// The text sizes the suite sweeps: below the standard, the standard, and the range a user who
/// has turned the system text size up actually lands in.
private static let sizes: [CGFloat] = [11, 13, 16, 18, 24, 36]
/// **The whole point of the type.** Every figure grows with the text size which is what
/// "layout survives the largest system text sizes" means arithmetically: a card's padding cannot
/// stay at 10 points while the title inside it doubles.
///
/// Two claims, because rounding to whole points makes them different claims. **Never smaller**
/// between adjacent sizes a figure that shrank as the text grew would be a bug and
/// **strictly larger** across the whole range, which is what separates a real derivation from a
/// fixed point size wearing a function's clothes. Adjacent sizes may legitimately share a value
/// for the smallest figures (0.3 em of a 16pt body and of an 18pt body both round to 5), and
/// that is the rounding doing its job: a hairline has no fractional setting worth having.
@Test("Every metric grows with the body point size")
func everyMetricIsMonotone() {
let metrics: [(String, (CGFloat) -> CGFloat)] = [
("stripGap", { BoardMetrics.stripGap(bodyPointSize: $0) }),
("laneCornerRadius", { BoardMetrics.laneCornerRadius(bodyPointSize: $0) }),
("lanePlatePadding", { BoardMetrics.lanePlatePadding(bodyPointSize: $0) }),
("laneStackSpacing", { BoardMetrics.laneStackSpacing(bodyPointSize: $0) }),
("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
("laneHeaderInset", { BoardMetrics.laneHeaderInset(bodyPointSize: $0) }),
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
("badgeHorizontalPadding", { BoardMetrics.badgeHorizontalPadding(bodyPointSize: $0) }),
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
("cardRowSpacing", { BoardMetrics.cardRowSpacing(bodyPointSize: $0) }),
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
("cardReplicaWidth", { BoardMetrics.cardReplicaWidth(bodyPointSize: $0) }),
("replicaPadding", { BoardMetrics.replicaPadding(bodyPointSize: $0) }),
("trashHeaderHorizontalPadding", { BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: $0) }),
("trashHatchSpacing", { BoardMetrics.trashHatchSpacing(bodyPointSize: $0) }),
("resizeHandleWidth", { BoardMetrics.resizeHandleWidth(bodyPointSize: $0) }),
]
for (name, metric) in metrics {
for (smaller, larger) in zip(Self.sizes, Self.sizes.dropFirst()) {
#expect(metric(smaller) <= metric(larger),
"\(name) shrank from \(smaller)pt to \(larger)pt")
}
#expect(metric(Self.sizes.first!) < metric(Self.sizes.last!),
"\(name) is fixed across the whole range")
}
}
/// **The new-card button's reserve stays ahead of the header's own furniture.**
///
/// 03-board-ui.md's graceful-truncation rule says a long lane title truncates rather than
/// colliding with the button, and the reserve is what makes that true. It has to stay wider than
/// a glyph-and-margin at every size, which is the fixed-22pt failure this conversion exists to
/// remove: at 24pt the glyph alone approaches the old reserve.
@Test("The header's button reserve stays wider than the glyph it reserves for")
func theButtonReserveOutgrowsItsGlyph() {
for size in Self.sizes {
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) > size,
"the reserve is narrower than one em at \(size)pt")
#expect(
BoardMetrics.newCardButtonReserve(bodyPointSize: size)
> BoardMetrics.laneHeaderSpacing(bodyPointSize: size),
"the reserve is narrower than the header's own spacing at \(size)pt"
)
}
}
/// The card plate's proportions hold at every size the stripe stays a stripe rather than
/// becoming a band, and the plate's inset stays wider than the stripe it sits beside (which is
/// what keeps "colouring a card never shifts its title" from becoming "colouring a card eats its
/// title").
@Test("The card plate keeps its proportions at every text size")
func theCardPlateKeepsItsProportions() {
for size in Self.sizes {
let stripe = BoardMetrics.cardStripeWidth(bodyPointSize: size)
let padding = BoardMetrics.cardContentPadding(bodyPointSize: size)
let radius = BoardMetrics.cardCornerRadius(bodyPointSize: size)
#expect(stripe < padding, "the stripe is wider than the plate's inset at \(size)pt")
#expect(stripe < radius, "the stripe is wider than the corner it rounds into at \(size)pt")
#expect(BoardMetrics.nominalCardHeight(bodyPointSize: size) > 2 * padding + size,
"the nominal height cannot hold one line of title at \(size)pt")
}
}
/// `em` is total and never yields a non-positive length: a zero-width stripe or a zero-height
/// band is a shape SwiftUI is asked to draw and cannot, and the pathological inputs (a
/// degenerate point size, a vanishing multiple) have to land somewhere.
@Test("The unit is floored at one point, whatever it is handed")
func theUnitIsTotal() {
#expect(BoardMetrics.em(0.3, bodyPointSize: 0) == 1)
#expect(BoardMetrics.em(0, bodyPointSize: 13) == 1)
#expect(BoardMetrics.em(-1, bodyPointSize: 13) == 1)
#expect(BoardMetrics.em(0.01, bodyPointSize: 13) == 1)
}
}
// MARK: - The no-horizontal-scroll invariant, under a gap that moves
@Suite("Board metrics ▸ the strip still never scrolls")
struct ScaledStripDivisionTests {
/// **The invariant 03-board-ui.md § Layout full visibility is built on, re-checked now that
/// the gap varies with the text size.**
///
/// The whole strip `totalUnits` standard widths plus `totalUnits + 1` gaps must fill the
/// window's width exactly, at every text size and every lane count. A larger text size therefore
/// buys a larger gap out of the lanes' own width: the lanes compress, the strip does not grow,
/// and horizontal scroll never appears. That is "the degenerate case is accepted, not floored",
/// holding through the scaling change rather than despite it.
@Test("The lanes plus the gaps fill the window exactly at every text size")
func theDivisionIsExactAtEveryTextSize() {
let stripWidth: CGFloat = 1400
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
let gap = BoardMetrics.stripGap(bodyPointSize: size)
for units in 1...12 {
let standard = LaneLayoutMath.standardWidth(
stripWidth: stripWidth, totalUnits: units, gap: gap)
let drawn = standard * CGFloat(units) + gap * CGFloat(units + 1)
#expect(abs(drawn - stripWidth) < 0.001,
"\(units) units at \(size)pt drew \(drawn) into \(stripWidth)")
}
}
}
/// The same window and the same lane count: a **larger** text size means **narrower** lanes,
/// never a wider strip. This is the direction the invariant depends on the alternative would be
/// a strip that overflowed its window and had to scroll.
@Test("A larger text size narrows the lanes rather than widening the strip")
func aLargerTextSizeCompressesTheLanes() {
let stripWidth: CGFloat = 1400
var previous = CGFloat.greatestFiniteMagnitude
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
let standard = LaneLayoutMath.standardWidth(
stripWidth: stripWidth,
totalUnits: 5,
gap: BoardMetrics.stripGap(bodyPointSize: size)
)
#expect(standard < previous, "lanes did not compress at \(size)pt")
previous = standard
}
}
}
// MARK: - Increase Contrast
@Suite("Accommodations ▸ Increase Contrast")
struct IncreaseContrastTests {
/// "Increase Contrast strengthens borders and the selection indicator" (10-accessibility.md), as
/// one flat point rather than a factor see `Accommodations.borderWidth` for why.
@Test("A stroke goes one point heavier under Increase Contrast")
func aStrokeGoesHeavier() {
for base in [1.0, 1.5, 2.0, 2.5, 3.0] as [CGFloat] {
#expect(Accommodations.borderWidth(base, contrast: .standard) == base)
#expect(Accommodations.borderWidth(base, contrast: .increased) == base + 1)
}
}
/// The widths encode a hierarchy a hovered card reads heavier than a selected one, a selected
/// tile heavier than an unselected one and the setting must not flatten it. A flat addend is
/// order-preserving; a multiplier applied to only some sites would not have been.
@Test("Strengthening preserves the hierarchy the widths encode")
func strengtheningIsOrderPreserving() {
let ordered: [CGFloat] = [1, 1.5, 2, 2.5, 3]
for contrast in [ColorSchemeContrast.standard, .increased] {
let strengthened = ordered.map { Accommodations.borderWidth($0, contrast: contrast) }
#expect(strengthened == strengthened.sorted(), "the order collapsed under \(contrast)")
#expect(Set(strengthened).count == ordered.count, "two widths merged under \(contrast)")
}
}
/// The half that is easy to miss: a card plate and a lane plate carry **no** resting border, so
/// "this is one card and that is another" is carried by a fill boundary alone exactly the
/// distinction the setting exists to rescue. Under Increase Contrast they gain one.
@Test("Resting plates gain an outline only under Increase Contrast")
func restingPlatesGainAnOutline() {
#expect(!Accommodations.drawsRestingBorder(contrast: .standard))
#expect(Accommodations.drawsRestingBorder(contrast: .increased))
}
/// Alpha is the other way a border can be weak. A width bump alone would leave the marquee's
/// 50%-alpha edge and the drag shadow's 55%-alpha dashes just as hard to see, two points wider.
@Test("A faded accent goes to full strength under Increase Contrast")
func afadedAccentGoesFull() {
for base in [0.5, 0.55, 0.6] {
#expect(Accommodations.accentOpacity(base, contrast: .standard) == base)
#expect(Accommodations.accentOpacity(base, contrast: .increased) == 1)
}
}
}
// MARK: - Reduce Transparency
@Suite("Accommodations ▸ Reduce Transparency")
struct ReduceTransparencyTests {
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board's one
/// surviving material is the transient search bar's `.bar` the design's own example, the card
/// face carousel's page dots, died with the carousel (03-board-ui.md § Card face).
@Test("The one glass underlay goes solid")
func glassGoesSolid() {
#expect(Accommodations.underlay(reduceTransparency: false) == .glass)
#expect(Accommodations.underlay(reduceTransparency: true) == .solid)
}
/// The washes are not glass they composite at an alpha rather than sampling a backdrop but
/// they fail the same way for the same user, because what is behind them is a colour the *user*
/// chose (03-board-ui.md § Styling). Each one goes opaque under the setting, and each keeps its
/// own weight without it: the trash header is heavier than its plate, because the header is the
/// whole of "you are looking at the trash".
@Test("Every translucent wash goes opaque, and keeps its weight otherwise")
func washesGoOpaque() {
#expect(Accommodations.trashPlateWash(reduceTransparency: true) == .opaque)
#expect(Accommodations.trashHeaderWash(reduceTransparency: true) == .opaque)
#expect(Accommodations.dragShadowWash(reduceTransparency: true) == .opaque)
#expect(Accommodations.trashPlateWash(reduceTransparency: false) == .translucent(opacity: 0.35))
#expect(Accommodations.trashHeaderWash(reduceTransparency: false) == .translucent(opacity: 0.5))
#expect(Accommodations.dragShadowWash(reduceTransparency: false) == .translucent(opacity: 0.5))
}
/// The trash column's two surfaces are a pair, and the pair has to stay legible as one: the
/// header must read as heavier than the plate under it, or the column stops announcing itself.
@Test("The trash header stays heavier than its plate")
func theTrashHeaderStaysHeavier() {
guard case let .translucent(header) = Accommodations.trashHeaderWash(reduceTransparency: false),
case let .translucent(plate) = Accommodations.trashPlateWash(reduceTransparency: false)
else {
Issue.record("both washes should be translucent without the setting")
return
}
#expect(header > plate)
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ Lanework is in early development. This list tracks what has actually shipped and
- **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is left alone, and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.
- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent, and a card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear.
- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent, and a card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled.
## Development