Remove the lane header's "+" new-card control

The lane header no longer draws a new-card button. Every other
creation path is untouched: File > New Card (Cmd-N), Return on a
selected lane, and double-click on lane empty space.

- LaneView: drop the button and its overlay slot, leaving the
  collapse chevron as the header's one piece of trailing chrome.
- NewCardTarget: drop the button's click-names-target carve-out over
  the Cmd-N target rule (Return on a selected lane is now the only
  direct-target path left).
- AccessibilityPhrases: drop the button's spoken label.
- BoardMetrics: drop its width reserve; laneHeaderTrailingReserve now
  reduces to the collapse chevron's own reserve.
- DESIGN/03, 04, 10, 11: update the lane title-bar inventory, the
  Cmd-N override clause and search-clearing mechanism list, the
  accessibility tree-shape sentence, and the pointer-grammar row that
  described the button.
- Tests: drop the two pinned accessibility-label tests and the
  newCardButtonReserve assertions; retarget the truncation-headroom
  test at the now-solo laneHeaderTrailingReserve; fix stale doc-quote
  comments and the manual accessibility-verification checklist.

Full KanbanTests suite: 2807 tests, 482 suites, all passing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 23:28:05 -04:00
parent b09c4bd5c0
commit eb950ee881
16 changed files with 76 additions and 126 deletions
+3 -10
View File
@@ -8,7 +8,7 @@ import Foundation
/// ### Why the strings live here and not at the modifiers
///
/// 10-accessibility.md states the board's tree as *sentences*: a lane container is
/// "title, lane, N cards", the header button is "New card in lane", a card's value carries its
/// "title, lane, N cards", its collapse chevron is "Collapse lane", a card's value carries its
/// attachment count and, when it is cut-pending, "cut, pending paste". Those are rules about text
/// which placeholder an untitled item wears, how a count folds its plural, what order two value
/// fragments join in and a rule about text is only checkable if there is a function to ask. Split
@@ -100,16 +100,9 @@ enum AccessibilityPhrases {
return "\(displayTitle(title)), lane, \(state)\(cardCount(count))"
}
/// The lane header's new-card button "New card in lane", the one labeled child
/// 10-accessibility.md gives the header.
static func newCardLabel(lane title: String?) -> String {
"New card in \(displayTitle(title))"
}
/// The lane header's **collapse chevron** "Collapse lane" (03-board-ui.md § Lane Collapsed
/// lanes), the header's second labeled child and the new-card button's twin in every respect: a
/// glyph-only control needs a word, and the word names the lane so the label stands on its own out
/// of context.
/// lanes), the header's one labeled child: a glyph-only control needs a word, and the word names
/// the lane so the label stands on its own out of context.
static func collapseLaneLabel(lane title: String?) -> String {
"Collapse \(displayTitle(title))"
}
+15 -23
View File
@@ -115,35 +115,27 @@ enum BoardMetrics {
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.
/// The room the header reserves for the **collapse chevron**, its one piece of trailing chrome
/// (03-board-ui.md § Lane Collapsed lanes). A new-card button once shared this edge and had its
/// own matching reserve (`newCardButtonReserve`, retired with the button this milestone); the
/// chevron kept its own figure rather than folding into a since-widowed combined one.
///
/// 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 room the header reserves for the **collapse chevron** beside the new-card button
/// (03-board-ui.md § Lane Collapsed lanes) `newCardButtonReserve`'s twin, and it exists for
/// exactly that figure's reason: two glyphs now share the header's trailing edge, and a title has
/// to truncate before it reaches either.
///
/// 1.4 em 18pt at the standard 13pt body: the chevron is a smaller glyph than the plus and sits
/// closer to it than to the title, so it asks for less breathing room than the button it precedes.
/// This is the one figure that is not decoration: the chevron is an `Image` at
/// `.imageScale(.small)`, which *is* a relative size, so a reserve fixed at 18pt would be overrun
/// by the glyph itself at a large system text size and the truncation rule would stop being true.
/// 1.4 em 18pt at the standard 13pt body is the glyph plus its breathing room, measured in the
/// same unit it grows in.
static func laneCollapseButtonReserve(bodyPointSize: CGFloat) -> CGFloat {
em(1.4, bodyPointSize: bodyPointSize)
}
/// Everything the lane header's trailing chrome takes the chevron plus the new-card button. The
/// one figure the header pads by, so "a long title truncates before it collides" stays one rule
/// however many controls end up living there.
/// Everything the lane header's trailing chrome takes the collapse chevron alone now that the
/// new-card button beside it is gone (this milestone narrowed this from a two-term sum to one).
/// Kept as its own figure rather than inlined at the call site: the one thing the header pads by,
/// so "a long title truncates before it collides" stays one rule however many controls end up
/// living there again.
static func laneHeaderTrailingReserve(bodyPointSize: CGFloat) -> CGFloat {
newCardButtonReserve(bodyPointSize: bodyPointSize)
+ laneCollapseButtonReserve(bodyPointSize: bodyPointSize)
laneCollapseButtonReserve(bodyPointSize: bodyPointSize)
}
/// The count badge's capsule inset horizontal and vertical, which are deliberately different:
+1 -1
View File
@@ -247,7 +247,7 @@ struct BoardView: View {
// **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
// The controls *around* the cards each lane's collapse chevron, 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").
//
+25 -48
View File
@@ -9,13 +9,15 @@ import SwiftUI
///
/// Leading SF Symbol from `icon` lenient, an unknown name renders the `square.stack` default
/// (`ItemSymbol`) then the title or its quiet "Untitled" placeholder, a quiet secondary
/// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**:
/// card-count badge, and a trailing quiet collapse chevron. **The whole bar is the drag surface**:
/// a click selects the lane toggling off on a repeat, exactly as empty space does
/// (04-interactions.md § Selection, settled) and movement begins a **system drag session**
/// carrying the lane (`DragSession`, DRAG-REORDER.md). The click-versus-drag split is the system's
/// own now: `.onTapGesture` and `.onDrag` coexist, so a hesitant click can never start a drag and a
/// drag can never also select. The one thing carved out of the drag region is the new-card button,
/// which sits in an overlay outside it.
/// drag can never also select. The one thing carved out of the drag region is the collapse chevron,
/// which sits in an overlay outside it. **Card creation has no header control**: the "+" that once
/// lived beside the chevron is gone (m-collapse's own follow-up), and every creation path N,
/// Return on a selected lane, double-click on lane empty space is untouched by its absence.
///
/// ### The lane's one context menu
///
@@ -37,8 +39,9 @@ import SwiftUI
/// invisibility works and why this needs so little code elsewhere: no card face means no marquee
/// registration and no navigation frame, no grid registration means the card zones have nothing to
/// resolve against (`BoardDropContext.retargetCards` answers end-of-lane instead), and no header means
/// no rename target and no new-card button. The strip keeps exactly three gestures: a plain click
/// expands, a modified click selects the lane, and a drag reorders it.
/// no rename target and no collapse chevron the strip's own plain click is its reversal instead. The
/// strip keeps exactly three gestures: a plain click expands, a modified click selects the lane, and a
/// drag reorders it.
///
/// ### The card face
///
@@ -223,7 +226,7 @@ struct LaneView: View, Equatable {
.onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
// **The lane is an accessibility container** "window lanes (accessibility containers, in
// lane `order`) cards (leaf elements, in card `order`)" (10-accessibility.md The board
// through VoiceOver). `.contain` rather than `.combine`: the header, the new-card button and
// through VoiceOver). `.contain` rather than `.combine`: the header, the collapse chevron and
// every card must stay individually reachable, which is the whole point of a container the
// VoiceOver cursor enters (`TrashLaneView` states the same rule from the trash's side).
.accessibilityElement(children: .contain)
@@ -456,13 +459,11 @@ struct LaneView: View, Equatable {
}
.onDisappear { drops.registry.removeHeader(lane.id) }
// **The header's trailing chrome, outside the drag region** the overlay is attached
// after `.onDrag` deliberately, which is what makes both controls clickable rather than
// grabbable (the new-card button's long-standing arrangement, now shared).
// after `.onDrag` deliberately, which is what makes the chevron clickable rather than
// grabbable (the new-card button's old arrangement, now the chevron's alone the button
// itself is gone).
.overlay(alignment: .trailing) {
HStack(spacing: 0) {
collapseButton
newCardButton
}
collapseButton
}
// **10-accessibility.md's 4.5:1 rule, at the one place on the board where text sits on
// a colour the user chose** (`BoardTextInk`) palette name and hand-written hex alike,
@@ -478,7 +479,7 @@ struct LaneView: View, Equatable {
// colour.
//
// **Placed here, not at the end of the chain**, which is the modifier order doing real
// work: everything above including the new-card button in the overlay is text on the
// work: everything above including the collapse chevron in the overlay is text on the
// board background and takes the computed ink, while the context menu and the Style
// popover attached below stay in the window's own appearance, because a menu is system
// chrome drawn on its own surface and not on this board's colour.
@@ -687,11 +688,11 @@ struct LaneView: View, Equatable {
Spacer(minLength: 0)
}
// Reserves the trailing chrome's width so a long title truncates before it collides, and keeps
// those controls out of the gestured region. **Font-derived** rather than a fixed figure: both
// are `Image`s at a relative image scale, so a fixed reserve would be overrun by the glyphs
// themselves at a large system text size and 03-board-ui.md's graceful-truncation rule would
// quietly stop holding (`BoardMetrics.laneHeaderTrailingReserve`, which is the collapse
// chevron's room plus the new-card button's).
// the chevron out of the gestured region. **Font-derived** rather than a fixed figure: it 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.laneHeaderTrailingReserve`, the collapse chevron's own room now that
// the new-card button that once shared it is gone).
.padding(.trailing, BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: pointSize))
.padding(.horizontal, BoardMetrics.laneHeaderInset(bodyPointSize: pointSize))
}
@@ -757,13 +758,13 @@ struct LaneView: View, Equatable {
}
/// **The collapse chevron** (03-board-ui.md § Lane Collapsed lanes) the pointer's way into the
/// fold, beside the new-card button at the header's trailing edge.
/// fold, at the header's trailing edge, and its **only** trailing control since the new-card button
/// that once sat beside it was removed (this milestone).
///
/// **Always visible, not hover-revealed**, because the header has no hover chrome to match: the
/// new-card button beside it has been unconditional since it existed, and one control that appears
/// on hover next to one that does not reads as a glitch rather than as a convention. The cost is
/// paid in the header's trailing reserve, which is the figure that keeps the title truncating before
/// either glyph (`headerContent`).
/// **Always visible, not hover-revealed**, because the header has no hover chrome at all: an
/// always-on glyph that only sometimes appeared would read as a glitch rather than as a convention.
/// The cost is paid in the header's trailing reserve, which is the figure that keeps the title
/// truncating before it (`headerContent`).
///
/// `chevron.left` because the fold travels that way the lane's content collapses toward its
/// leading edge and leaves a spine and the strip's reversal is a click on the strip itself, which
@@ -785,30 +786,6 @@ struct LaneView: View, Equatable {
.disabled(!store.acceptsBoardMutations)
}
/// The new-card button a **pointer twin** of File New Card whose click *names its target*:
/// "the lane header's new-card button overrides [the N target] rule the click names its
/// target lane, selection notwithstanding" (11-command-nexus.md Pointer grammar, settled), so
/// it passes this lane and no anchor rather than consulting `NewCardTarget`.
private var newCardButton: some View {
Button {
store.transient.beginPlaceholder(inLane: lane.id)
} label: {
Image(systemName: "plus")
.imageScale(.small)
.foregroundStyle(.secondary)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// "The lane header's new-card button is a labeled child ('New card in lane')"
// (10-accessibility.md The board through VoiceOver) the header's one child element, which
// is why it lives in an overlay outside the flattened bar rather than inside it.
.accessibilityLabel(AccessibilityPhrases.newCardLabel(lane: lane.title.value))
// Mutating, so the read-only lock disables it like every other write path
// (02-architecture.md § The lock's scope), and the focused-editor rule closes it while an
// inline editor is open (04 Grammar) the pointer twin of a disabled menu item.
.disabled(store.isReadOnly || store.isEditingInline)
}
// MARK: - The lane drag
/// Begins the lane's system drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop).
+4 -4
View File
@@ -20,11 +20,11 @@
///
/// ### What it deliberately does not decide
///
/// - **The lane header's new-card button overrides this rule entirely** (11-command-nexus.md
/// Pointer grammar, settled): "the click names its target lane, selection notwithstanding". That
/// call site passes its own lane and never comes here.
/// - **Return on a selected lane** is the same target as this rule's lane branch, but it is reached
/// by grammar rather than by the menu; it also passes its lane directly.
/// by grammar rather than by the menu; it passes its lane directly rather than coming here. (The
/// lane header once carried a new-card button with the same carve-out click names its target,
/// selection notwithstanding but the button itself is gone; Return is the one direct-target path
/// left.)
enum NewCardTarget {
/// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the
+1 -1
View File
@@ -199,7 +199,7 @@ extension View {
/// change. Chrome outside the strip keeps using `.font(_:)` and stays at system size on purpose.
///
/// SF Symbols need no equivalent `Image(systemName:)` under `.imageScale(_:)` sizes off the
/// current font, which is why `BoardMetrics.newCardButtonReserve` is 1.7 em rather than 22pt.
/// current font, which is why `BoardMetrics.laneCollapseButtonReserve` is 1.4 em rather than 18pt.
func boardFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View {
modifier(BoardFontModifier(style: style, weight: weight))
}