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:
@@ -18,10 +18,10 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
|
|||||||
|
|
||||||
## Lane
|
## Lane
|
||||||
|
|
||||||
- Title bar: leading SF Symbol (the lane's `icon`), title, **card-count badge** (quiet, secondary styling), new-card button. The whole title bar is the lane's drag surface — no separate grip; a plain click (no movement) on it selects the lane (04-interactions.md ▸ Selection). **The lane has one context menu** (settled), invoked on the header or on lane empty space alike — Rename, Style…, the quick-style recents row, the Width stepper, Delete (inventory normative in 11-command-nexus.md ▸ Context menus); a full lane still has its header, so the menu is always reachable. The count reads the search filter like every other surface (04-interactions.md): during a search it shows the visible count, not the total.
|
- Title bar: leading SF Symbol (the lane's `icon`), title, **card-count badge** (quiet, secondary styling), the collapse chevron (below). The whole title bar is the lane's drag surface — no separate grip; a plain click (no movement) on it selects the lane (04-interactions.md ▸ Selection). **No header card-creation control** (removed): the header carried a "+" new-card button through m5; it is gone, and every other creation path — ⌘N, Return on a selected lane, double-click on lane empty space — is unchanged. **The lane has one context menu** (settled), invoked on the header or on lane empty space alike — Rename, Style…, the quick-style recents row, the Width stepper, Delete (inventory normative in 11-command-nexus.md ▸ Context menus); a full lane still has its header, so the menu is always reachable. The count reads the search filter like every other surface (04-interactions.md): during a search it shows the visible count, not the total.
|
||||||
- Body: vertical card stack (masonry grid when wide — settled, the pathfinder's masonry works), scrolls vertically.
|
- Body: vertical card stack (masonry grid when wide — settled, the pathfinder's masonry works), scrolls vertically.
|
||||||
- Right-edge **drag-to-resize** between integer widths (1×, 2×, 3×, … — no cap): shadow snaps at the inter-column gap with 10pt release hysteresis; the window grows/shrinks by one standard width per snap so other lanes keep their exact size. **At the screen's visible frame the window stops and the drag degrades to the re-divide** (settled 2026-08-08, superseding the pathfinder's hard stop): further snaps re-divide the now-fixed width across one more unit each — siblings compress, exactly the stepper's mechanism — so a lane keeps growing at the siblings' expense once the screen is spent; a window with no headroom to begin with (already at the edge, or full screen) re-divides from the first snap. The two regimes meet without a pixel jump — the re-divided standard at the screen fit *is* the frozen standard. Shrinking mirrors it: re-divide back down to the screen fit, then window shrinking resumes (and shrinking is always allowed). The rubber-band feedback (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound) moves to the true end of travel — the strip's own capacity — and the window still never overflows the screen. The header context menu's Width control (stepper, uncapped) remains the precise control: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress); the drag now reaches those widths too, but the stepper stays the exact, accessible path. The **Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘← — 11-command-nexus.md) are this stepper's keyboard face** — same re-divide semantics, never the window's size; window-growing behavior belongs to the drag alone — and they **batch over a multi-lane selection** (settled, the styling precedent): each selected lane steps one unit, one gesture, one commit; the context-menu stepper itself stays single-lane by nature. **A width write landing on 1 removes the `width` key** (settled — the remove-at-default family: the empty rename removes `title`, the None well removes `background`): a default lane's frontmatter stays clean, drag, stepper, and menu items alike; a hand-written `width: 1` is legal and preserved until the app itself next edits width. **A failed width commit at drag release rolls the window back** (settled): the failure surfaces as the ordinary one-shot banner and the window animates back by the uncommitted delta — 02-architecture.md's write-failure honesty (the action visibly doesn't happen) applied to the one control that moves the window.
|
- Right-edge **drag-to-resize** between integer widths (1×, 2×, 3×, … — no cap): shadow snaps at the inter-column gap with 10pt release hysteresis; the window grows/shrinks by one standard width per snap so other lanes keep their exact size. **At the screen's visible frame the window stops and the drag degrades to the re-divide** (settled 2026-08-08, superseding the pathfinder's hard stop): further snaps re-divide the now-fixed width across one more unit each — siblings compress, exactly the stepper's mechanism — so a lane keeps growing at the siblings' expense once the screen is spent; a window with no headroom to begin with (already at the edge, or full screen) re-divides from the first snap. The two regimes meet without a pixel jump — the re-divided standard at the screen fit *is* the frozen standard. Shrinking mirrors it: re-divide back down to the screen fit, then window shrinking resumes (and shrinking is always allowed). The rubber-band feedback (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound) moves to the true end of travel — the strip's own capacity — and the window still never overflows the screen. The header context menu's Width control (stepper, uncapped) remains the precise control: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress); the drag now reaches those widths too, but the stepper stays the exact, accessible path. The **Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘← — 11-command-nexus.md) are this stepper's keyboard face** — same re-divide semantics, never the window's size; window-growing behavior belongs to the drag alone — and they **batch over a multi-lane selection** (settled, the styling precedent): each selected lane steps one unit, one gesture, one commit; the context-menu stepper itself stays single-lane by nature. **A width write landing on 1 removes the `width` key** (settled — the remove-at-default family: the empty rename removes `title`, the None well removes `background`): a default lane's frontmatter stays clean, drag, stepper, and menu items alike; a hand-written `width: 1` is legal and preserved until the app itself next edits width. **A failed width commit at drag release rolls the window back** (settled): the failure surfaces as the ordinary one-shot banner and the window animates back by the uncommitted delta — 02-architecture.md's write-failure honesty (the action visibly doesn't happen) applied to the one control that moves the window.
|
||||||
- **Collapsed lanes** (settled 2026-08-08): a lane folds to a **fixed slim vertical strip** — its `icon`, its card-count badge, its title rotated a quarter turn **counterclockwise so it reads bottom-up** (the book-spine orientation) and **justified to the top** of the room below that chrome (ruled by the owner 2026-08-08, reversing the top-down reading of the first pass), and its accent band, at a fixed width (~44pt, an em multiple like every other figure) — and **the strip is not part of the width re-division**: the window's width divides across the *expanded* lanes' units alone, with each collapsed strip's fixed width taken off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family (the window never moves and the siblings grow into what the lane gave up). Persistence is a first-class lane frontmatter key, `collapsed: true` — **document state exactly like `width`**, because the files are the board and an agent must be able to fold a lane by editing frontmatter; absent means expanded, expanding **removes** the key (never writes `false` — the remove-at-default family), and the lane's `width` is preserved untouched while collapsed so expanding restores the lane the user had. **Toggling**: the header's trailing collapse chevron (always visible beside the new-card button — the header has no hover chrome to match), the lane context menu's single **Collapse Lane** / **Expand Lane** row (single-lane like the width stepper, not batched), and a plain click anywhere on the collapsed strip, which expands it; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. Rename and the expanded header's double-click behaviours are unchanged. **While collapsed**: no resize handle (the width stepper and ⌥⌘→/⌥⌘← still edit the preserved key), and the lane's cards are **not rendered at all** — which is what excludes them from the marquee sweep, from spatial keyboard navigation, and from the ⌥-arrow jumps (↓ from a folded lane is inert, like an empty one), and what makes ⌘N skip the lane for creation (a selection inside a folded lane falls through to the last-active lane rather than refusing). A drop on the strip **appends at the lane's end**, like an end-of-lane drop, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto-expand in v1 (future work). Lane reorder drag works on the strip, and a folded lane's replica and drop shadow are the strip rather than its units. **Search still matches cards in collapsed lanes** — the badge and the flatten lists count them, since collapse is a presentation fold rather than a filter — but the lane stays folded and shows no match: pointing into a folded lane is not something the current search presentation can do, and the honest minimum is that the query does not fold or unfold anything. The **trash quasi-lane is not collapsible**; multi-select styling and the Style editor are unaffected.
|
- **Collapsed lanes** (settled 2026-08-08): a lane folds to a **fixed slim vertical strip** — its `icon`, its card-count badge, its title rotated a quarter turn **counterclockwise so it reads bottom-up** (the book-spine orientation) and **justified to the top** of the room below that chrome (ruled by the owner 2026-08-08, reversing the top-down reading of the first pass), and its accent band, at a fixed width (~44pt, an em multiple like every other figure) — and **the strip is not part of the width re-division**: the window's width divides across the *expanded* lanes' units alone, with each collapsed strip's fixed width taken off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family (the window never moves and the siblings grow into what the lane gave up). Persistence is a first-class lane frontmatter key, `collapsed: true` — **document state exactly like `width`**, because the files are the board and an agent must be able to fold a lane by editing frontmatter; absent means expanded, expanding **removes** the key (never writes `false` — the remove-at-default family), and the lane's `width` is preserved untouched while collapsed so expanding restores the lane the user had. **Toggling**: the header's trailing collapse chevron (always visible, not hover-revealed — the header has no hover chrome to match), the lane context menu's single **Collapse Lane** / **Expand Lane** row (single-lane like the width stepper, not batched), and a plain click anywhere on the collapsed strip, which expands it; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. Rename and the expanded header's double-click behaviours are unchanged. **While collapsed**: no resize handle (the width stepper and ⌥⌘→/⌥⌘← still edit the preserved key), and the lane's cards are **not rendered at all** — which is what excludes them from the marquee sweep, from spatial keyboard navigation, and from the ⌥-arrow jumps (↓ from a folded lane is inert, like an empty one), and what makes ⌘N skip the lane for creation (a selection inside a folded lane falls through to the last-active lane rather than refusing). A drop on the strip **appends at the lane's end**, like an end-of-lane drop, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto-expand in v1 (future work). Lane reorder drag works on the strip, and a folded lane's replica and drop shadow are the strip rather than its units. **Search still matches cards in collapsed lanes** — the badge and the flatten lists count them, since collapse is a presentation fold rather than a filter — but the lane stays folded and shows no match: pointing into a folded lane is not something the current search presentation can do, and the honest minimum is that the query does not fold or unfold anything. The **trash quasi-lane is not collapsible**; multi-select styling and the Style editor are unaffected.
|
||||||
|
|
||||||
## Card face
|
## Card face
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ Every command is a menu item. The full inventory — every command and action, i
|
|||||||
- **⌫/⌘⌫ delete** (resettled 2026-07-28; lanes rejoined 2026-07-29): on cards *and lanes*, a move into the trash (`.trash/`, top position — 03-board-ui.md; a lane travels subtree-intact, `kind: lane` stamped when absent, no dialog — recoverable now, so nothing needs confirming); on a **trash** selection the same chord deletes **permanently** (one Delete vocabulary, staged by place — confirmation per 03's recoverability rule, a lane's alert counting its cards, a mixed trash selection's alert counting both kinds). Selection moves to the deleted item's successor sibling, Finder-style (next card in the lane, next lane on the board; the last sibling's predecessor otherwise; empty container = nothing selected) — repeated ⌫ walks down a lane. **In the trash the successor walk is kind-blind** (ruled 2026-07-31): the next row of either kind, in the same all-rows order plain arrows walk — a successor is a fresh singleton selection, so the landing violates no grammar, and repeated ⌘⌫ empties a mixed trash without dead-ends, each delete confirm-gated per its kind. Deliberate deletes pick a successor; *external* vanishing never does (02-architecture.md's reload-survival rule: the selection just shrinks). **Put Back is retired with the tombstone model** (resettled 2026-07-28): File ▸ Delete is the chord's only owner — no twin menu items, no shared-equivalent routing; restore is drag-out or ⌘X/⌘V (The trash below). Plain ⌫ performs the same delete as fixed grammar (see Grammar above) — there is no Edit ▸ Delete item, so the two Delete-titled homes never collide for title-matched remapping.
|
- **⌫/⌘⌫ delete** (resettled 2026-07-28; lanes rejoined 2026-07-29): on cards *and lanes*, a move into the trash (`.trash/`, top position — 03-board-ui.md; a lane travels subtree-intact, `kind: lane` stamped when absent, no dialog — recoverable now, so nothing needs confirming); on a **trash** selection the same chord deletes **permanently** (one Delete vocabulary, staged by place — confirmation per 03's recoverability rule, a lane's alert counting its cards, a mixed trash selection's alert counting both kinds). Selection moves to the deleted item's successor sibling, Finder-style (next card in the lane, next lane on the board; the last sibling's predecessor otherwise; empty container = nothing selected) — repeated ⌫ walks down a lane. **In the trash the successor walk is kind-blind** (ruled 2026-07-31): the next row of either kind, in the same all-rows order plain arrows walk — a successor is a fresh singleton selection, so the landing violates no grammar, and repeated ⌘⌫ empties a mixed trash without dead-ends, each delete confirm-gated per its kind. Deliberate deletes pick a successor; *external* vanishing never does (02-architecture.md's reload-survival rule: the selection just shrinks). **Put Back is retired with the tombstone model** (resettled 2026-07-28): File ▸ Delete is the chord's only owner — no twin menu items, no shared-equivalent routing; restore is drag-out or ⌘X/⌘V (The trash below). Plain ⌫ performs the same delete as fixed grammar (see Grammar above) — there is no Edit ▸ Delete item, so the two Delete-titled homes never collide for title-matched remapping.
|
||||||
- **Select All**: all visible cards on the board — filter-respecting, like every surface (Search below). **On the active trash side it selects the trash** (resettled 2026-07-28): with the trash visible and a non-empty trash selection, Select All selects all visible trash cards; in every other state, all visible live cards — the container boundary decides which "all" is meant (The trash below).
|
- **Select All**: all visible cards on the board — filter-respecting, like every surface (Search below). **On the active trash side it selects the trash** (resettled 2026-07-28): with the trash visible and a non-empty trash selection, Select All selects all visible trash cards; in every other state, all visible live cards — the container boundary decides which "all" is meant (The trash below).
|
||||||
- **The contract's one carve-out is configuration** (settled; containers re-ruled 2026-07-31 — the popover/sheet split — and **re-ruled back 2026-08-07**): form-like configuration keeps exactly one home per control, and that home is the **board popover** — rename, styling, branch display and *switching*, branch *creation*, add git, commit identity, ahead/behind with Pull/Push, the status badges; its keyboard path is Board Info (⌘I) plus Tab-reachable controls (10-accessibility.md's Full Keyboard Access). *(For a week, setup lived in a board settings sheet reached by Board ▸ Board Settings…; the split's mechanical argument — setup flows fire confirmation alerts, run inline network probes, and accept drag-in key import, acts that want a surface a stray click can't dismiss — did not survive the cost of a second validated surface for controls a user could otherwise reach directly. 03-board-ui.md ▸ Board settings sheet carries the retirement, and 07-sync-collab.md's credential and SSH surfaces are where the argument gets its next hearing.)* Recurring remote *operations* stay under the contract: Board ▸ Pull and Board ▸ Push are menu items (no default chord, remappable; validation enables them only on remote-backed boards — 07-sync-collab.md).
|
- **The contract's one carve-out is configuration** (settled; containers re-ruled 2026-07-31 — the popover/sheet split — and **re-ruled back 2026-08-07**): form-like configuration keeps exactly one home per control, and that home is the **board popover** — rename, styling, branch display and *switching*, branch *creation*, add git, commit identity, ahead/behind with Pull/Push, the status badges; its keyboard path is Board Info (⌘I) plus Tab-reachable controls (10-accessibility.md's Full Keyboard Access). *(For a week, setup lived in a board settings sheet reached by Board ▸ Board Settings…; the split's mechanical argument — setup flows fire confirmation alerts, run inline network probes, and accept drag-in key import, acts that want a surface a stray click can't dismiss — did not survive the cost of a second validated surface for controls a user could otherwise reach directly. 03-board-ui.md ▸ Board settings sheet carries the retirement, and 07-sync-collab.md's credential and SSH surfaces are where the argument gets its next hearing.)* Recurring remote *operations* stay under the contract: Board ▸ Pull and Board ▸ Push are menu items (no default chord, remappable; validation enables them only on remote-backed boards — 07-sync-collab.md).
|
||||||
- **⌘N target rule** (settled): 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** (settled — 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; the lane header's new-card button **overrides this rule** — the click names its target lane, selection notwithstanding (11-command-nexus.md ▸ Pointer grammar); 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. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first.
|
- **⌘N target rule** (settled): 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** (settled — 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. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first.
|
||||||
|
|
||||||
### The trash, keyboard-first (resettled 2026-07-28 — the materialized trash)
|
### The trash, keyboard-first (resettled 2026-07-28 — the materialized trash)
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App
|
|||||||
## Search
|
## Search
|
||||||
|
|
||||||
- Search field invoked with ⌘F (the board toolbar's sole default item; removed from the toolbar, ⌘F surfaces it transiently — 03-board-ui.md ▸ Toolbar; in the **card window**, Edit ▸ Find is find-in-text instead — 05-card-window.md), live filter: cards whose title *and* body both miss the query animate out; case/diacritic-insensitive substring. Scope is **all card content the format makes meaningful** (re-ruled 2026-07-29, superseding title-plus-body-only): title + body today; **comment bodies join when comments ship** — via a search-owned transient comment index, never the snapshot: the first live-query keystroke kicks an async sweep of `comments/*/index.md` bodies (`.draft` and `comments/.trash/` excluded), kept fresh by the same FSEvents stream while a query is active and discarded when it clears — the board walk stays O(cards), 01's window-scoped read untouched; **attributes join as they activate** (title now; labels/tags and their kin are reserved, inert keys this version — nothing to search until a future version gives them life). Attachment filenames stay unsearched. Scope options (content vs attributes, either/or) are WISHLIST #10.
|
- Search field invoked with ⌘F (the board toolbar's sole default item; removed from the toolbar, ⌘F surfaces it transiently — 03-board-ui.md ▸ Toolbar; in the **card window**, Edit ▸ Find is find-in-text instead — 05-card-window.md), live filter: cards whose title *and* body both miss the query animate out; case/diacritic-insensitive substring. Scope is **all card content the format makes meaningful** (re-ruled 2026-07-29, superseding title-plus-body-only): title + body today; **comment bodies join when comments ship** — via a search-owned transient comment index, never the snapshot: the first live-query keystroke kicks an async sweep of `comments/*/index.md` bodies (`.draft` and `comments/.trash/` excluded), kept fresh by the same FSEvents stream while a query is active and discarded when it clears — the board walk stays O(cards), 01's window-scoped read untouched; **attributes join as they activate** (title now; labels/tags and their kin are reserved, inert keys this version — nothing to search until a future version gives them life). Attachment filenames stay unsearched. Scope options (content vs attributes, either/or) are WISHLIST #10.
|
||||||
- The filter is the single source of truth for "what's on the board": layout, drop zones, marquee, ranges, arrow nav, and lane count badges all read it. **A drop under an active filter counts in the rendered space** (ruled 2026-08-06, closing the divergence found 2026-08-01): the zones, the shadow, and the write all resolve through the same filtered list — the position the shadow shows is the landing the write performs (the shipped interim — zones and write counting the unfiltered lane while the slots rendered filtered — was self-consistent but named a position the user could not see; retired). The slot's meaning in the full order is its **visible anchor**: the dropped card enters immediately *after* the slot's visible predecessor — or immediately *before* its visible successor when the slot has none (top of the lane) — and a lane the query emptied appends at its true end; a multi-card drop enters in proposal order beside the same anchor. Hidden cards keep their ranks untouched, never displaced by a gesture that could not see them — and the anchor is what makes the gesture's meaning survive the query's clearing: when the filter lifts, the card sits exactly beside the card it was dropped against. The rendered space means everything rendered — the new-card placeholder occupies its slot for the zones exactly as it does for layout. Hidden cards leave the selection; creating a card clears the search — creation's carve-out exists because a brand-new card must not be born invisible, and it is **stated by mechanism, not by gesture** (settled): *any* user-initiated creation on the board clears the query — ⌘N, Return-creation, the header button, empty-space double-click, paste, and Finder file drops alike — while foreign/agent-filed cards keep riding the live filter (02-architecture.md's derived-result rule). **Rename deliberately gets no carve-out**: a rename committed during an active search re-runs the predicate like any edit — a title that stops matching animates the card out and drops it from the selection, exactly as an agent's edit would; the filter stays a pure predicate with one exception, not two. **Escape is staged** (settled): in a non-empty field it clears the query, focus staying in the field; in an empty field it returns focus to the board; with *board* focus and an active search, one press clears the search and the full board returns — search takes Escape before its clear-selection meaning, which applies only when no search is active. **A lane the query empties keeps its slot** (settled): lanes are never filtered out — an all-misses lane stays on the board at its width with a 0 badge (the count reads the filter, 03-board-ui.md); the search filters cards, and the board's structure is not a search result. **A leaving card stays input-reachable for its out-transition** (settled): marquee and arrow targets deregister when the ~0.28 s animate-out ends, so a card mid-departure is briefly reachable while already out of the selection — accepted: it is literally on screen for that span, and closing the window would teach three input sites a predicate the layout already applied. **An open inline rename survives the filter hiding its card** (settled): the editor is a surface the filter doesn't reach — it stays open and focused, commits by UUID wherever the card lives, Escape abandons; keystrokes are never silently discarded for a card that still exists (the dirty-buffer courtesy), and the vanish-discard rule stays reserved for true liveness flips. The typed-query path can't even occur — focusing the search field is focus loss, which commits the rename first — so the rule covers foreign edits that stop the card matching.
|
- The filter is the single source of truth for "what's on the board": layout, drop zones, marquee, ranges, arrow nav, and lane count badges all read it. **A drop under an active filter counts in the rendered space** (ruled 2026-08-06, closing the divergence found 2026-08-01): the zones, the shadow, and the write all resolve through the same filtered list — the position the shadow shows is the landing the write performs (the shipped interim — zones and write counting the unfiltered lane while the slots rendered filtered — was self-consistent but named a position the user could not see; retired). The slot's meaning in the full order is its **visible anchor**: the dropped card enters immediately *after* the slot's visible predecessor — or immediately *before* its visible successor when the slot has none (top of the lane) — and a lane the query emptied appends at its true end; a multi-card drop enters in proposal order beside the same anchor. Hidden cards keep their ranks untouched, never displaced by a gesture that could not see them — and the anchor is what makes the gesture's meaning survive the query's clearing: when the filter lifts, the card sits exactly beside the card it was dropped against. The rendered space means everything rendered — the new-card placeholder occupies its slot for the zones exactly as it does for layout. Hidden cards leave the selection; creating a card clears the search — creation's carve-out exists because a brand-new card must not be born invisible, and it is **stated by mechanism, not by gesture** (settled): *any* user-initiated creation on the board clears the query — ⌘N, Return-creation, empty-space double-click, paste, and Finder file drops alike — while foreign/agent-filed cards keep riding the live filter (02-architecture.md's derived-result rule). **Rename deliberately gets no carve-out**: a rename committed during an active search re-runs the predicate like any edit — a title that stops matching animates the card out and drops it from the selection, exactly as an agent's edit would; the filter stays a pure predicate with one exception, not two. **Escape is staged** (settled): in a non-empty field it clears the query, focus staying in the field; in an empty field it returns focus to the board; with *board* focus and an active search, one press clears the search and the full board returns — search takes Escape before its clear-selection meaning, which applies only when no search is active. **A lane the query empties keeps its slot** (settled): lanes are never filtered out — an all-misses lane stays on the board at its width with a 0 badge (the count reads the filter, 03-board-ui.md); the search filters cards, and the board's structure is not a search result. **A leaving card stays input-reachable for its out-transition** (settled): marquee and arrow targets deregister when the ~0.28 s animate-out ends, so a card mid-departure is briefly reachable while already out of the selection — accepted: it is literally on screen for that span, and closing the window would teach three input sites a predicate the layout already applied. **An open inline rename survives the filter hiding its card** (settled): the editor is a surface the filter doesn't reach — it stays open and focused, commits by UUID wherever the card lives, Escape abandons; keystrokes are never silently discarded for a card that still exists (the dirty-buffer courtesy), and the vanish-discard rule stays reserved for true liveness flips. The typed-query path can't even occur — focusing the search field is focus loss, which commits the rename first — so the rule covers foreign edits that stop the card matching.
|
||||||
- **Dispatch while the search field is focused** (settled): the field is a *control*, not a content editor — the focused-editor lockdown (Grammar above) does not apply. Text-domain keys route to the field: ⌘A/⌘X/⌘C/⌘V act on the query, plain ⌫ edits the query and never reaches the board, horizontal arrows move the caret. **Every key with the field focused acts on the field — stock NSSearchField behavior, no pass-throughs** (settled): vertical arrows are caret movement, ⇧-arrows select query text, and **Return is a swallowed no-op** (the filter is live, there is nothing to submit — it never reaches the board's rename/create grammar). **Tab is the keep-filter path**: plain key-view traversal moves focus to the board with the query intact, and the whole board grammar (arrows, ⌥↑ escalation, Return, ⌘↩) then applies over the *filtered* board; ⌘F returns to the field. Board menu commands stay enabled and act on the board selection exactly as when the field is unfocused — ⌘N included (creating a card clears the search, above) — **except the caret-chord commands**: Move Left/Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable while the field is focused (Grammar above, caret-chords rule), so ⌘←/⌘→ stay line-start/end in the query even with a lane selected — and the Delete pair stays unambiguous by construction: plain ⌫ is query editing, ⌘⌫ is File ▸ Delete on the selection, and ⌘Z/⇧⌘Z are the field's own text undo, never git undo (06-history-undo.md ▸ Undo routing's control-class rule).
|
- **Dispatch while the search field is focused** (settled): the field is a *control*, not a content editor — the focused-editor lockdown (Grammar above) does not apply. Text-domain keys route to the field: ⌘A/⌘X/⌘C/⌘V act on the query, plain ⌫ edits the query and never reaches the board, horizontal arrows move the caret. **Every key with the field focused acts on the field — stock NSSearchField behavior, no pass-throughs** (settled): vertical arrows are caret movement, ⇧-arrows select query text, and **Return is a swallowed no-op** (the filter is live, there is nothing to submit — it never reaches the board's rename/create grammar). **Tab is the keep-filter path**: plain key-view traversal moves focus to the board with the query intact, and the whole board grammar (arrows, ⌥↑ escalation, Return, ⌘↩) then applies over the *filtered* board; ⌘F returns to the field. Board menu commands stay enabled and act on the board selection exactly as when the field is unfocused — ⌘N included (creating a card clears the search, above) — **except the caret-chord commands**: Move Left/Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable while the field is focused (Grammar above, caret-chords rule), so ⌘←/⌘→ stay line-start/end in the query even with a lane selected — and the Delete pair stays unambiguous by construction: plain ⌫ is query editing, ⌘⌫ is File ▸ Delete on the selection, and ⌘Z/⇧⌘Z are the field's own text undo, never git undo (06-history-undo.md ▸ Undo routing's control-class rule).
|
||||||
|
|
||||||
## Changes from Kanban
|
## Changes from Kanban
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
|
|||||||
|
|
||||||
## The board through VoiceOver
|
## The board through VoiceOver
|
||||||
|
|
||||||
- **Tree shape**: window → lanes (accessibility containers, in lane `order`) → cards (leaf elements, in card `order`). A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like the visible badge (04-interactions.md). The lane header's new-card button is a labeled child ("New card in ⟨lane⟩"). A card is **one flattened element**: label = title (or the untitled placeholder), value carries the attachment count when present, selected state via trait. Face icon and chips are decorative — folded into the element, never separately focusable: the flattened element carries the attachment count in its value, and the accessible attachment surface is the card window's keyboard-native section (below); the face itself has no media presentation (03-board-ui.md's no-carousel resettlement).
|
- **Tree shape**: window → lanes (accessibility containers, in lane `order`) → cards (leaf elements, in card `order`). A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like the visible badge (04-interactions.md). The lane header's collapse chevron is a labeled child ("Collapse ⟨lane⟩"). A card is **one flattened element**: label = title (or the untitled placeholder), value carries the attachment count when present, selected state via trait. Face icon and chips are decorative — folded into the element, never separately focusable: the flattened element carries the attachment count in its value, and the accessible attachment surface is the card window's keyboard-native section (below); the face itself has no media presentation (03-board-ui.md's no-carousel resettlement).
|
||||||
- **Logical order, not masonry position** (decided): within a wide lane, VoiceOver reads cards by `order` — the interior grid columns are presentation only. This deliberately diverges from on-screen geometry (narrowed by the 2026-07-31 column-major masonry: walking down one column now *is* consecutive `order`; the divergence that remains is a geometry-sorted reading-order sweep — left-to-right, then down — which interleaves the columns); the spatial arrow-key model (04-interactions.md) remains available alongside, since board keyboard navigation keeps working with VoiceOver running.
|
- **Logical order, not masonry position** (decided): within a wide lane, VoiceOver reads cards by `order` — the interior grid columns are presentation only. This deliberately diverges from on-screen geometry (narrowed by the 2026-07-31 column-major masonry: walking down one column now *is* consecutive `order`; the divergence that remains is a geometry-sorted reading-order sweep — left-to-right, then down — which interleaves the columns); the spatial arrow-key model (04-interactions.md) remains available alongside, since board keyboard navigation keeps working with VoiceOver running.
|
||||||
- **VO cursor and app selection are independent** (Finder-style): moving the VoiceOver cursor never mutates selection. VO-Space on any selectable element — card or lane header — toggles its selection (the ⌘-click analogue — a toggle, never plain click's replace, 04-interactions.md ▸ Selection; ruled 2026-07-29: a replace would silently wipe a multi-element selection, and one uniform VO-Space rule means the user never has to know the element kind to predict Space); ⌘↩ opens the card window; arrow keys and ⇧-arrows drive selection exactly as without VoiceOver. Selection state is always readable from the element (trait), and cut cards expose their dimmed pending state in the value ("cut, pending paste").
|
- **VO cursor and app selection are independent** (Finder-style): moving the VoiceOver cursor never mutates selection. VO-Space on any selectable element — card or lane header — toggles its selection (the ⌘-click analogue — a toggle, never plain click's replace, 04-interactions.md ▸ Selection; ruled 2026-07-29: a replace would silently wipe a multi-element selection, and one uniform VO-Space rule means the user never has to know the element kind to predict Space); ⌘↩ opens the card window; arrow keys and ⇧-arrows drive selection exactly as without VoiceOver. Selection state is always readable from the element (trait), and cut cards expose their dimmed pending state in the value ("cut, pending paste").
|
||||||
- **Actions come from the context menu.** Context menus are the single inventory of per-item actions (Open, Rename, Delete, width stepper, …), reachable the standard way (VO-⇧-M); where SwiftUI additionally surfaces menu items as custom accessibility actions, that's free improvement, not a separate design surface. **The custom-action cut** (confirmed 2026-07-29): every plain button row of an item's context menu becomes a custom action; rows that open their own accessible surface (Style…'s popover) and non-action controls (the quick-style swatch picker) stay menu-only — the context menu remains the full inventory either way. Each action must call the same method as its menu row, so the two surfaces cannot drift.
|
- **Actions come from the context menu.** Context menus are the single inventory of per-item actions (Open, Rename, Delete, width stepper, …), reachable the standard way (VO-⇧-M); where SwiftUI additionally surfaces menu items as custom accessibility actions, that's free improvement, not a separate design surface. **The custom-action cut** (confirmed 2026-07-29): every plain button row of an item's context menu becomes a custom action; rows that open their own accessible surface (Style…'s popover) and non-action controls (the quick-style swatch picker) stay menu-only — the context menu remains the full inventory either way. Each action must call the same method as its menu row, so the two surfaces cannot drift.
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ All board grammar keys are inert while a title editor is focused, and menu dispa
|
|||||||
- **Lane edge drag** (03 ▸ Lane): window-growing resize between integer widths — the one width control that moves the window.
|
- **Lane edge drag** (03 ▸ Lane): window-growing resize between integer widths — the one width control that moves the window.
|
||||||
- **Finder file drops** (04): onto a card = attach; onto lane empty space = one card per file; anywhere on the card window = attach (05's payload-split precedence).
|
- **Finder file drops** (04): onto a card = attach; onto lane empty space = one card per file; anywhere on the card window = attach (05's payload-split precedence).
|
||||||
- **Preview** (05): task-list checkbox toggle (the one interactive exception), link opens, text selection. **In-content controls aren't P-only** (settled): checkboxes and links are real controls in the focus/accessibility tree, so Full Keyboard Access + Space and VO-Space reach them (05 ▸ Task-list checkboxes) — content rides the system focus model rather than earning command rows; "a command absent here doesn't exist" scopes to commands, not content.
|
- **Preview** (05): task-list checkbox toggle (the one interactive exception), link opens, text selection. **In-content controls aren't P-only** (settled): checkboxes and links are real controls in the focus/accessibility tree, so Full Keyboard Access + Space and VO-Space reach them (05 ▸ Task-list checkboxes) — content rides the system focus model rather than earning command rows; "a command absent here doesn't exist" scopes to commands, not content.
|
||||||
- **Lane header new-card button** (03, labeled "New card in ⟨lane⟩" — 10): creates in **the button's lane**, appended at the bottom — the click names its target, overriding 04's selection-derived ⌘N target rule (settled); placeholder and abandon semantics exactly as ⌘N. The card window's attachments-section **quiet add affordance** (05) is the same class: a pointer twin of File ▸ Add Attachment…, no separate behavior.
|
- **The card window's attachments-section quiet add affordance** (05): a pointer twin of File ▸ Add Attachment…, no separate behavior. (The lane header once carried a matching "+" new-card button with its own click-names-target carve-out over 04's ⌘N rule; it is removed — every creation path now goes through the selection-derived rule, Return on a selected lane, or empty-space double-click.)
|
||||||
- **Attachment rows** drag out their file URL (05). **Welcome rows**: single click selects, double click opens (03).
|
- **Attachment rows** drag out their file URL (05). **Welcome rows**: single click selects, double click opens (03).
|
||||||
|
|
||||||
## Context menus
|
## Context menus
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Foundation
|
|||||||
/// ### Why the strings live here and not at the modifiers
|
/// ### Why the strings live here and not at the modifiers
|
||||||
///
|
///
|
||||||
/// 10-accessibility.md states the board's tree as *sentences*: a lane container is
|
/// 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 —
|
/// 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
|
/// 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
|
/// 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))"
|
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
|
/// 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
|
/// lanes), the header's one labeled child: a glyph-only control needs a word, and the word names
|
||||||
/// glyph-only control needs a word, and the word names the lane so the label stands on its own out
|
/// the lane so the label stands on its own out of context.
|
||||||
/// of context.
|
|
||||||
static func collapseLaneLabel(lane title: String?) -> String {
|
static func collapseLaneLabel(lane title: String?) -> String {
|
||||||
"Collapse \(displayTitle(title))"
|
"Collapse \(displayTitle(title))"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,35 +115,27 @@ enum BoardMetrics {
|
|||||||
em(0.4, bodyPointSize: bodyPointSize)
|
em(0.4, bodyPointSize: bodyPointSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trailing room the header reserves for the new-card button, so **a long title truncates
|
/// The room the header reserves for the **collapse chevron**, its one piece of trailing chrome
|
||||||
/// before it collides with the button** rather than running under it.
|
/// (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
|
/// 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 22pt would be
|
/// `.imageScale(.small)`, which *is* a relative size, so a reserve fixed at 18pt would be overrun
|
||||||
/// overrun by the glyph itself at a large system text size and the truncation rule would stop
|
/// by the glyph itself at a large system text size and the truncation rule would stop being true.
|
||||||
/// being true. 1.7 em is the button plus its breathing room, measured in the same unit the glyph
|
/// 1.4 em — 18pt at the standard 13pt body — is the glyph plus its breathing room, measured in the
|
||||||
/// grows in.
|
/// same unit it 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.
|
|
||||||
static func laneCollapseButtonReserve(bodyPointSize: CGFloat) -> CGFloat {
|
static func laneCollapseButtonReserve(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
em(1.4, bodyPointSize: bodyPointSize)
|
em(1.4, bodyPointSize: bodyPointSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything the lane header's trailing chrome takes — the chevron plus the new-card button. The
|
/// Everything the lane header's trailing chrome takes — the collapse chevron alone now that the
|
||||||
/// one figure the header pads by, so "a long title truncates before it collides" stays one rule
|
/// new-card button beside it is gone (this milestone narrowed this from a two-term sum to one).
|
||||||
/// however many controls end up living there.
|
/// 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 {
|
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:
|
/// The count badge's capsule inset — horizontal and vertical, which are deliberately different:
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ struct BoardView: View {
|
|||||||
// **The board is one tab stop** (10-accessibility.md ▸ Full Keyboard Access: "the board is
|
// **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,
|
// 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.
|
// 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
|
// 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").
|
// of the same sentence ("every control … is Tab-reachable").
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ import SwiftUI
|
|||||||
///
|
///
|
||||||
/// Leading SF Symbol from `icon` — lenient, an unknown name renders the `square.stack` default
|
/// 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
|
/// (`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
|
/// 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**
|
/// (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
|
/// 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
|
/// 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,
|
/// 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.
|
/// 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
|
/// ### 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
|
/// 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
|
/// 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
|
/// 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
|
/// no rename target and no collapse chevron — the strip's own plain click is its reversal instead. The
|
||||||
/// expands, a modified click selects the lane, and a drag reorders it.
|
/// strip keeps exactly three gestures: a plain click expands, a modified click selects the lane, and a
|
||||||
|
/// drag reorders it.
|
||||||
///
|
///
|
||||||
/// ### The card face
|
/// ### The card face
|
||||||
///
|
///
|
||||||
@@ -223,7 +226,7 @@ struct LaneView: View, Equatable {
|
|||||||
.onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
|
.onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
|
||||||
// **The lane is an accessibility container** — "window → lanes (accessibility containers, in
|
// **The lane is an accessibility container** — "window → lanes (accessibility containers, in
|
||||||
// lane `order`) → cards (leaf elements, in card `order`)" (10-accessibility.md ▸ The board
|
// 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
|
// 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).
|
// VoiceOver cursor enters (`TrashLaneView` states the same rule from the trash's side).
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
@@ -456,13 +459,11 @@ struct LaneView: View, Equatable {
|
|||||||
}
|
}
|
||||||
.onDisappear { drops.registry.removeHeader(lane.id) }
|
.onDisappear { drops.registry.removeHeader(lane.id) }
|
||||||
// **The header's trailing chrome, outside the drag region** — the overlay is attached
|
// **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
|
// after `.onDrag` deliberately, which is what makes the chevron clickable rather than
|
||||||
// grabbable (the new-card button's long-standing arrangement, now shared).
|
// grabbable (the new-card button's old arrangement, now the chevron's alone — the button
|
||||||
|
// itself is gone).
|
||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
HStack(spacing: 0) {
|
|
||||||
collapseButton
|
collapseButton
|
||||||
newCardButton
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// **10-accessibility.md's ≥ 4.5:1 rule, at the one place on the board where text sits on
|
// **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,
|
// a colour the user chose** (`BoardTextInk`) — palette name and hand-written hex alike,
|
||||||
@@ -478,7 +479,7 @@ struct LaneView: View, Equatable {
|
|||||||
// colour.
|
// colour.
|
||||||
//
|
//
|
||||||
// **Placed here, not at the end of the chain**, which is the modifier order doing real
|
// **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…
|
// 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
|
// 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.
|
// chrome drawn on its own surface and not on this board's colour.
|
||||||
@@ -687,11 +688,11 @@ struct LaneView: View, Equatable {
|
|||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
// Reserves the trailing chrome's width so a long title truncates before it collides, and keeps
|
// 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
|
// the chevron out of the gestured region. **Font-derived** rather than a fixed figure: it is an
|
||||||
// are `Image`s at a relative image scale, so a fixed reserve would be overrun by the glyphs
|
// `Image` at a relative image scale, so a fixed reserve would be overrun by the glyph itself at
|
||||||
// themselves at a large system text size and 03-board-ui.md's graceful-truncation rule would
|
// a large system text size and 03-board-ui.md's graceful-truncation rule would quietly stop
|
||||||
// quietly stop holding (`BoardMetrics.laneHeaderTrailingReserve`, which is the collapse
|
// holding (`BoardMetrics.laneHeaderTrailingReserve`, the collapse chevron's own room now that
|
||||||
// chevron's room plus the new-card button's).
|
// the new-card button that once shared it is gone).
|
||||||
.padding(.trailing, BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: pointSize))
|
.padding(.trailing, BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: pointSize))
|
||||||
.padding(.horizontal, BoardMetrics.laneHeaderInset(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
|
/// **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
|
/// **Always visible, not hover-revealed**, because the header has no hover chrome at all: an
|
||||||
/// new-card button beside it has been unconditional since it existed, and one control that appears
|
/// always-on glyph that only sometimes appeared would read as a glitch rather than as a convention.
|
||||||
/// on hover next to one that does not reads as a glitch rather than as a convention. The cost is
|
/// The cost is paid in the header's trailing reserve, which is the figure that keeps the title
|
||||||
/// paid in the header's trailing reserve, which is the figure that keeps the title truncating before
|
/// truncating before it (`headerContent`).
|
||||||
/// either glyph (`headerContent`).
|
|
||||||
///
|
///
|
||||||
/// `chevron.left` because the fold travels that way — the lane's content collapses toward its
|
/// `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
|
/// 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)
|
.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
|
// MARK: - The lane drag
|
||||||
|
|
||||||
/// Begins the lane's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
/// Begins the lane's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||||
|
|||||||
@@ -20,11 +20,11 @@
|
|||||||
///
|
///
|
||||||
/// ### What it deliberately does not decide
|
/// ### 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
|
/// - **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 {
|
enum NewCardTarget {
|
||||||
|
|
||||||
/// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the
|
/// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ extension View {
|
|||||||
/// change. Chrome outside the strip keeps using `.font(_:)` and stays at system size on purpose.
|
/// 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
|
/// 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 {
|
func boardFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View {
|
||||||
modifier(BoardFontModifier(style: style, weight: weight))
|
modifier(BoardFontModifier(style: style, weight: weight))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Testing
|
|||||||
/// the tree as sentences:
|
/// the tree as sentences:
|
||||||
///
|
///
|
||||||
/// > A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like
|
/// > A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like
|
||||||
/// > the visible badge. The lane header's new-card button is a labeled child ("New card in ⟨lane⟩").
|
/// > the visible badge. The lane header's collapse chevron is a labeled child ("Collapse ⟨lane⟩").
|
||||||
/// > A card is one flattened element: label = title (or the untitled placeholder), value carries the
|
/// > A card is one flattened element: label = title (or the untitled placeholder), value carries the
|
||||||
/// > attachment count when present, selected state via trait … cut cards expose their dimmed pending
|
/// > attachment count when present, selected state via trait … cut cards expose their dimmed pending
|
||||||
/// > state in the value ("cut, pending paste").
|
/// > state in the value ("cut, pending paste").
|
||||||
@@ -69,18 +69,6 @@ struct AccessibilityPhrasesTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The new-card button
|
|
||||||
|
|
||||||
@Test("The header button names its lane")
|
|
||||||
func newCardLabel() {
|
|
||||||
#expect(AccessibilityPhrases.newCardLabel(lane: "Doing") == "New card in Doing")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("The header button of an untitled lane names the placeholder")
|
|
||||||
func newCardLabelUntitled() {
|
|
||||||
#expect(AccessibilityPhrases.newCardLabel(lane: nil) == "New card in Untitled")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Card elements
|
// MARK: - Card elements
|
||||||
|
|
||||||
@Test("A card's label is its title")
|
@Test("A card's label is its title")
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ struct BoardZoomMetricsTests {
|
|||||||
("laneStackSpacing", { BoardMetrics.laneStackSpacing(bodyPointSize: $0) }),
|
("laneStackSpacing", { BoardMetrics.laneStackSpacing(bodyPointSize: $0) }),
|
||||||
("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
|
("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
|
||||||
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
|
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
|
||||||
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
|
|
||||||
("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }),
|
("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }),
|
||||||
("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(bodyPointSize: $0) }),
|
("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(bodyPointSize: $0) }),
|
||||||
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
||||||
|
|||||||
@@ -432,8 +432,8 @@ struct CreateCardsFromFilesTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// **The create half is a user-initiated creation, so it clears the query** (04-interactions.md
|
/// **The create half is a user-initiated creation, so it clears the query** (04-interactions.md
|
||||||
/// § Search, stated by mechanism: "⌘N, Return-creation, the header button, empty-space
|
/// § Search, stated by mechanism: "⌘N, Return-creation, empty-space double-click, paste, and
|
||||||
/// double-click, paste, and Finder file drops alike"). The card is titled `shot`, which the
|
/// Finder file drops alike"). The card is titled `shot`, which the
|
||||||
/// standing query would hide — the whole point of the carve-out.
|
/// standing query would hide — the whole point of the carve-out.
|
||||||
@Test("A file drop that creates cards clears the search — the same rule ⌘N obeys")
|
@Test("A file drop that creates cards clears the search — the same rule ⌘N obeys")
|
||||||
func creatingFromFilesClearsTheSearch() throws {
|
func creatingFromFilesClearsTheSearch() throws {
|
||||||
|
|||||||
@@ -359,8 +359,8 @@ struct PasteFromTrashTests {
|
|||||||
/// Two rules that meet at the same guard.
|
/// Two rules that meet at the same guard.
|
||||||
///
|
///
|
||||||
/// **A paste is a user-initiated creation, so it clears the destination's query**
|
/// **A paste is a user-initiated creation, so it clears the destination's query**
|
||||||
/// (04-interactions.md § Search, stated by mechanism: "⌘N, Return-creation, the header button,
|
/// (04-interactions.md § Search, stated by mechanism: "⌘N, Return-creation, empty-space
|
||||||
/// empty-space double-click, paste, and Finder file drops alike") — cards and lanes alike, since the
|
/// double-click, paste, and Finder file drops alike") — cards and lanes alike, since the
|
||||||
/// clipboard holds one or the other and both mint items on arrival.
|
/// clipboard holds one or the other and both mint items on arrival.
|
||||||
///
|
///
|
||||||
/// **The pasteboard is re-read lazily, and a stale paste no-ops** (04 ▸ Clipboard, settled):
|
/// **The pasteboard is re-read lazily, and a stale paste no-ops** (04 ▸ Clipboard, settled):
|
||||||
|
|||||||
@@ -39,12 +39,12 @@ struct BoardMetricsSettledFiguresTests {
|
|||||||
#expect(BoardMetrics.laneStackSpacing(bodyPointSize: size) == 8)
|
#expect(BoardMetrics.laneStackSpacing(bodyPointSize: size) == 8)
|
||||||
#expect(BoardMetrics.laneHeaderSpacing(bodyPointSize: size) == 6)
|
#expect(BoardMetrics.laneHeaderSpacing(bodyPointSize: size) == 6)
|
||||||
#expect(BoardMetrics.laneAccentBandHeight(bodyPointSize: size) == 5)
|
#expect(BoardMetrics.laneAccentBandHeight(bodyPointSize: size) == 5)
|
||||||
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) == 22)
|
// The fold's own figure (03-board-ui.md § Lane ▸ Collapsed lanes): the strip is the ruling's
|
||||||
// The fold's two figures (03-board-ui.md § Lane ▸ Collapsed lanes): the strip is the ruling's
|
// ~44pt, and the chevron's reserve is 18 — which is also the header's whole trailing budget
|
||||||
// ~44pt, and the chevron's reserve is 18 — together the header's 40pt trailing budget.
|
// now that the new-card button that once shared it is gone (m-remove-header-add).
|
||||||
#expect(BoardMetrics.collapsedLaneWidth(bodyPointSize: size) == 44)
|
#expect(BoardMetrics.collapsedLaneWidth(bodyPointSize: size) == 44)
|
||||||
#expect(BoardMetrics.laneCollapseButtonReserve(bodyPointSize: size) == 18)
|
#expect(BoardMetrics.laneCollapseButtonReserve(bodyPointSize: size) == 18)
|
||||||
#expect(BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size) == 40)
|
#expect(BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size) == 18)
|
||||||
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
|
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
|
||||||
#expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
|
#expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
|
||||||
#expect(BoardMetrics.cardContentPadding(bodyPointSize: size) == 10)
|
#expect(BoardMetrics.cardContentPadding(bodyPointSize: size) == 10)
|
||||||
@@ -95,7 +95,6 @@ struct BoardMetricsScalingTests {
|
|||||||
("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
|
("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
|
||||||
("laneHeaderInset", { BoardMetrics.laneHeaderInset(bodyPointSize: $0) }),
|
("laneHeaderInset", { BoardMetrics.laneHeaderInset(bodyPointSize: $0) }),
|
||||||
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
|
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
|
||||||
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
|
|
||||||
("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }),
|
("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }),
|
||||||
("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(bodyPointSize: $0) }),
|
("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(bodyPointSize: $0) }),
|
||||||
("laneHeaderTrailingReserve", { BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: $0) }),
|
("laneHeaderTrailingReserve", { BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: $0) }),
|
||||||
@@ -122,19 +121,21 @@ struct BoardMetricsScalingTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The new-card button's reserve stays ahead of the header's own furniture.**
|
/// **The header's trailing 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
|
/// 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
|
/// colliding with the collapse chevron, and the reserve is what makes that true. It has to stay
|
||||||
/// a glyph-and-margin at every size, which is the fixed-22pt failure this conversion exists to
|
/// wider than a glyph-and-margin at every size, which is the fixed-point-size failure this
|
||||||
/// remove: at 24pt the glyph alone approaches the old reserve.
|
/// conversion exists to remove: at 24pt the glyph alone approaches a stale fixed reserve. (Once
|
||||||
@Test("The header's button reserve stays wider than the glyph it reserves for")
|
/// the sum of two glyphs' room — the new-card button and the chevron — this is now the chevron's
|
||||||
func theButtonReserveOutgrowsItsGlyph() {
|
/// alone, the button having been removed.)
|
||||||
|
@Test("The header's trailing reserve stays wider than the glyph it reserves for")
|
||||||
|
func theTrailingReserveOutgrowsItsGlyph() {
|
||||||
for size in Self.sizes {
|
for size in Self.sizes {
|
||||||
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) > size,
|
#expect(BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size) > size,
|
||||||
"the reserve is narrower than one em at \(size)pt")
|
"the reserve is narrower than one em at \(size)pt")
|
||||||
#expect(
|
#expect(
|
||||||
BoardMetrics.newCardButtonReserve(bodyPointSize: size)
|
BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size)
|
||||||
> BoardMetrics.laneHeaderSpacing(bodyPointSize: size),
|
> BoardMetrics.laneHeaderSpacing(bodyPointSize: size),
|
||||||
"the reserve is narrower than the header's own spacing at \(size)pt"
|
"the reserve is narrower than the header's own spacing at \(size)pt"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ Start with VoiceOver on (⌘F5), the scratch board frontmatter, and the trash hi
|
|||||||
|
|
||||||
> The new lane arrives with its title editor focused. Type `Later` and press Return. The lane now reads **"Later, lane, 0 cards"**.
|
> The new lane arrives with its title editor focused. Type `Later` and press Return. The lane now reads **"Later, lane, 0 cards"**.
|
||||||
|
|
||||||
**2. Create a card.** With the new lane still active, ⌘N (File ▸ New Card). Alternatively VO-→ to the lane header's one child button, which reads **"New card in Later" [button]**, and VO-Space it.
|
**2. Create a card.** With the new lane still active, ⌘N (File ▸ New Card).
|
||||||
|
|
||||||
> The card placeholder appears with its editor focused. Type `Smoke test` and press Return. VO-→ onto it: **"Smoke test"**. No value is spoken — it has no attachments and is not cut.
|
> The card placeholder appears with its editor focused. Type `Smoke test` and press Return. VO-→ onto it: **"Smoke test"**. No value is spoken — it has no attachments and is not cut.
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ The pane ships with the comments feature; 10-accessibility.md ▸ Comments is th
|
|||||||
- [ ] **Increase Contrast** (Accessibility ▸ Display ▸ Increase contrast): strokes gain a flat point, borderless card and lane plates gain a resting separator hairline, faded accents go to full alpha, and the selection ring and plate edge both strengthen. Hierarchy must be preserved — everything strengthening by the same amount is the intent, not a bug.
|
- [ ] **Increase Contrast** (Accessibility ▸ Display ▸ Increase contrast): strokes gain a flat point, borderless card and lane plates gain a resting separator hairline, faded accents go to full alpha, and the selection ring and plate edge both strengthen. Hierarchy must be preserved — everything strengthening by the same amount is the intent, not a bug.
|
||||||
- [ ] **Reduce Transparency**: the transient search bar's glass goes solid, and so do the alpha washes that composite over a user-chosen board background — the trash plate, the hatched trash header, the drag shadow. Look for any remaining see-through surface.
|
- [ ] **Reduce Transparency**: the transient search bar's glass goes solid, and so do the alpha washes that composite over a user-chosen board background — the trash plate, the hatched trash header, the drag shadow. Look for any remaining see-through surface.
|
||||||
- [ ] **Reduce Motion**: movement animations go instant, appear/disappear transitions go crossfade — uniformly. Named cases to walk: reflow-on-drag, search animate-out, the drag replica's lift and settle, the lane-resize rubber band, the trash column's appear/disappear, and the store's reload seam (the largest animated surface in the app). The drag replica's 1:1 tracking and the selection marquee correctly have no reduced variant.
|
- [ ] **Reduce Motion**: movement animations go instant, appear/disappear transitions go crossfade — uniformly. Named cases to walk: reflow-on-drag, search animate-out, the drag replica's lift and settle, the lane-resize rubber band, the trash column's appear/disappear, and the store's reload seam (the largest animated surface in the app). The drag replica's 1:1 tracking and the selection marquee correctly have no reduced variant.
|
||||||
- [ ] **Full Keyboard Access** (Keyboard ▸ Keyboard navigation, VoiceOver **off**): the board is **one** tab stop, not one per card, and its focus ring becomes visible under FKA when it is hidden without. Tab must reach every lane's new-card button, the toolbar, the search field, the board popover's controls, the card window, and welcome.
|
- [ ] **Full Keyboard Access** (Keyboard ▸ Keyboard navigation, VoiceOver **off**): the board is **one** tab stop, not one per card, and its focus ring becomes visible under FKA when it is hidden without. Tab must reach every lane's collapse chevron, the toolbar, the search field, the board popover's controls, the card window, and welcome.
|
||||||
- [ ] **The template chooser is arrow-navigable** under FKA: tiles take focus, arrows move between them and clamp at the ends, Space picks, Return is still the sheet's default action, and focus follows the selection one way only.
|
- [ ] **The template chooser is arrow-navigable** under FKA: tiles take focus, arrows move between them and clamp at the ends, Space picks, Return is still the sheet's default action, and focus follows the selection one way only.
|
||||||
- [ ] **The style editor's grids** are arrow-navigable, every well is Tab-reachable and labeled by name (palette color, symbol name; leading wells "None" / "Default"), the current value is stated by trait, and a batch selection's mixed state reads as "mixed".
|
- [ ] **The style editor's grids** are arrow-navigable, every well is Tab-reachable and labeled by name (palette color, symbol name; leading wells "None" / "Default"), the current value is stated by trait, and a batch selection's mixed state reads as "mixed".
|
||||||
- [ ] **State is never colour-alone**, everywhere: selection is ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled.
|
- [ ] **State is never colour-alone**, everywhere: selection is ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled.
|
||||||
|
|||||||
Reference in New Issue
Block a user