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
128 lines
8.0 KiB
Swift
128 lines
8.0 KiB
Swift
/// 04-interactions.md's **⌘N target rule** (settled), as a pure function of the three things it
|
|
/// reads — the selection, the last-active lane, and the snapshot (`NewCardTargetTests`).
|
|
///
|
|
/// The rule verbatim, and each clause's branch below:
|
|
///
|
|
/// > with a card selected, the new card is created in that card's lane, immediately after it
|
|
/// > (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency);
|
|
/// > a multi-selection anchors at its last member in flatten order (lane `order`, then card
|
|
/// > `order`, the multi-drag order; the same anchor serves paste): creation follows the last
|
|
/// > selected card, or appends to the last selected lane; … with nothing selected — or a
|
|
/// > **trash** selection, which never anchors creation — the **last-active lane** — the lane
|
|
/// > that most recently held selection or a creation in this window session — falling back to the
|
|
/// > first lane. … **Zero-lane board**: card creation … disable[s] via menu validation until a
|
|
/// > lane exists.
|
|
///
|
|
/// **A pure function rather than a method on the store** for the reason every rule in this codebase
|
|
/// that can be one is: the branches are lines of test rather than UI states to drive, and the menu
|
|
/// item's `disabled` and its action then read the *same* answer instead of two hand-kept-in-sync
|
|
/// conditions.
|
|
///
|
|
/// ### What it deliberately does not decide
|
|
///
|
|
/// - **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 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
|
|
/// lane's bottom). Exactly `NewCardPlaceholder`'s two anchoring fields, because that is what
|
|
/// this resolves *into*.
|
|
struct Resolution: Equatable {
|
|
let laneID: ItemID
|
|
let anchorCardID: ItemID?
|
|
}
|
|
|
|
/// The target, or `nil` when there is none — **the zero-lane board**, where "New Card,
|
|
/// Return-creation, and Paste with a card payload disable via menu validation until a lane
|
|
/// exists". `nil` is therefore the menu item's `disabled` condition as well as its refusal, so
|
|
/// the two can never disagree.
|
|
///
|
|
/// - Parameters:
|
|
/// - selection: the board's current selection, container included. A `.trash` selection
|
|
/// "never anchors creation" and is treated exactly as an empty one — the settled precedent
|
|
/// 04 ▸ Clipboard cites for paste, applied here to its source rule ("a trashed card's live
|
|
/// disk-lane never leaks in as 'the selected card's lane'").
|
|
/// - lastActiveLaneID: `TransientBoardState.lastActiveLaneID`, already cleared by the reload
|
|
/// rule if its lane vanished — but re-checked here anyway, because a caller need not have
|
|
/// reloaded since the lane went.
|
|
static func resolve(
|
|
selection: ItemReferenceSet,
|
|
lastActiveLaneID: ItemID?,
|
|
snapshot: BoardModel
|
|
) -> Resolution? {
|
|
// **Collapsed lanes are not creation targets** (03-board-ui.md § Lane ▸ Collapsed lanes:
|
|
// "⌘N new-card targeting skips collapsed lanes"): the placeholder is a pseudo-card drawn in the
|
|
// lane's masonry (`LaneSlot.placeholder`), and a folded lane draws none — so a card created
|
|
// there would be a focused text field rendered nowhere, with no way to type a title into it and
|
|
// no way out but Escape. Skipping is therefore not a preference but the only coherent answer,
|
|
// and it is applied to every branch below by narrowing the lane list once.
|
|
//
|
|
// **A board whose every lane is folded has no target at all**, which is exactly the zero-lane
|
|
// board's answer and reaches the same place: `nil` is New Card's `disabled` condition as well
|
|
// as its refusal, so the item greys out rather than doing nothing when pressed.
|
|
let lanes = snapshot.lanes.filter { !LaneLayoutMath.isCollapsed($0) }
|
|
guard !lanes.isEmpty else { return nil }
|
|
|
|
// **A selection inside a folded lane falls through rather than refusing** — the stale
|
|
// selection's rule verbatim, for its reason: the user pressed ⌘N and the board has lanes it
|
|
// can create into. So collapsing a lane with one of its cards selected leaves ⌘N working, at
|
|
// the last-active lane below. `flattenAnchor` itself is deliberately untouched: it is
|
|
// **shared with paste** ("the same anchor serves paste"), and a paste into a folded lane is
|
|
// perfectly coherent — it writes a rank, it renders nothing, and the card is there when the
|
|
// lane unfolds.
|
|
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot),
|
|
lanes.contains(where: { $0.id == anchor.laneID }) {
|
|
return anchor
|
|
}
|
|
// Nothing selected, a trash selection, or a stale one — the ids name nothing the board
|
|
// renders, a selection the next reload will drop. Falls through rather than refusing: the
|
|
// user pressed ⌘N and the board has lanes. The target is then the lane that most recently
|
|
// held selection or a creation, and the first lane when there is no such lane (or it has
|
|
// since gone).
|
|
if let lastActiveLaneID, let lane = lanes.first(where: { $0.id == lastActiveLaneID }) {
|
|
return Resolution(laneID: lane.id, anchorCardID: nil)
|
|
}
|
|
return lanes.first.map { Resolution(laneID: $0.id, anchorCardID: nil) }
|
|
}
|
|
|
|
/// **The shared anchor, on its own** — "a multi-selection anchors at its last member in flatten
|
|
/// order (lane `order`, then card `order`, the multi-drag order; the same anchor serves paste)".
|
|
///
|
|
/// Extracted rather than left inside `resolve` because paste needs *exactly this clause* and not
|
|
/// the two that surround it. Card paste is `resolve` verbatim (the last-active-lane fallback and
|
|
/// all), but **lane paste has a different fallback** — "nothing selected = the board's right end",
|
|
/// never the last-active lane — so it takes the anchor and stops. Two derivations of "the last
|
|
/// member in flatten order" would be two chances for creation and paste to disagree about the one
|
|
/// rule 04 says they share.
|
|
///
|
|
/// `nil` covers the three cases that anchor nothing, which the callers then answer their own way:
|
|
/// an empty selection, a **trash** one ("a trash selection never anchors paste",
|
|
/// settled — and "a trashed card's live disk-lane never leaks in as 'the selected card's lane'",
|
|
/// which falls out of never looking at the trashed side at all), and a stale one whose ids name
|
|
/// nothing the board renders.
|
|
static func flattenAnchor(selection: ItemReferenceSet, snapshot: BoardModel) -> Resolution? {
|
|
guard selection.container == .board, !selection.ids.isEmpty else { return nil }
|
|
|
|
// The snapshot's lanes and cards are already in display order, so the flatten order is one
|
|
// walk, and the *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only
|
|
// one of the two branches ever fires within a walk; a sole selection is simply the degenerate
|
|
// one-member case of the same rule.
|
|
var anchor: Resolution?
|
|
for lane in snapshot.lanes {
|
|
// A selected lane: creation appends at its bottom, Return consistency; paste lands after
|
|
// the lane itself.
|
|
if selection.ids.contains(lane.id) {
|
|
anchor = Resolution(laneID: lane.id, anchorCardID: nil)
|
|
}
|
|
// A selected card: its lane, immediately after it — paste-anchor consistency.
|
|
for card in lane.cards where selection.ids.contains(card.id) {
|
|
anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
|
|
}
|
|
}
|
|
return anchor
|
|
}
|
|
}
|