From 95f00211c16facd501ac3b74073084ccc2a37b13 Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 29 Jul 2026 11:10:59 -0400 Subject: [PATCH] Resolve the 2026-07-29 design-review findings into the DESIGN docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settles the m11-accessibility findings and two undo-doc findings from the Redesign board (rationale per card in its Resolved lane): - 13: permanent delete stays non-undoable — the trash-section clause contradicting Rules is amended; staleness validation reads disk, not the snapshot (blessing the implemented false-skip fix). - 10: VO-Space toggles on any selectable element; the digest covers the trash while shown; a vanished head with surviving co-selection is still named (naming and recovery are independent axes); banner-row buttons are literal FKA Tab stops; lane-title landmark+heading doubling accepted; trash visibility wording pinned; the custom-action cut confirmed; banner transitions are origin-independent; the AA obligation binds the primary label tier. - 03: the palette AA claim rewritten to the real mechanism — one ink-selection code path for palette names and hex, pinned by PaletteContrastTests in both appearances. Code-conformance residue filed as "Realign code with the 2026-07-29 accessibility rulings" atop the Implementation Backlog. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97 --- DESIGN/03-board-ui.md | 2 +- DESIGN/10-accessibility.md | 18 +++++++++--------- DESIGN/13-native-undo.md | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/DESIGN/03-board-ui.md b/DESIGN/03-board-ui.md index 9778cae..b210439 100644 --- a/DESIGN/03-board-ui.md +++ b/DESIGN/03-board-ui.md @@ -41,7 +41,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i One **style editor** component — a background palette grid and a curated symbol grid — presented from three anchors: **embedded** in the card window sidebar's Style section (05-card-window.md) and in the board popover's styling area, and as a **popover** opened by Style… from a card/lane context menu or the menu bar (Board ▸ Style…, ⌥⌘S — 11-command-nexus.md; selection-aware: it styles the selected cards or lane, and with nothing selected, the board). One component, one behavior, three anchors — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't. -- **Palette-only in-app**: the background grid offers the 12 palette colors — every pair AA-verified at design time (10-accessibility.md), the claim pinned by a computed-contrast unit test over all 12 pairs so palette drift can never silently break it — plus a leading **None** well that removes the `background` key. Custom hex is not pickable in-app but stays fully honored from disk (runtime contrast, 10-accessibility.md): curated in-app, unlimited on disk. +- **Palette-only in-app**: the background grid offers the 12 palette colors — every one AA-verified through one code path (ratified 2026-07-29: palette names route through the same runtime ink-selection seam as hand-written hex — the appearance flip picks the readable label vocabulary — and PaletteContrastTests pins that the chosen ink meets AA in both appearances for all 12 backgrounds, so palette drift can never silently break it) — plus a leading **None** well that removes the `background` key. Custom hex is not pickable in-app but stays fully honored from disk (runtime contrast, 10-accessibility.md): curated in-app, unlimited on disk. - **Curated symbol grid**: a hand-picked set (roughly five dozen kanban-relevant SF Symbols); its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — named symbols the running OS knows, that is: inventories grow per macOS release, so a newer-OS name renders the level default on an older Mac, value preserved on disk — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch. - **Off-palette values display leniently**: a hand-written hex background or uncurated symbol shows as the current value in the editor (labeled verbatim, outside the grids); choosing any well replaces it. - **Batch edits**: a multi-selection shows per-dimension mixed state (no well selected, "—" where a value would read); choosing a well applies to the whole selection — one gesture, one commit on git boards. diff --git a/DESIGN/10-accessibility.md b/DESIGN/10-accessibility.md index 5b06acd..81e1e5c 100644 --- a/DESIGN/10-accessibility.md +++ b/DESIGN/10-accessibility.md @@ -12,10 +12,10 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na - **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). - **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; 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 a card toggles its selection (the ⌘-click analogue — a toggle, never plain click's replace, 04-interactions.md ▸ Selection); ⌘↩ 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, Delete Immediately, 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. -- **Rotor**: lane titles are headings, so the headings rotor jumps lane-to-lane — on a one-dimensional board that *is* structural navigation; no custom rotors unless practice shows the need. -- **Trash lane**: when shown (View ▸ Show Trash), it is the last container, labeled as Trash with its count; toggling visibility is announced. Its cards are ordinary card elements (resettled 2026-07-28 — the materialized trash: no lane entries, no special states beyond the container) exposing Delete and Reveal in Finder via the context menu; there is no Open (03-board-ui.md's no-editing-in-the-trash), and restore is drag-out or the ⌘X/⌘V keyboard path; keyboard reachability follows 04-interactions.md ▸ The trash. +- **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, Delete Immediately, 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. +- **Rotor**: lane titles are headings, so the headings rotor jumps lane-to-lane — on a one-dimensional board that *is* structural navigation; no custom rotors unless practice shows the need. **The title doubling is accepted** (ruled 2026-07-29): entering a lane reads the container label ("Doing, lane, 3 cards") and then the heading ("Doing, heading") — two elements, two purposes: the label gives boundary-crossing context, the heading feeds the rotor. This is the platform-standard landmark-plus-heading pattern; collapsing it would cost the on-entry announcement, the more valuable half. +- **Trash lane**: when shown (View ▸ Show Trash), it is the last container, labeled as Trash with its count; toggling visibility is announced — "Trash shown" / "Trash hidden" (confirmed 2026-07-29: resulting state, not the action, so a mis-hit tells the user where the board ended up and the toolbar toggle reads like the menu checkmark; the count stays on the container label, which VO reads on arrival, rather than duplicated into the announcement). Its cards are ordinary card elements (resettled 2026-07-28 — the materialized trash: no lane entries, no special states beyond the container) exposing Delete and Reveal in Finder via the context menu; there is no Open (03-board-ui.md's no-editing-in-the-trash), and restore is drag-out or the ⌘X/⌘V keyboard path; keyboard reachability follows 04-interactions.md ▸ The trash. - **Search**: filtered-out cards leave layout and the accessibility tree together — the filter is the single source of truth for "what's on the board" (04-interactions.md), and the tree is one of its readers. Lane counts announced reflect the filter. ## Moving without dragging @@ -27,9 +27,9 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na ## Live board announcements -- **Foreign changes announce, app-mediated echoes never do.** The auto-committer already classifies every observed change as app-mediated or foreign (the EchoLedger — 02-architecture.md ▸ Components) and synthesizes diff summaries for commit messages (06-history-undo.md); announcements reuse that summarizer — one polite (non-interrupting) digest per reload debounce ("Board changed: 2 cards edited, 1 card added"), never per-file chatter. On no-git boards the same classifier runs without the committer — announcements don't depend on git mode. -- **A vanishing focus is called out specifically.** If the selected or VO-focused card disappears in a reload (deleted externally, or gone with its deleted lane), the announcement names it ("Card 'Fix login' was deleted externally") and focus recovers to the card's lane (mirroring selection's reload-survival rules, 02-architecture.md). **When the lane itself vanished, recovery walks up then sideways** (settled): focus lands on the lane now occupying the vanished lane's position — the next lane by `order`, else the previous one — and on the board container only when no lanes remain (the ⌫-successor pattern, 04-interactions.md, applied to external change; never into the trash, which stays hidden — no layout side effects from a foreign edit). The announcement then names the *lane*, not the card ("Lane 'Doing' was deleted externally, with 5 cards") — the implied-events-don't-steal-the-subject discipline of 06-history-undo.md's composer, applied to speech. -- **Bracketed operations announce once, at completion** ("Pulled 3 commits", "Switched to branch 'redesign'") — never their internal churn (02-architecture.md's bracketing). The live-reload-resilience banner (02-architecture.md) is an accessibility element and is announced when it appears and when it clears — including the read-only lock after a failed bracketed reload. +- **Foreign changes announce, app-mediated echoes never do.** The auto-committer already classifies every observed change as app-mediated or foreign (the EchoLedger — 02-architecture.md ▸ Components) and synthesizes diff summaries for commit messages (06-history-undo.md); announcements reuse that summarizer — one polite (non-interrupting) digest per reload debounce ("Board changed: 2 cards edited, 1 card added"), never per-file chatter. On no-git boards the same classifier runs without the committer — announcements don't depend on git mode. **The digest covers the trash only while the trash lane is shown** (ruled 2026-07-29): with View ▸ Show Trash on, trash cards are ordinary elements of the visible board (▸ Trash lane above), so a foreign purge, restore, or Empty Trash joins the digest like any lane's churn — a user working in the shown trash must hear it emptied under them; while hidden, trash churn stays silent, matching "no layout side effects from a foreign edit". +- **A vanishing focus is called out specifically.** If the selected or VO-focused card disappears in a reload (deleted externally, or gone with its deleted lane), the announcement names it ("Card 'Fix login' was deleted externally") and focus recovers to the card's lane (mirroring selection's reload-survival rules, 02-architecture.md). **Naming and recovery are independent axes** (ruled 2026-07-29): when the head of a multi-selection vanishes but co-selected cards survive, the announcement still names the vanished head — the thing under the cursor was deleted, and that is what the rule exists to say — while the focus move is vetoed by the survivors (02's re-resolution rule: the head re-anchors within the surviving selection; a reload never edits a selection the user still partly holds). Vanished non-head members stay unnamed and fall to the digest's counts. **When the lane itself vanished, recovery walks up then sideways** (settled): focus lands on the lane now occupying the vanished lane's position — the next lane by `order`, else the previous one — and on the board container only when no lanes remain (the ⌫-successor pattern, 04-interactions.md, applied to external change; never into the trash, which stays hidden — no layout side effects from a foreign edit). The announcement then names the *lane*, not the card ("Lane 'Doing' was deleted externally, with 5 cards") — the implied-events-don't-steal-the-subject discipline of 06-history-undo.md's composer, applied to speech. +- **Bracketed operations announce once, at completion** ("Pulled 3 commits", "Switched to branch 'redesign'") — never their internal churn (02-architecture.md's bracketing). The live-reload-resilience banner (02-architecture.md) is an accessibility element and is announced when it appears and when it clears — including the read-only lock after a failed bracketed reload. **Banner transitions are origin-independent** (confirmed 2026-07-29): "app-mediated echoes never announce" governs the change digest — never narrate the user's own edits — but a banner appearing or clearing is surface liveness, visible to a sighted user regardless of cause, so it speaks on any reload origin (an app write whose reload clears a breakage is exactly a moment the user should hear "cleared"). Precedence ladder: raised condition > bracket completion > cleared condition > vanished focus > digest. ## Card window, welcome, template chooser, popover @@ -42,10 +42,10 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na ## Text scaling & visual accommodations - **Full relative scaling** (decided): relative text styles everywhere, no fixed point sizes. Card face, lane header, and masonry metrics derive from font metrics, so layout survives the largest system text sizes; the no-horizontal-scroll invariant is untouched (lane count is the user's choice; lanes scroll vertically), and 03-board-ui.md's graceful-truncation rules apply at every scale. -- **Contrast is pinned to WCAG AA.** Lane and card colors render as edge accents (03-board-ui.md's top-edge band / left-edge stripe), so text never sits on them — they are supplementary decoration, never the sole carrier of information, and carry no text-contrast obligation. The ≥ 4.5:1 automatic-contrast rule binds where text does sit on a user-chosen color: the **board** background (palette pairs verified at design time; arbitrary hex computes its text color at runtime against that threshold). An `#RRGGBBAA` background with alpha computes against the color **composited over its effective backdrop** in the active appearance (the board's over the window background; light and dark resolve differently), recomputed on appearance change. Increase Contrast strengthens borders and the selection indicator. +- **Contrast is pinned to WCAG AA.** Lane and card colors render as edge accents (03-board-ui.md's top-edge band / left-edge stripe), so text never sits on them — they are supplementary decoration, never the sole carrier of information, and carry no text-contrast obligation. The ≥ 4.5:1 automatic-contrast rule binds where text does sit on a user-chosen color: the **board** background (palette pairs verified at design time; arbitrary hex computes its text color at runtime against that threshold). An `#RRGGBBAA` background with alpha computes against the color **composited over its effective backdrop** in the active appearance (the board's over the window background; light and dark resolve differently), recomputed on appearance change. Palette names and hex share one ink-selection code path — the palette's AA claim is pinned by a computed-contrast test over all 12 backgrounds in both appearances (ratified 2026-07-29). **The AA obligation binds the primary label tier** (ruled 2026-07-29): the ink seam moves the whole label hierarchy with the primary, and subordinate tiers (.secondary, .quaternary) inherit the system vocabulary's own contrast posture, which sits below 4.5:1 on any background including the system's — the platform-standard reading; the strict path for users who need more is Increase Contrast, which raises accents and washes to full alpha (▸ Visual accommodations). Increase Contrast strengthens borders and the selection indicator. - **State is never color-alone**: selection is a ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled — all already patterned; kept as a rule. - **Reduce Motion is a per-voice rule, not a feature list** (settled): movement animations go **instant**, appear/disappear transitions go **crossfade**, uniformly — every animated surface derives its reduced variant from its voice, the store's reload seam included (the largest animated surface in the app), so new surfaces never need individual rulings. The named cases — reflow-on-drag, search animate-out, the drag replica's lift and settle transitions (its 1:1 tracking never animates, like the selection marquee, which needs no variant — 03-board-ui.md ▸ Motion), the lane-resize rubber-band feedback (03-board-ui.md ▸ Lane), trash animations — are applications of the rule, not the rule itself. **Reduce Transparency**: glass underlays go solid, wherever they appear. -- **Full Keyboard Access** (independent of VoiceOver): the board is one tab stop with arrow-key navigation within; every control — lane buttons, popover, card window, welcome — is Tab-reachable. +- **Full Keyboard Access** (independent of VoiceOver): the board is one tab stop with arrow-key navigation within; every control — lane buttons, popover, card window, welcome — is Tab-reachable. **"Every control" is literal and includes banner-row buttons** (ruled 2026-07-29): a Dismiss or Cancel on a banner must be a Tab stop — FKA serves sighted keyboard-only users, to whom VO custom actions are invisible, and Cancel on an in-progress operation is exactly the control that cannot require a pointer. This coexists with the VoiceOver presentation (one combined row-sentence with Dismiss/Cancel as custom actions): the AX combine and the FKA focus loop are independent surfaces; the implementation may uncombine conditionally under FKA if the focus system requires it. ## Verification diff --git a/DESIGN/13-native-undo.md b/DESIGN/13-native-undo.md index 3edface..9246918 100644 --- a/DESIGN/13-native-undo.md +++ b/DESIGN/13-native-undo.md @@ -10,12 +10,12 @@ The undo/redo substrate for base Lanework (12-editions.md), filling the one gap - **Coalescing follows commit granularity** (settled): one gesture, one undo step — a multi-card move is one step with a plural title; an Edit session is one step, registered at the Edit→Preview flip (the effective Save — 05-card-window.md); a styling batch is one step (03's one-gesture-one-commit rule, substrate swapped). The 06 vocabulary supplies menu titles ("Undo Move 3 Cards"), via NSUndoManager's dynamic retitling — the same naming machinery both editions use. - **Session-only persistence** (settled): the stack lives with the board session and dies at close/quit — standard macOS behavior. Git undo's survive-relaunch property is a Pro difference, stated honestly (12's matrix). - **Foreign writes never join the stack** (settled): NSUndoManager can only undo what the app mediated. An agent's or hand edit is not a step — the honest capability gap vs Pro (12's matrix). Foreign changes also do not clear the stack wholesale; collisions are handled lazily, per step, by validation: -- **Staleness validation before every apply** (settled): an inverse operation re-checks its target against the current snapshot at ⌘Z time. **The predicate is field-level** (settled — ruled 2026-07-27): each step registers both sides of its write anyway (the before-value is the inverse; the after-value is what its write set), so validation compares the targeted field's current value against the expected after-value — nearly free, and truer to never-surprise-the-file than an existence-only check (an inverse rename must not clobber a foreign rename on a still-existing card; body steps compare bytes). Target folder gone, or the field no longer holding the step's after-value → the step is **skipped, not applied**: popped from the stack with an info-tone banner ("Undo skipped — 'Fix login' changed outside Lanework"), and ⌘Z falls through to the next step. Never apply a stale inverse on top of someone else's newer write. **Invalidation is lazy** (settled — ruled 2026-07-27): staleness is discovered at ⌘Z time, never by background pruning — the EchoLedger's foreign diffs do not eagerly drop colliding steps. The stack always looks full; with the field-level predicate a skip fires only on a genuine per-field collision, and a skipped step's banner explains itself, where eager pruning would shrink the stack invisibly mid-session. +- **Staleness validation before every apply** (settled): an inverse operation re-checks its target against the disk — a fresh read of the target at ⌘Z time (blessed 2026-07-29: not the store snapshot, which is by construction one reload behind the app's own writes; a rapid ⌘Z run validated against the snapshot would compare pre-state and false-skip every step). **The predicate is field-level** (settled — ruled 2026-07-27): each step registers both sides of its write anyway (the before-value is the inverse; the after-value is what its write set), so validation compares the targeted field's current value against the expected after-value — nearly free, and truer to never-surprise-the-file than an existence-only check (an inverse rename must not clobber a foreign rename on a still-existing card; body steps compare bytes). Target folder gone, or the field no longer holding the step's after-value → the step is **skipped, not applied**: popped from the stack with an info-tone banner ("Undo skipped — 'Fix login' changed outside Lanework"), and ⌘Z falls through to the next step. Never apply a stale inverse on top of someone else's newer write. **Invalidation is lazy** (settled — ruled 2026-07-27): staleness is discovered at ⌘Z time, never by background pruning — the EchoLedger's foreign diffs do not eagerly drop colliding steps. The stack always looks full; with the field-level predicate a skip fires only on a genuine per-field collision, and a skipped step's banner explains itself, where eager pruning would shrink the stack invisibly mid-session. - **Locks disable the stack** (settled): every read-only lock (vanished root, failed reload after wholesale ops, unwritable location — 02-architecture.md) disables Undo/Redo with the other mutating commands; the stack itself survives the lock and resumes when it clears. Steps landed before a lock validate like any other at apply time. ## Interaction with the trash -⌫'s undo is the move back — a card delete is a move into `.trash/` (resettled 2026-07-28), so its undo is the ordinary inverse move, returning the card to its source lane and rank; a restore-by-move undoes the same way in reverse. The stack and the trash never conflict — they are the same folder moves addressed by recency instead of by selection. A **lane delete's undo recreates the folder** from the registered inverse (the one destructive delete — in-session only, which is the accepted net; git boards additionally keep it in history), and a permanent card delete undoes the same way. +⌫'s undo is the move back — a card delete is a move into `.trash/` (resettled 2026-07-28), so its undo is the ordinary inverse move, returning the card to its source lane and rank; a restore-by-move undoes the same way in reverse. The stack and the trash never conflict — they are the same folder moves addressed by recency instead of by selection. A **lane delete's undo recreates the folder** from the registered inverse (the one destructive delete — in-session only, which is the accepted net; git boards additionally keep it in history). A **permanent card delete registers no step** — Delete Immediately and Empty Trash are not undoable (Rules above); the confirm is the safety. ## Out of scope