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:
+151
-46
@@ -104,12 +104,37 @@ enum CuratedSymbols {
|
||||
/// that makes the first one narrow enough to sit beside a card would overflow the second by 70
|
||||
/// points. Nothing about *behavior* is in here: every well, every write, the batch display and the
|
||||
/// keyboard grammar are the editor's, identical at every anchor. Only the geometry moves.
|
||||
///
|
||||
/// ### Everything here scales with the body font
|
||||
///
|
||||
/// A well is a **container for a glyph**, and the glyph inside it is drawn at `.imageScale(.medium)`
|
||||
/// — a relative size. So a well fixed at 20 points would be overrun by its own symbol at a large
|
||||
/// system text size, and 10-accessibility.md's "every well Tab-reachable and labeled by name" would
|
||||
/// be true of controls the user could no longer read. Every figure below is therefore a multiple of
|
||||
/// the body point size, chosen to reproduce today's numbers at the standard 13pt body — the same
|
||||
/// derivation, and the same rationale, as `BoardMetrics` and `CardWindowMetrics`.
|
||||
struct StyleEditorLayout: Equatable {
|
||||
|
||||
/// One well's side, and the gap between two — the numbers the grids are laid out on, named once
|
||||
/// so the fit rule below and the wells themselves cannot drift apart.
|
||||
static let wellSide: CGFloat = 20
|
||||
static let wellSpacing: CGFloat = 6
|
||||
/// One well's side, and the gap between two — the numbers the grids are laid out on, derived
|
||||
/// once so the fit rule below and the wells themselves cannot drift apart.
|
||||
///
|
||||
/// 1.55 em and 0.45 em: 20pt and 6pt at the standard 13pt body, which is what the grids have
|
||||
/// always drawn.
|
||||
static func wellSide(bodyPointSize: CGFloat) -> CGFloat {
|
||||
max(1, (bodyPointSize * 1.55).rounded())
|
||||
}
|
||||
|
||||
static func wellSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
||||
max(1, (bodyPointSize * 0.45).rounded())
|
||||
}
|
||||
|
||||
/// The gap between the editor's two sections — and, at the popover anchor, its inset too, which
|
||||
/// is why it is one figure rather than two that happen to agree. The *sidebar* anchor brings no
|
||||
/// inset of its own (its column is already gutted) but still wants the sections apart, so the
|
||||
/// spacing has to survive `padding` going to zero.
|
||||
static func sectionSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
||||
max(1, (bodyPointSize * 1.08).rounded())
|
||||
}
|
||||
|
||||
/// A fixed width, or `nil` to take whatever the anchor proposes.
|
||||
var width: CGFloat?
|
||||
@@ -120,6 +145,11 @@ struct StyleEditorLayout: Equatable {
|
||||
/// How tall the symbol grid may grow before it scrolls inside itself, or `nil` for "never" —
|
||||
/// the grid then draws whole and the anchor scrolls it.
|
||||
var symbolGridMaximumHeight: CGFloat?
|
||||
/// The well geometry this layout's grids draw on — carried on the value rather than read from
|
||||
/// the statics above, so a view has one thing to consult and the two can never disagree about
|
||||
/// which text size they were computed for.
|
||||
var wellSide: CGFloat
|
||||
var wellSpacing: CGFloat
|
||||
|
||||
/// The Style… popover and the board popover's styling area: a fixed frame, its own padding, and
|
||||
/// a symbol grid that scrolls within it.
|
||||
@@ -128,13 +158,21 @@ struct StyleEditorLayout: Equatable {
|
||||
/// enough to sit beside a card without covering the lane it came from; the symbol grid's cap is
|
||||
/// eight rows or so — enough that it reads as a set rather than as a strip, short enough that the
|
||||
/// popover fits beside a card on a laptop screen.
|
||||
static let popover = StyleEditorLayout(
|
||||
width: 268,
|
||||
padding: 14,
|
||||
backgroundColumns: 7,
|
||||
symbolColumns: 8,
|
||||
symbolGridMaximumHeight: 168
|
||||
)
|
||||
///
|
||||
/// The column counts are the design's own and stay fixed at every text size — 03-board-ui.md
|
||||
/// names the 7 + 6 fall — while the *frame* around them grows, which is what keeps the 7 wells
|
||||
/// inside it (20.6 em is 268pt at the standard body size, the number 03 settled on).
|
||||
static func popover(bodyPointSize: CGFloat) -> StyleEditorLayout {
|
||||
StyleEditorLayout(
|
||||
width: (bodyPointSize * 20.6).rounded(),
|
||||
padding: sectionSpacing(bodyPointSize: bodyPointSize),
|
||||
backgroundColumns: 7,
|
||||
symbolColumns: 8,
|
||||
symbolGridMaximumHeight: (bodyPointSize * 12.9).rounded(),
|
||||
wellSide: wellSide(bodyPointSize: bodyPointSize),
|
||||
wellSpacing: wellSpacing(bodyPointSize: bodyPointSize)
|
||||
)
|
||||
}
|
||||
|
||||
/// The card window's sidebar section (05-card-window.md ▸ Style).
|
||||
///
|
||||
@@ -146,21 +184,29 @@ struct StyleEditorLayout: Equatable {
|
||||
/// a grid with wells the pointer cannot reach.
|
||||
/// - **The symbol grid does not scroll.** The sidebar is already a scroll view, and a scroll view
|
||||
/// inside a scroll view is a scroll view that fights (`CardWindowView`'s rule, for its reason).
|
||||
static func sidebar(contentWidth: CGFloat) -> StyleEditorLayout {
|
||||
let columns = columns(fitting: contentWidth)
|
||||
static func sidebar(contentWidth: CGFloat, bodyPointSize: CGFloat) -> StyleEditorLayout {
|
||||
let columns = columns(fitting: contentWidth, bodyPointSize: bodyPointSize)
|
||||
return StyleEditorLayout(
|
||||
width: nil,
|
||||
padding: 0,
|
||||
backgroundColumns: columns,
|
||||
symbolColumns: columns,
|
||||
symbolGridMaximumHeight: nil
|
||||
symbolGridMaximumHeight: nil,
|
||||
wellSide: wellSide(bodyPointSize: bodyPointSize),
|
||||
wellSpacing: wellSpacing(bodyPointSize: bodyPointSize)
|
||||
)
|
||||
}
|
||||
|
||||
/// How many wells fit across `width` — `n` wells and `n - 1` gaps, floored, and never less than
|
||||
/// one. Pure, and the whole of "the grid never overflows the column it was given".
|
||||
static func columns(fitting width: CGFloat) -> Int {
|
||||
max(1, Int((width + wellSpacing) / (wellSide + wellSpacing)))
|
||||
///
|
||||
/// Both the column and the wells grow with the text size, so the count stays roughly stable
|
||||
/// across text sizes rather than collapsing to one: `CardWindowMetrics`' sidebar is 26 body
|
||||
/// *characters* wide and a well is 1.55 body *ems*, and the ratio between those does not move.
|
||||
static func columns(fitting width: CGFloat, bodyPointSize: CGFloat) -> Int {
|
||||
let side = wellSide(bodyPointSize: bodyPointSize)
|
||||
let spacing = wellSpacing(bodyPointSize: bodyPointSize)
|
||||
return max(1, Int((width + spacing) / (side + spacing)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,20 +233,28 @@ struct StyleEditorView: View {
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
let target: StyleTarget
|
||||
/// The anchor's geometry, and nothing else (`StyleEditorLayout`). Defaulted to the popover's, so
|
||||
/// the two anchors that were here first say nothing about it.
|
||||
var layout: StyleEditorLayout = .popover
|
||||
/// The anchor's geometry, and nothing else (`StyleEditorLayout`). `nil` takes the popover's, so
|
||||
/// the two anchors that were here first say nothing about it — resolved in `body` rather than
|
||||
/// defaulted in the declaration, because the popover's geometry now depends on the live text
|
||||
/// size and a default argument cannot read one.
|
||||
var layout: StyleEditorLayout?
|
||||
|
||||
/// The live body metric, read here rather than passed in — `CardStyleSection`'s pattern, so
|
||||
/// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling
|
||||
/// rule).
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
|
||||
var body: some View {
|
||||
let layout = self.layout ?? .popover(bodyPointSize: pointSize)
|
||||
let subjects = store.styleSubjects(of: target)
|
||||
let background = StyleFieldState.resolve(subjects.map(\.background))
|
||||
let icon = StyleFieldState.resolve(subjects.map(\.icon))
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: StyleEditorLayout.sectionSpacing(bodyPointSize: pointSize)) {
|
||||
targetCaption(count: subjects.count)
|
||||
backgroundSection(background)
|
||||
backgroundSection(background, layout: layout)
|
||||
Divider()
|
||||
symbolSection(icon)
|
||||
symbolSection(icon, layout: layout)
|
||||
}
|
||||
.padding(layout.padding)
|
||||
.frame(width: layout.width)
|
||||
@@ -231,12 +285,13 @@ struct StyleEditorView: View {
|
||||
|
||||
/// The twelve palette wells and their leading None (03-board-ui.md § Styling ▸ Controls:
|
||||
/// "palette-only in-app … plus a leading **None** well that removes the `background` key").
|
||||
private func backgroundSection(_ state: StyleFieldState) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionHeader("Background", current: backgroundCurrent(state))
|
||||
private func backgroundSection(_ state: StyleFieldState, layout: StyleEditorLayout) -> some View {
|
||||
VStack(alignment: .leading, spacing: layout.wellSpacing) {
|
||||
sectionHeader("Background", current: backgroundCurrent(state), layout: layout)
|
||||
StyleWellGrid(
|
||||
wells: backgroundWells(state),
|
||||
columns: layout.backgroundColumns,
|
||||
layout: layout,
|
||||
apply: { change in
|
||||
StyleCommand.apply(background: change, to: target, in: store, recents: recents)
|
||||
}
|
||||
@@ -272,12 +327,12 @@ struct StyleEditorView: View {
|
||||
|
||||
/// The curated grid and its leading default well — "its leading well is the level's default
|
||||
/// symbol and removes the `icon` key" (§ Controls).
|
||||
private func symbolSection(_ state: StyleFieldState) -> some View {
|
||||
private func symbolSection(_ state: StyleFieldState, layout: StyleEditorLayout) -> some View {
|
||||
let level = store.styleLevel(of: target)
|
||||
let fallback = ItemSymbol.default(for: level)
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback))
|
||||
symbolGrid(state, fallback: fallback)
|
||||
return VStack(alignment: .leading, spacing: layout.wellSpacing) {
|
||||
sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback), layout: layout)
|
||||
symbolGrid(state, fallback: fallback, layout: layout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,10 +340,11 @@ struct StyleEditorView: View {
|
||||
/// (`StyleEditorLayout.symbolGridMaximumHeight`), and the one shape difference between the
|
||||
/// popover and the card sidebar.
|
||||
@ViewBuilder
|
||||
private func symbolGrid(_ state: StyleFieldState, fallback: String) -> some View {
|
||||
private func symbolGrid(_ state: StyleFieldState, fallback: String, layout: StyleEditorLayout) -> some View {
|
||||
let grid = StyleWellGrid(
|
||||
wells: symbolWells(state, fallback: fallback),
|
||||
columns: layout.symbolColumns,
|
||||
layout: layout,
|
||||
apply: { change in
|
||||
StyleCommand.apply(icon: change, to: target, in: store, recents: recents)
|
||||
}
|
||||
@@ -331,13 +387,15 @@ struct StyleEditorView: View {
|
||||
|
||||
// MARK: - Section chrome
|
||||
|
||||
private func sectionHeader(_ title: String, current: CurrentValue) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
private func sectionHeader(_ title: String, current: CurrentValue, layout: StyleEditorLayout) -> some View {
|
||||
HStack(spacing: layout.wellSpacing) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer(minLength: 8)
|
||||
Spacer(minLength: layout.wellSpacing)
|
||||
if let face = current.face {
|
||||
StyleWellFace(face: face, size: 14)
|
||||
// A touch smaller than a well: this is a *statement* of the current value, not a
|
||||
// control, and it must not read as a fourteenth swatch that can be clicked.
|
||||
StyleWellFace(face: face, size: (layout.wellSide * 0.7).rounded())
|
||||
}
|
||||
Text(current.text)
|
||||
.font(.caption)
|
||||
@@ -398,7 +456,12 @@ private struct StyleWellFace: View {
|
||||
}
|
||||
|
||||
let face: Face
|
||||
var size: CGFloat = StyleEditorLayout.wellSide
|
||||
/// The well's side, supplied by the caller because it is font-derived and the caller is the one
|
||||
/// holding the layout it came from (`StyleEditorLayout.wellSide`).
|
||||
let size: CGFloat
|
||||
|
||||
/// Increase Contrast, for the swatch's border below (10-accessibility.md; `Accommodations`).
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var body: some View {
|
||||
switch face {
|
||||
@@ -417,13 +480,30 @@ private struct StyleWellFace: View {
|
||||
/// invisible control on a light popover (10-accessibility.md's contrast stance turned on the
|
||||
/// app's own chrome). A `nil` colour adds the diagonal strike that means "none".
|
||||
private func swatch(_ color: Color?) -> some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(color ?? Color(nsColor: .textBackgroundColor))
|
||||
.overlay { if color == nil { NoValueStrike().stroke(.secondary, lineWidth: 1) } }
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(.separator, lineWidth: 1))
|
||||
.overlay {
|
||||
if color == nil {
|
||||
NoValueStrike(inset: strikeInset)
|
||||
.stroke(.secondary, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
|
||||
}
|
||||
}
|
||||
// A point heavier under Increase Contrast — this hairline is the *only* thing separating
|
||||
// a `chalk` well from the popover it sits on, which is the same reason it is drawn at all
|
||||
// (10-accessibility.md's contrast stance turned on the app's own chrome).
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
|
||||
)
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
|
||||
/// The swatch's radius and its "none" strike's inset, as fractions of the well — so both follow
|
||||
/// the well when the text size grows it (10-accessibility.md's full-relative-scaling rule).
|
||||
private var cornerRadius: CGFloat { max(1, (size * 0.2).rounded()) }
|
||||
|
||||
private var strikeInset: CGFloat { max(1, (size * 0.15).rounded()) }
|
||||
|
||||
private func glyph(_ name: String, tint: AnyShapeStyle) -> some View {
|
||||
Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
|
||||
.imageScale(.medium)
|
||||
@@ -435,10 +515,14 @@ private struct StyleWellFace: View {
|
||||
/// The corner-to-corner slash on the None well — the pathfinder's swatch vocabulary, kept because it
|
||||
/// is also the system's (an empty colour well slashes in Finder's own tag editor).
|
||||
private struct NoValueStrike: Shape {
|
||||
/// How far in from each corner the stroke starts — a fraction of the well, supplied by the
|
||||
/// caller, so it follows the well when the text size grows it.
|
||||
let inset: CGFloat
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX + 3, y: rect.maxY - 3))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - 3, y: rect.minY + 3))
|
||||
path.move(to: CGPoint(x: rect.minX + inset, y: rect.maxY - inset))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - inset, y: rect.minY + inset))
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -456,31 +540,44 @@ private struct StyleWellGrid: View {
|
||||
|
||||
let wells: [StyleWell]
|
||||
let columns: Int
|
||||
/// The anchor's geometry — the well side and spacing this grid lays out on
|
||||
/// (`StyleEditorLayout`, all font-derived).
|
||||
let layout: StyleEditorLayout
|
||||
let apply: (StyleChange) -> Void
|
||||
|
||||
@FocusState private var focused: Int?
|
||||
|
||||
/// Increase Contrast, for the selection ring below — 10-accessibility.md names the selection
|
||||
/// indicator specifically, and this is the style editor's ("the current value is stated by
|
||||
/// trait", whose visible half is this ring).
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(.flexible(minimum: StyleEditorLayout.wellSide), spacing: StyleEditorLayout.wellSpacing),
|
||||
repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
|
||||
count: columns
|
||||
),
|
||||
spacing: StyleEditorLayout.wellSpacing
|
||||
spacing: layout.wellSpacing
|
||||
) {
|
||||
ForEach(wells) { well in
|
||||
Button {
|
||||
apply(well.change)
|
||||
} label: {
|
||||
StyleWellFace(face: well.face)
|
||||
StyleWellFace(face: well.face, size: layout.wellSide)
|
||||
.overlay(selectionRing(well.isSelected))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// **Every well is Tab-reachable and labeled by name** (10-accessibility.md ▸ Style
|
||||
// editor). The focus ring is deliberately *not* disabled here, unlike the board
|
||||
// strip's: a well is a control, and Full Keyboard Access has to be able to show
|
||||
// which one Tab landed on.
|
||||
.focusable()
|
||||
.focused($focused, equals: well.id)
|
||||
.help(well.label)
|
||||
.accessibilityLabel(well.label)
|
||||
// "The current value is stated by trait" — never by the highlight alone.
|
||||
.accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
@@ -489,10 +586,18 @@ private struct StyleWellGrid: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected well's ring — a point heavier under Increase Contrast, which is 10's
|
||||
/// "strengthens … the selection indicator" landing on the one selection indicator this component
|
||||
/// has (`Accommodations`). The trait beside it is what makes the state readable without it.
|
||||
private func selectionRing(_ isSelected: Bool) -> some View {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 2)
|
||||
.padding(-2)
|
||||
RoundedRectangle(cornerRadius: max(1, (layout.wellSide * 0.25).rounded()))
|
||||
.strokeBorder(
|
||||
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||||
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
|
||||
)
|
||||
// Drawn *outside* the well, by the ring's own half-width, so a heavier ring under
|
||||
// Increase Contrast grows outward instead of eating into the swatch it is marking.
|
||||
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
|
||||
}
|
||||
|
||||
/// One step per press, clamped at the ends rather than wrapped: a grid whose last row is short
|
||||
|
||||
Reference in New Issue
Block a user