Compare commits

...
6 Commits
Author SHA1 Message Date
rzen 190a8e36f1 Search takes the center — the titlebar placement grammar ratified
Redesign card 6d5a97fd resolved off the 2026-08-01 live try-out: leading is
board identity, center is view controls, trailing stays the user's catalog.
centeredItemIdentifiers, not a flexible-space sandwich — centers against the
window, holds as catalog items install, reaches machines with a saved
arrangement; defaultItems and its pinned tests untouched. 03 records the
grammar and the accepted tensions (widget-vs-field budget in narrow windows,
HIG's trailing convention yielding).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 22:13:55 -04:00
rzen 9766e1f61c The Colors panel joins the palette — the combo ratified, and each anchor composes the halves it needs
Four rulings close Redesign Contradiction 3452893f (2026-08-06): the in-app
escape hatch is ratified in full, reversing 2026-07-29's palette-only rule —
the combo's Other… opens the system Colors panel, a pick landing on a palette
color stores the name, anything else the hex. Free-picked colors change no
contrast story: they land on the same runtime ink computation hand-written hex
always got (10 amended to say so; no warning surface is owed). Anchor
ownership: the card sidebar's background story is the combo alone — the well
grid's background half stays with the other anchors (StyleEditorView gains
showsBackground beside showsSymbols; the popover's symbol half already went to
its inline SymbolPicker). Quick-style recents stay palette-vocabulary — a
panel pick never enters them.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 22:05:48 -04:00
rzen 73698cd77b A reusable symbol picker — the board's glyph joins its name in the info popover
SymbolPicker: one well at rest, a 6x6 curated grid in a popover (leading
well = the level default, clearing the key), and an optional search over
the OS's full symbol inventory read from CoreGlyphs metadata. Geometry
font-derived off StyleEditorLayout's base, grid enlarged by a deliberate
1.3x. Wired inline with the rename field in the board info popover
through the StyleCommand funnel; the Title header retires. Curated set
and the search's standing vs 03's full-browser refusal await ratification.
2026-08-06 21:18:35 -04:00
rzen b1ea97c03e Locality means the board the drag was picked up from — a rename is not a departure
Minor ruled into 04 ▸ Cross-board copy: the Finder volume model — a
mid-drag rename of the source board carries the live drag with it (the
drag holds its source store's identity, never a frozen key, which would
let a board opened at the vacated path compare equal — the worse,
destructive failure). Until the carry ships the residue is recorded:
key follows folder, a mid-drag rename finishes a reorder as a copy —
cosmetic, seconds-bounded. Implementation filed.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 21:02:47 -04:00
rzen 1980570b27 The popover's two post-split postures named — the explanatory row and the mode-none door
Minors ruled into 03 ▸ the git-at-a-glance section: a single-branch
board's switch picker opens onto a disabled "No other branches" row —
teaching why it is empty and where creation went — and the Pro
mode-none section stays as header plus the Board Settings… door rather
than vanishing, the git story keeping its named place.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 21:02:47 -04:00
rzen b1aa0c4483 The parse memo's blind spot blessed on file — reachable, pinned, and still outside the app's care
Minor ruled into 02 ▸ the memoize ruling: a stamp-preserving rewrite is
a decision on file rather than a surprise in the field — git lives with
the same blind spot, the behavior is test-pinned, and the tightenings
(hashing, generation stamps) wait for a real-world defeat.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 21:02:47 -04:00
14 changed files with 1565 additions and 24 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ The **one named exception** is transient UI state rendering things that don't ex
- **A failed reload never replaces a good snapshot.** Fail-fast (01-storage-format.md) is the *initial-load* contract, where there is nothing to fall back on. Once a board is open, a watcher-triggered reload that fails (unparseable YAML, missing required fields — typically a non-atomic external write caught mid-flight) keeps the last good snapshot on screen and raises a **non-modal banner** carrying fail-fast's specifics (offending path + what's wrong). The watcher keeps watching; the next successful reload clears the banner automatically — transient breakage self-heals without the user losing the board, persistent breakage stays loudly visible. Editing is not locked out: writes go through the Writer as usual (the breakage is per-file and localized), and the reload debounce already absorbs most momentary invalid states before they surface. - **A failed reload never replaces a good snapshot.** Fail-fast (01-storage-format.md) is the *initial-load* contract, where there is nothing to fall back on. Once a board is open, a watcher-triggered reload that fails (unparseable YAML, missing required fields — typically a non-atomic external write caught mid-flight) keeps the last good snapshot on screen and raises a **non-modal banner** carrying fail-fast's specifics (offending path + what's wrong). The watcher keeps watching; the next successful reload clears the banner automatically — transient breakage self-heals without the user losing the board, persistent breakage stays loudly visible. Editing is not locked out: writes go through the Writer as usual (the breakage is per-file and localized), and the reload debounce already absorbs most momentary invalid states before they surface.
- **Duplicate-id detection heals silently** (re-ruled 2026-07-29, superseding the repairable-condition banner): the loader's board-wide dedupe (01-storage-format.md ▸ Fractal layout rules) withholds losing occurrences from every snapshot; a scheduled heal remints them through the Writer (the loader itself never writes) and a warning-tone notice reports the repair — no banner, no button, nothing waits on consent. The withheld window is one heal cycle, not a standing condition; a remint racing a vanished duplicate (repaired elsewhere, a hand-deleted copy) is a no-op, never an error. - **Duplicate-id detection heals silently** (re-ruled 2026-07-29, superseding the repairable-condition banner): the loader's board-wide dedupe (01-storage-format.md ▸ Fractal layout rules) withholds losing occurrences from every snapshot; a scheduled heal remints them through the Writer (the loader itself never writes) and a warning-tone notice reports the repair — no banner, no button, nothing waits on consent. The withheld window is one heal cycle, not a standing condition; a remint racing a vanished duplicate (repaired elsewhere, a hand-deleted copy) is a no-op, never an error.
- **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal — and, blessed 2026-07-31, the store **skips the assignment entirely** when the fresh snapshot equals the current one: assigning an equal tree into an `@Observable` property still costs a render pass, so "costs nothing visible" becomes *costs nothing*), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **The skip's structural consequence — the two-counter split (blessed 2026-08-06):** once value-equal landings stop bumping the applied-snapshot generation, anything whose subject is the *walk* rather than the applied snapshot can no longer key on it. The store therefore carries two counters — snapshots **applied** (the committed-overlay hold's event, meaning unchanged) and walks **landed** with a snapshot in hand, bumped on every successful reload, equal or not; a failed reload bumps neither. The rule for choosing: **anything outside the snapshot, or about the walk itself, watches landed walks, not applied snapshots** — the card window's comment thread (comments are outside the snapshot, so a foreign comment arriving leaves the model value-equal), the comment search index (same shape), and the auto-committer's covering gate (what covers a flush is a completed walk, whether or not it found anything to show); on the applied counter each would sleep through exactly the value-equal landing it exists to notice. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry). **The walk memoizes its parse, never its result** (blessed 2026-07-31 — a performance posture, not a semantic change): the loader may reuse the previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged — the previous snapshot *is* the memo — while directory enumeration (folder discovery, attachment listings, trash entries) stays fresh every walk, because attachment changes never touch `index.md`. The loader's contract is result-purity with cost unspecified: same tree in, same snapshot out, and the memo can only change how fast. The mtime+size trust is the git-index heuristic; a writer that defeats it — content changed, mtime and size both preserved — is outside the app's care. - **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal — and, blessed 2026-07-31, the store **skips the assignment entirely** when the fresh snapshot equals the current one: assigning an equal tree into an `@Observable` property still costs a render pass, so "costs nothing visible" becomes *costs nothing*), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **The skip's structural consequence — the two-counter split (blessed 2026-08-06):** once value-equal landings stop bumping the applied-snapshot generation, anything whose subject is the *walk* rather than the applied snapshot can no longer key on it. The store therefore carries two counters — snapshots **applied** (the committed-overlay hold's event, meaning unchanged) and walks **landed** with a snapshot in hand, bumped on every successful reload, equal or not; a failed reload bumps neither. The rule for choosing: **anything outside the snapshot, or about the walk itself, watches landed walks, not applied snapshots** — the card window's comment thread (comments are outside the snapshot, so a foreign comment arriving leaves the model value-equal), the comment search index (same shape), and the auto-committer's covering gate (what covers a flush is a completed walk, whether or not it found anything to show); on the applied counter each would sleep through exactly the value-equal landing it exists to notice. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry). **The walk memoizes its parse, never its result** (blessed 2026-07-31 — a performance posture, not a semantic change): the loader may reuse the previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged — the previous snapshot *is* the memo — while directory enumeration (folder discovery, attachment listings, trash entries) stays fresh every walk, because attachment changes never touch `index.md`. The loader's contract is result-purity with cost unspecified: same tree in, same snapshot out, and the memo can only change how fast. The mtime+size trust is the git-index heuristic; a writer that defeats it — content changed, mtime and size both preserved — is outside the app's care (blessed 2026-08-06 as a decision on file, the boundary being reachable in practice — a byte-length-preserving edit plus deliberate utimes, a restore tool replaying old attributes — and pinned by test: git itself lives with the same blind spot, and the named tightenings — content hashing, which is the read the memo exists to avoid, or fileSystemFileNumber/generation stamps — wait for a real-world defeat, not a hypothetical one).
- **App-initiated git churn is bracketed.** Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. **The bracket also locks writes** (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted. - **App-initiated git churn is bracketed.** Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. **The bracket also locks writes** (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted.
- **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A container crossing is a vanish for this purpose** (resettled 2026-07-28 — the materialized trash): re-resolution matches UUID *and* container side (board vs `.trash/`), so a foreign move that trashes a selected board card — or restores a selected trash card — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's container-boundary invariant true across reloads. The old effective-liveness ancestor walk is retired with the tombstone model — presence in the snapshot is the whole question. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted or moved to the trash (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map). - **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A container crossing is a vanish for this purpose** (resettled 2026-07-28 — the materialized trash): re-resolution matches UUID *and* container side (board vs `.trash/`), so a foreign move that trashes a selected board card — or restores a selected trash card — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's container-boundary invariant true across reloads. The old effective-liveness ancestor walk is retired with the tombstone model — presence in the snapshot is the whole question. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted or moved to the trash (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map).
- **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The rule arms on every bracket exit, thrown operations included** (settled): an operation that fails or aborts mid-flight is precisely when the tree's state is least known, so the mandatory final reload runs regardless — succeeding, it renders whatever the operation left (often value-equal after a clean failure, whose tree is left as it was — 06-history-undo.md); failing, it locks exactly as above. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and restore, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles (these disable in place as controls — 05-card-window.md's in-content rule) — and the board refuses drops, including drags arriving from another board's window. **The lock is a predicate every mutating entry point consults, not a menu-validation sweep** (settled): mutating paths without a menu item exist beyond the checkboxes — the attachment row's ⌫/Remove (grammar key + context menu only, 11-command-nexus.md ▸ Context menus), plain-⌫ delete, Return-creation — and each disables with its surface or follows its command twin's validation; menu validation is the lock's most visible face, never its whole mechanism. **File ▸ Duplicate and File ▸ Save as Template join the disabled set** (settled): both copy the on-disk tree, which the lock marks as gone (vanished root), unknown (this failed-reload state), or unwritable — and both are specified to run the close flush first (03-board-ui.md, 09-templates.md), which the lock's suspended saves make impossible to honor. **One carve-out: under the unwritable-location lock alone (below), Save as Template stays live** — it reads the board and writes into Application Support, the copy-out-is-a-read principle applied (archiving the read-only DMG board being inspected is a legitimate errand). **The carve-out gates on the hazard itself, open sessions, not on lock provenance** (settled): the item disables while any open Edit or raw-source session holds unsaved content — content the lock's suspended saves cannot flush, which the template would silently miss (09-templates.md's never-misses-keystrokes guarantee outranks availability) — and re-enables when those sessions settle or the lock clears. A lock standing since open never meets this state: it disables the flip into Edit mode, so no session can start beneath it and the gate is vacuously open; unsaved sessions under this lock exist only when the symmetric probe (▸ Write-failure surfacing) raised it mid-session. Duplicate stays disabled even there — its destination is the same unwritable parent (a writable-parent/unwritable-board permission split was weighed and set aside as too rare to earn the inconsistency). Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it). - **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The rule arms on every bracket exit, thrown operations included** (settled): an operation that fails or aborts mid-flight is precisely when the tree's state is least known, so the mandatory final reload runs regardless — succeeding, it renders whatever the operation left (often value-equal after a clean failure, whose tree is left as it was — 06-history-undo.md); failing, it locks exactly as above. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and restore, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles (these disable in place as controls — 05-card-window.md's in-content rule) — and the board refuses drops, including drags arriving from another board's window. **The lock is a predicate every mutating entry point consults, not a menu-validation sweep** (settled): mutating paths without a menu item exist beyond the checkboxes — the attachment row's ⌫/Remove (grammar key + context menu only, 11-command-nexus.md ▸ Context menus), plain-⌫ delete, Return-creation — and each disables with its surface or follows its command twin's validation; menu validation is the lock's most visible face, never its whole mechanism. **File ▸ Duplicate and File ▸ Save as Template join the disabled set** (settled): both copy the on-disk tree, which the lock marks as gone (vanished root), unknown (this failed-reload state), or unwritable — and both are specified to run the close flush first (03-board-ui.md, 09-templates.md), which the lock's suspended saves make impossible to honor. **One carve-out: under the unwritable-location lock alone (below), Save as Template stays live** — it reads the board and writes into Application Support, the copy-out-is-a-read principle applied (archiving the read-only DMG board being inspected is a legitimate errand). **The carve-out gates on the hazard itself, open sessions, not on lock provenance** (settled): the item disables while any open Edit or raw-source session holds unsaved content — content the lock's suspended saves cannot flush, which the template would silently miss (09-templates.md's never-misses-keystrokes guarantee outranks availability) — and re-enables when those sessions settle or the lock clears. A lock standing since open never meets this state: it disables the flip into Edit mode, so no session can start beneath it and the gate is vacuously open; unsaved sessions under this lock exist only when the symmetric probe (▸ Write-failure surfacing) raised it mid-session. Duplicate stays disabled even there — its destination is the same unwritable parent (a writable-parent/unwritable-board permission split was weighed and set aside as too rare to earn the inconsistency). Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it).
+5 -5
View File
@@ -13,7 +13,7 @@ The board window: layout, lanes, cards, and styling. Interaction mechanics (sele
Toolbars are **pure enhancement**: every function they host already has a menu item + shortcut (04-interactions.md's contract), so nothing below is anyone's only path. Both windows' toolbars are **user-customizable, macOS-native** (right-click ▸ Customize Toolbar…, drag to rearrange, system overflow and icon/text display options) — the sets below are shipped defaults, not verdicts. Toolbar item labels match their menu-item titles exactly (Show Trash, Edit Body, Raw Source, …), minus any trailing ellipsis (macOS convention: "Add Attachment…" labels as Add Attachment) — one vocabulary everywhere, and the customize palette self-documents against the menus. One exception: the Undo/Redo toolbar items keep static labels — NSUndoManager rewrites their menu titles dynamically ("Undo Move Card…", 04-interactions.md ▸ Configurable bindings), which a toolbar label doesn't track. Toolbars are **pure enhancement**: every function they host already has a menu item + shortcut (04-interactions.md's contract), so nothing below is anyone's only path. Both windows' toolbars are **user-customizable, macOS-native** (right-click ▸ Customize Toolbar…, drag to rearrange, system overflow and icon/text display options) — the sets below are shipped defaults, not verdicts. Toolbar item labels match their menu-item titles exactly (Show Trash, Edit Body, Raw Source, …), minus any trailing ellipsis (macOS convention: "Add Attachment…" labels as Add Attachment) — one vocabulary everywhere, and the customize palette self-documents against the menus. One exception: the Undo/Redo toolbar items keep static labels — NSUndoManager rewrites their menu titles dynamically ("Undo Move Card…", 04-interactions.md ▸ Configurable bindings), which a toolbar label doesn't track.
- **Board window default: the search field, nothing else** — trailing, the one default item; the titlebar stays clean. ⌘F always summons search: with the field removed from the toolbar, invoking it surfaces the field transiently until the search clears. **A squeezed field expands in place; the strip answers only genuine unreachability** (blessed 2026-08-06): a space-constrained `NSSearchToolbarItem` collapses to a magnifying-glass button still in the window, and ⌘F expands and focuses it — the toolbar's own field, a better surface than the fallback — so the transient strip fires only for a truly windowless field (true overflow, or the item removed from the toolbar). **The item's overflow row is the platform's own second answer, and the duality is blessed** (2026-08-06): AppKit's overflow menu row carries a live action that widens the window until the field is usable, and suppressing it would destroy the honest overflow presentation — so a squeezed toolbar reaches search two ways with two honest resolutions: ⌘F expands the item or surfaces the strip (ours), the overflow row grows the window (the platform's) — different gestures, reasonable respective outcomes, no contradiction to resolve. **The field is the platform's own search toolbar item and grows on focus** (2026-08-01): the em-derived width is the *focused* width — applied when the field takes the keyboard, expanding via the item's own animation — and the resting width is AppKit's natural one, not the app's to set. **The two-homes width rule is focused-width parity** (ruled 2026-08-01): the width the user *types in* is the same em-derived figure whichever home the field is in — the toolbar item focused, or the transient strip; the toolbar field's resting width is outside the invariant (the strip never rests — it exists only while a search is live or focused, so it has no collapsed state to mirror). **Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out (the zoom pair is catalog-only by the same logic as everything else here — the titlebar's default stays the search field alone; each disables at its end of the ladder, and both disable mid-drag like their menu rows — Layout ▸ zoom above), Undo, Redo (the pair disabled only under locks and on empty stacks — re-ruled 2026-07-31, twice: the provider follows the board, so boards without app-managed git — repo-nested included — bind 13-native-undo.md's native stack in **every** tier and Pro git boards bind the git provider — 06-history-undo.md), Show Trash (toggle state matching the View menu checkmark). The board popover deliberately has **no toolbar item** — the window-title widget is its committed home (below), and a second entry would muddy it. - **Board window default: the search field, nothing else** — **centered** (ratified 2026-08-06 off the 2026-08-01 live try-out, reversing the earlier trailing ruling): the titlebar reads as a placement grammar — leading is board identity (the title widget), center is view controls (search today, the filter family if one ever grows), trailing remains the user's catalog space. The mechanism is `NSToolbar.centeredItemIdentifiers`, deliberately not a flexible-space sandwich: it centers against the window rather than leftover space, holds as catalog items install, and sits outside the autosaved configuration, so it takes effect on machines with a saved arrangement — and `defaultItems` is unchanged, keeping the pinned default-set tests standing. The known tensions were weighed and accepted at ratification: the 400pt leading widget and a centered field share the titlebar's budget in narrow windows (the squeezed field's expand-in-place answer below is the relief), and HIG's trailing-edge convention yields to the grammar. The titlebar stays clean. ⌘F always summons search: with the field removed from the toolbar, invoking it surfaces the field transiently until the search clears. **A squeezed field expands in place; the strip answers only genuine unreachability** (blessed 2026-08-06): a space-constrained `NSSearchToolbarItem` collapses to a magnifying-glass button still in the window, and ⌘F expands and focuses it — the toolbar's own field, a better surface than the fallback — so the transient strip fires only for a truly windowless field (true overflow, or the item removed from the toolbar). **The item's overflow row is the platform's own second answer, and the duality is blessed** (2026-08-06): AppKit's overflow menu row carries a live action that widens the window until the field is usable, and suppressing it would destroy the honest overflow presentation — so a squeezed toolbar reaches search two ways with two honest resolutions: ⌘F expands the item or surfaces the strip (ours), the overflow row grows the window (the platform's) — different gestures, reasonable respective outcomes, no contradiction to resolve. **The field is the platform's own search toolbar item and grows on focus** (2026-08-01): the em-derived width is the *focused* width — applied when the field takes the keyboard, expanding via the item's own animation — and the resting width is AppKit's natural one, not the app's to set. **The two-homes width rule is focused-width parity** (ruled 2026-08-01): the width the user *types in* is the same em-derived figure whichever home the field is in — the toolbar item focused, or the transient strip; the toolbar field's resting width is outside the invariant (the strip never rests — it exists only while a search is live or focused, so it has no collapsed state to mirror). **Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out (the zoom pair is catalog-only by the same logic as everything else here — the titlebar's default stays the search field alone; each disables at its end of the ladder, and both disable mid-drag like their menu rows — Layout ▸ zoom above), Undo, Redo (the pair disabled only under locks and on empty stacks — re-ruled 2026-07-31, twice: the provider follows the board, so boards without app-managed git — repo-nested included — bind 13-native-undo.md's native stack in **every** tier and Pro git boards bind the git provider — 06-history-undo.md), Show Trash (toggle state matching the View menu checkmark). The board popover deliberately has **no toolbar item** — the window-title widget is its committed home (below), and a second entry would muddy it.
- **Card window default: Edit Body · Raw Source · Add Attachment** — the window's three committed functions, all discoverable from its toolbar; the catalog is the same trio. Edit Body is a **single toggle button** (on-state in Edit — mirroring the View ▸ Edit Body checkmark and the ⌘E/Return/Escape grammar; the pathfinder's segmented Preview|Edit is retired). Raw Source is likewise a toggle showing on-state; while source mode is active, Edit Body disables (Cancel/Apply own the exits — 05-card-window.md). Add Attachment stays enabled in every mode — attachment operations never touch `index.md`, so they're safe alongside a raw edit (the sidebar's feedback returns on exit). - **Card window default: Edit Body · Raw Source · Add Attachment** — the window's three committed functions, all discoverable from its toolbar; the catalog is the same trio. Edit Body is a **single toggle button** (on-state in Edit — mirroring the View ▸ Edit Body checkmark and the ⌘E/Return/Escape grammar; the pathfinder's segmented Preview|Edit is retired). Raw Source is likewise a toggle showing on-state; while source mode is active, Edit Body disables (Cancel/Apply own the exits — 05-card-window.md). Add Attachment stays enabled in every mode — attachment operations never touch `index.md`, so they're safe alongside a raw edit (the sidebar's feedback returns on exit).
## Lane ## Lane
@@ -41,10 +41,10 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
### Controls (settled) ### Controls (settled)
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. 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. **The anchors compose the halves they need** (2026-08-06): the card sidebar shows the symbol grid with the **color combo** (below) standing in for the background half — the narrow context the combo was built for; the board popover shows the background half only, its symbol picker beside the rename field owning the board glyph (two surfaces writing one key in one popover would read as two settings); the Style… popover carries both grids in full.
- **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-first, panel-backed** (re-ratified 2026-08-06, reversing 2026-07-29's palette-only ruling): 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. Beside the grid the vocabulary now has a second, compact form: the **color combo** — a swatch-faced popup listing None, the role's twelve, the current off-palette value verbatim when there is one, and **Other…**, which opens the system Colors panel. The panel is the in-app escape hatch the 2026-07-29 ruling withheld: a pick landing exactly on a palette color stores the *name* (so a re-pick never drifts to a hex spelling), anything else stores the hex — the same unlimited vocabulary hand-editing always had, now pickable. An arbitrary pick changes no contrast story (it lands on the identical runtime ink computation hand-written hex already gets — 10-accessibility.md), and the quick-style recents stay palette-vocabulary: a panel pick never enters them.
- **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. - **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. (Symbols keep this stance deliberately — the 2026-08-06 color-panel reversal above is colors only: the system offers a Colors panel worth deferring to, and no symbol browser of equal standing.)
- **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. - **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. - **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.
- **The Style… popover tracks its target set live and dismisses when it empties** (settled): its target is the selection, re-resolved across reloads by 02-architecture.md's UUID rule — a member that vanishes or flips liveness leaves the set and the mixed-state display recomputes; a set emptied by a foreign reload dismisses the popover (the inline-rename discard applied here) — it never silently retargets to the board, and nothing writes into a vanished folder (a member moved to the trash leaves the set like any other departure). **The read-only lock instead disables its wells in place** (settled): a popover open when the lock lands stays open, content disabled — the banner names why, and the lock never yanks a surface (the Edit buffer's keeps-its-place posture). The embedded anchors need no rule of their own: the card sidebar dismisses with its card's window, and the board popover's target is the board itself. - **The Style… popover tracks its target set live and dismisses when it empties** (settled): its target is the selection, re-resolved across reloads by 02-architecture.md's UUID rule — a member that vanishes or flips liveness leaves the set and the mixed-state display recomputes; a set emptied by a foreign reload dismisses the popover (the inline-rename discard applied here) — it never silently retargets to the board, and nothing writes into a vanished folder (a member moved to the trash leaves the set like any other departure). **The read-only lock instead disables its wells in place** (settled): a popover open when the lock lands stays open, content disabled — the banner names why, and the lock never yanks a surface (the Edit buffer's keeps-its-place posture). The embedded anchors need no rule of their own: the card sidebar dismisses with its card's window, and the board popover's target is the board itself.
@@ -57,7 +57,7 @@ The window-title widget opens the **board popover** — the one board-level surf
- **Board rename** (settled: this function stays in-app, unlike the pathfinder which dropped it with the inspector). Rename edits the board's frontmatter `title` only — the folder is never renamed by the app; the Finder document name is Finder's to change (01-storage-format.md's board-naming rule). A foreign rename landing while the popover is open resyncs the field from the snapshot only while the field is unfocused — a focused field keeps the user's keystrokes, the dirty-buffer courtesy applied here (settled). - **Board rename** (settled: this function stays in-app, unlike the pathfinder which dropped it with the inspector). Rename edits the board's frontmatter `title` only — the folder is never renamed by the app; the Finder document name is Finder's to change (01-storage-format.md's board-naming rule). A foreign rename landing while the popover is open resyncs the field from the snapshot only while the field is unfocused — a focused field keeps the user's keystrokes, the dirty-buffer courtesy applied here (settled).
- **Board styling** — the embedded style editor (Styling ▸ Controls above). - **Board styling** — the embedded style editor (Styling ▸ Controls above).
- **Git at a glance** — Pro tier surface, mode-aware, *display and daily operations only* (re-ruled 2026-07-31 — setup moved to the board settings sheet below; in the free tier this section is absent on ordinary boards and reduces to the contextual one-line Pro pointer on boards carrying an inert `.git` — 12-editions.md): the posture lines (repo-nested explanation, unreadable-repo and paused states — 06), branch display with the **switch picker**, and on remote-backed boards remote tracking (ahead/behind) with **Pull/Push** controls and the status badges (Authentication needed, queued pushes, last error — the badge points at the sheet, capture happens there). A **Board Settings…** row opens the sheet — the popover's one setup affordance. - **Git at a glance** — Pro tier surface, mode-aware, *display and daily operations only* (re-ruled 2026-07-31 — setup moved to the board settings sheet below; in the free tier this section is absent on ordinary boards and reduces to the contextual one-line Pro pointer on boards carrying an inert `.git` — 12-editions.md): the posture lines (repo-nested explanation, unreadable-repo and paused states — 06), branch display with the **switch picker**, and on remote-backed boards remote tracking (ahead/behind) with **Pull/Push** controls and the status badges (Authentication needed, queued pushes, last error — the badge points at the sheet, capture happens there). **A single-branch board's picker opens onto a disabled explanatory row** (ruled 2026-08-06): with creation relocated to the sheet, the menu holds only the *other* local branches, and an empty menu reads as broken — a disabled "No other branches" row teaches both why the menu is empty and where creation went. A **Board Settings…** row opens the sheet — the popover's one setup affordance. **The Pro mode-none posture is header plus that door** (blessed 2026-08-06): with no repository the section stays — "Git" and the Board Settings… row alone — rather than vanishing, so the board's git story keeps its named place in the popover and the door stands exactly where a user looking for git will look.
## Board settings sheet ## Board settings sheet
+1 -1
View File
@@ -18,7 +18,7 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
- **Multi-drag**: dragging any member of a multi-selection drags the whole selection; N contiguous shadows; drop inserts contiguously in preserved relative order — defined, for any multi-selection, as lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom). - **Multi-drag**: dragging any member of a multi-selection drags the whole selection; N contiguous shadows; drop inserts contiguously in preserved relative order — defined, for any multi-selection, as lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom).
- **Locality picks the default — the Finder volume model** (settled): within a board a drag is a **move** (rearranging); between boards it is a **copy** (transferring — the system copy badge shows over the foreign board). **⌥ always forces copy** and **⌘ always forces move**, Finder's exact modifier grammar; each is a no-op where its behavior is already the default. The badge tracks the effective operation live as the cursor crosses a board boundary. - **Locality picks the default — the Finder volume model** (settled): within a board a drag is a **move** (rearranging); between boards it is a **copy** (transferring — the system copy badge shows over the foreign board). **⌥ always forces copy** and **⌘ always forces move**, Finder's exact modifier grammar; each is a no-op where its behavior is already the default. The badge tracks the effective operation live as the cursor crosses a board boundary.
- **Within-board ⌥-drag copies**: originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop. Lane drags never copy *within their board* — a within-board lane duplicate is **not available by drag** (⌥ is simply ignored there: the drag stays a clean reorder and the badge never shows copy); the duplicate itself is supported, via the clipboard (Lane paste below) — the usual shape: the keyboard path is the canonical one, drag the enhancement (10-accessibility.md). - **Within-board ⌥-drag copies**: originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop. Lane drags never copy *within their board* — a within-board lane duplicate is **not available by drag** (⌥ is simply ignored there: the drag stays a clean reorder and the badge never shows copy); the duplicate itself is supported, via the clipboard (Lane paste below) — the usual shape: the keyboard path is the canonical one, drag the enhancement (10-accessibility.md).
- **Cross-board copy** (the default): cards and lanes (including multi-selections) drag between open boards; fresh-GUID duplicates land at the drop, originals stay, `created` is kept (a copy is a fork — 01-storage-format.md). Lanes copy cards and all — transferring workflow structure between boards is safe by default. A lane carries exactly its cards — the trash is board-level (`.trash/` — 03-board-ui.md), so there is nothing lane-nested to strip or carry: copy and ⌘-drag move alike transfer the lane's folder as it is (resettled 2026-07-28; the old tombstone-stripping rule is retired with the tombstone model). - **Cross-board copy** (the default): cards and lanes (including multi-selections) drag between open boards; fresh-GUID duplicates land at the drop, originals stay, `created` is kept (a copy is a fork — 01-storage-format.md). **Locality means the board the drag was picked up from** (ruled 2026-08-06): a drag knows where it came from — the Finder volume model — so a rename or Finder move of the source board absorbed mid-drag is not a departure: the relocation carries the live drag with it (the drag holds its source *store's* identity, not a frozen copy of its key — a frozen key would let a new board opened at the vacated path compare equal to the renamed-away one, turning a copy into a silent cross-board move, the worse failure), and a within-board reorder stays a reorder across a mid-drag rename. Until the carry ships, the recorded residue — the minted key follows the folder, so a mid-drag rename finishes a reorder as a copy — is cosmetic and bounded by the seconds a drag is in flight. Lanes copy cards and all — transferring workflow structure between boards is safe by default. A lane carries exactly its cards — the trash is board-level (`.trash/` — 03-board-ui.md), so there is nothing lane-nested to strip or carry: copy and ⌘-drag move alike transfer the lane's folder as it is (resettled 2026-07-28; the old tombstone-stripping rule is retired with the tombstone model).
- **Cross-board move** (⌘-drag): a real filesystem move, works across volumes — identity travels. A moved folder whose UUID already exists in the destination board arrives as a fresh-UUID copy (01-storage-format.md's import-boundary rule); in a compound move (lane with cards, multi-selection) only the colliding folders are reminted — the rest is a true move (01's per-folder degradation). - **Cross-board move** (⌘-drag): a real filesystem move, works across volumes — identity travels. A moved folder whose UUID already exists in the destination board arrives as a fresh-UUID copy (01-storage-format.md's import-boundary rule); in a compound move (lane with cards, multi-selection) only the colliding folders are reminted — the rest is a true move (01's per-folder degradation).
- **Files from Finder**: dropped on a card → copied into its `attachments/` (any *file* type, multi-file; card highlights while hovered). Dropped on lane empty space → creates a card with the file attached, titled with the filename without its extension (multi-file drop: one card per file). **Folders are refused at hover** (settled — the attachment model is flat top-level files, and the importer refuses directories by design): a drag containing only folders never engages — no highlight, no drop proposal, the standard incompatible-payload read; a mixed drag proposes for its files only, and the drop imports the files while a loss row (02-architecture.md's warning tone) names the skipped folders ("Folders can't be attached — 2 skipped"). The create path thereby only ever fires with at least one importable file — no card is minted for an import that cannot succeed. **Created cards land at the drop position** (settled): resolved through the same card-grid zones an ordinary card drag uses, shadow included — drops are positional everywhere, and append-at-bottom stays the creation *trio's* rule, not the drop's. A multi-file drop shows **one nominal-height shadow per incoming file** (the multi-drag precedent; when macOS withholds item counts during hover the count floors at one shadow, the commit unaffected). **A release on the lane header resolves to the topmost position** (settled — forgiving beats a dead stripe: the header's chrome roles don't collide with a file payload). **The landing shadow is the create path's whole feedback** (settled): no lane-level highlight on top — each target gets one clear signal, and the card-attach highlight exists precisely because that target has no shadow. - **Files from Finder**: dropped on a card → copied into its `attachments/` (any *file* type, multi-file; card highlights while hovered). Dropped on lane empty space → creates a card with the file attached, titled with the filename without its extension (multi-file drop: one card per file). **Folders are refused at hover** (settled — the attachment model is flat top-level files, and the importer refuses directories by design): a drag containing only folders never engages — no highlight, no drop proposal, the standard incompatible-payload read; a mixed drag proposes for its files only, and the drop imports the files while a loss row (02-architecture.md's warning tone) names the skipped folders ("Folders can't be attached — 2 skipped"). The create path thereby only ever fires with at least one importable file — no card is minted for an import that cannot succeed. **Created cards land at the drop position** (settled): resolved through the same card-grid zones an ordinary card drag uses, shadow included — drops are positional everywhere, and append-at-bottom stays the creation *trio's* rule, not the drop's. A multi-file drop shows **one nominal-height shadow per incoming file** (the multi-drag precedent; when macOS withholds item counts during hover the count floors at one shadow, the commit unaffected). **A release on the lane header resolves to the topmost position** (settled — forgiving beats a dead stripe: the header's chrome roles don't collide with a file payload). **The landing shadow is the create path's whole feedback** (settled): no lane-level highlight on top — each target gets one clear signal, and the card-attach highlight exists precisely because that target has no shadow.
- **A foreign reload mid-drag re-grounds the drag, never corrupts the drop** (settled — a two-second drag racing agent edits is the designed concurrency). Three rules compose: (1) **geometry re-derives** — the frozen-at-drag-start inputs are the *dragged items'* sizes and the physical pointer only (03-board-ui.md ▸ Motion); the analytic resting zones recompute against each new snapshot, so a foreign lane-count re-divide mid-drag just moves the zones and the next proposal targets the board as it now is. (2) **Proposals re-validate by liveness** — a proposal whose target lane vanished in the reload is invalidated; the shadow withdraws and no proposal stands until the pointer reaches a live target, and **release with no valid proposal cancels** — items return, nothing is written; a card is never filed under a vanished parent. (3) **An emptied drag cancels itself** — drag membership is already a UUID set that vanished items leave silently (02-architecture.md); when the *last* dragged item vanishes the replica dissolves and release is a no-op. Partial vanishing drops the survivors, matching the pending-cut precedent. **A cross-board lane arrival pre-divides the destination strip during hover** (settled): while a foreign lane drag proposes into a board, the destination's standard width is computed with the arriving run's units included, so the shadow draws at the width the lane will actually take — without this it overflows the strip (the pathfinder's stripWidthUnits). The first entry samples the un-widened standard for one frame before hysteresis settles — accepted, imperceptible. - **A foreign reload mid-drag re-grounds the drag, never corrupts the drop** (settled — a two-second drag racing agent edits is the designed concurrency). Three rules compose: (1) **geometry re-derives** — the frozen-at-drag-start inputs are the *dragged items'* sizes and the physical pointer only (03-board-ui.md ▸ Motion); the analytic resting zones recompute against each new snapshot, so a foreign lane-count re-divide mid-drag just moves the zones and the next proposal targets the board as it now is. (2) **Proposals re-validate by liveness** — a proposal whose target lane vanished in the reload is invalidated; the shadow withdraws and no proposal stands until the pointer reaches a live target, and **release with no valid proposal cancels** — items return, nothing is written; a card is never filed under a vanished parent. (3) **An emptied drag cancels itself** — drag membership is already a UUID set that vanished items leave silently (02-architecture.md); when the *last* dragged item vanishes the replica dissolves and release is a no-op. Partial vanishing drops the survivors, matching the pending-cut precedent. **A cross-board lane arrival pre-divides the destination strip during hover** (settled): while a foreign lane drag proposes into a board, the destination's standard width is computed with the arriving run's units included, so the shadow draws at the width the lane will actually take — without this it overflows the strip (the pathfinder's stripWidthUnits). The first entry samples the un-widened standard for one frame before hysteresis settles — accepted, imperceptible.
+1 -1
View File
@@ -63,7 +63,7 @@ Stacked sections under small-caps headers, in this order; quiet rows, read-optim
### Style ### Style
The card-level styling home: the **embedded style editor** — background palette grid (with the leading None well) and curated symbol grid, per 03-board-ui.md ▸ Styling ▸ Controls. Card styling is discoverable here without a context menu; the same component appears in the board popover and behind Style…. The card-level styling home: the **Background color combo** over the **curated symbol grid** (03-board-ui.md ▸ Styling ▸ Controls, its 2026-08-06 anchor-ownership rule) — the sidebar is exactly the narrow context the combo was built for, so it stands in for the well grid's background half here, panel escape hatch included; the full grid remains the surface at the other anchors. Card styling is discoverable here without a context menu; the component family is shared with the board popover and Style….
### Details — unknown frontmatter keys ### Details — unknown frontmatter keys
+1 -1
View File
@@ -45,7 +45,7 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
- **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. - **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.
- **Board zoom is that scaling's user-facing control** (settled 2026-08-02; behavior in 03-board-ui.md ▸ Layout, rows in 11-command-nexus.md). macOS ships no system text-size setting, so the commitment above had nothing to move it — the point size the whole board derives from is read once and never changes. View ▸ Zoom In / Zoom Out / Actual Size supply the multiplier: one ladder, one effective body size, and every em multiple and every text style scaling off it together. This is an accessibility feature before it is a convenience one, which is why it is a first-class menu command with a chord rather than a setting buried in a pane, and why **the strip's own truncation rules are the acceptance test** — 03's graceful-truncation promise "at every scale" is only checkable now that a scale exists. **A zoom change announces its new level** ("Zoom 125%") through the ordinary announcement path: it is chrome, not information — nothing about the board's meaning changes — so no label, value, or trait anywhere else moves with it. - **Board zoom is that scaling's user-facing control** (settled 2026-08-02; behavior in 03-board-ui.md ▸ Layout, rows in 11-command-nexus.md). macOS ships no system text-size setting, so the commitment above had nothing to move it — the point size the whole board derives from is read once and never changes. View ▸ Zoom In / Zoom Out / Actual Size supply the multiplier: one ladder, one effective body size, and every em multiple and every text style scaling off it together. This is an accessibility feature before it is a convenience one, which is why it is a first-class menu command with a chord rather than a setting buried in a pane, and why **the strip's own truncation rules are the acceptance test** — 03's graceful-truncation promise "at every scale" is only checkable now that a scale exists. **A zoom change announces its new level** ("Zoom 125%") through the ordinary announcement path: it is chrome, not information — nothing about the board's meaning changes — so no label, value, or trait anywhere else moves with it.
- **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. - **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). The 2026-08-06 color-combo reversal (03 ▸ Styling ▸ Controls) changes none of this: a panel-picked color is stored as the palette name when it lands on one, else as hex, and either spelling renders through the same runtime ink seam — in-app picking gained the freedom hand-editing always had, and the ink math was already waiting for it. No warning surface exists or is owed; the app's answer to a low-contrast pick is to choose readable ink, not to argue. 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. - **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. - **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. **"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. - **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.
+23 -2
View File
@@ -309,9 +309,28 @@ struct BoardInfoView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
sectionHeader("Title") HStack(spacing: 6) {
// The board's own icon, inline with its name the same field the embedded style
// editor's symbol section below writes, offered here too since a board's identity
// is its name *and* its glyph together (03-board-ui.md § Styling Controls). No
// `undo:` the board popover has none of its own, so this reaches the board's
// stack exactly as the embedded editor's writes do.
SymbolPicker(
current: store.snapshot.icon.value,
fallback: ItemSymbol.board,
onSelect: { name in
StyleCommand.apply(
icon: name.map { StyleChange.set($0) } ?? .remove,
to: .board,
in: store,
recents: recents
)
}
)
.disabled(!store.acceptsBoardMutations)
BoardRenameField(store: store) BoardRenameField(store: store)
} }
}
.padding(inset) .padding(inset)
Divider() Divider()
@@ -324,7 +343,9 @@ struct BoardInfoView: View {
// ("nothing selected = the board"); this embed is the surface that exists *because* // ("nothing selected = the board"); this embed is the surface that exists *because*
// the board is a style target, so it can have no other target (§ Styling // the board is a style target, so it can have no other target (§ Styling
// Controls: "the board popover's target is the board itself"). // Controls: "the board popover's target is the board itself").
StyleEditorView(store: store, recents: recents, target: .board) // No symbol section the inline `SymbolPicker` beside the rename field above is
// the board glyph's one surface in this popover.
StyleEditorView(store: store, recents: recents, target: .board, showsSymbols: false)
} }
// Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary free-tier // Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary free-tier
+5
View File
@@ -138,6 +138,11 @@ enum BoardToolbar {
specs: specs(store: store, search: search), specs: specs(store: store, search: search),
defaults: defaultItems defaults: defaultItems
) )
// Centered against the window, not a flexible-space sandwich (03 Toolbar's placement
// grammar, ratified 2026-08-06): `centeredItemIdentifiers` holds as catalog items install
// and sits outside the autosaved configuration, so it reaches machines that saved an
// arrangement under the old trailing default. `defaultItems` is untouched.
controller.toolbar.centeredItemIdentifiers = [.boardSearch]
controller.onInstalledItemsChanged = { [weak search] identifiers in controller.onInstalledItemsChanged = { [weak search] identifiers in
search?.isInstalledInToolbar = identifiers.contains(.boardSearch) search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
} }
+84 -2
View File
@@ -35,9 +35,16 @@ struct CardStyleSection: View {
/// **This window's undo stack** (13-native-undo.md Rules two levels): a colour or symbol /// **This window's undo stack** (13-native-undo.md Rules two levels): a colour or symbol
/// chosen here is a gesture *issued in this window*, so its step joins the window's session and /// chosen here is a gesture *issued in this window*, so its step joins the window's session and
/// reaches board history only inside the coarse close step. The shared editor takes it as an /// reaches board history only inside the coarse close step. The shared editor takes it as an
/// anchor's parameter, exactly as it takes the layout. /// anchor's parameter, exactly as it takes the layout and so does the background combo below,
/// for the same reason.
let undo: CardWindowUndo let undo: CardWindowUndo
/// The trailing debounce on a live colour-panel drag (`ColorComboView`'s `onPanelChange`,
/// opened from the combo's **Other** row): cancelled and replaced on every tick, so only the
/// value the user is still on ~400ms after the last one actually reaches disk. One task for the
/// section's one combo.
@State private var backgroundPanelCommit: Task<Void, Never>?
/// The live body metric, read here rather than passed in `CardAttachmentsSection`'s pattern, /// The live body metric, read here rather than passed in `CardAttachmentsSection`'s pattern,
/// so every section in this sidebar derives its geometry the same way. /// so every section in this sidebar derives its geometry the same way.
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
@@ -56,6 +63,10 @@ struct CardStyleSection: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) { VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
CardSidebarSectionHeader(title: "Style") CardSidebarSectionHeader(title: "Style")
backgroundComboRow
// Symbols only: the combo row above is this sidebar's whole background story
// (03 Styling Controls, the 2026-08-06 anchor-ownership rule) the well grid's
// background half stays with the other anchors.
StyleEditorView( StyleEditorView(
store: store, store: store,
recents: recents, recents: recents,
@@ -64,11 +75,82 @@ struct CardStyleSection: View {
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize), contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
bodyPointSize: pointSize bodyPointSize: pointSize
), ),
undo: undo undo: undo,
showsBackground: false
) )
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
// MARK: - Background combo
/// The labeled **Background** row, above the well grid a narrower, single-value alternative
/// to it (`ColorCombo.swift`'s own doc comment): an inspector row, caption leading and a
/// compact combo trailing, the arrangement every Xcode inspector uses for exactly this control.
/// The combo takes just over half the row rather than filling it sized off the same metric
/// the sidebar's own width comes from, so the pair holds its proportions at every text size.
private var backgroundComboRow: some View {
HStack(spacing: 0) {
Text("Background")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
ColorComboView(
role: .background,
value: currentBackground,
isEnabled: !store.isReadOnly,
onChange: { commitBackground($0) },
onPanelChange: { debounceBackground($0) }
)
.frame(width: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize) * 0.55)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
/// The card's `background` field, exactly as written malformed reads as its raw text, missing
/// reads `nil`, both `StyleFieldState.written`'s own rule (`StyleModel.swift`). The **raw**
/// string, never a resolved colour: `ColorComboModel`'s matching needs the bytes, not what they
/// render as.
private var currentBackground: String? {
let subject = store.styleSubjects(of: Self.target(forCard: cardID)).first
return StyleFieldState.written(subject?.background ?? .missing)
}
/// A discrete pick commits immediately. `nil` removes; a name from `Palette.backgrounds` goes
/// through `StyleCommand.apply` so it feeds `StyleRecents` exactly like a well click would
/// ("updated on every background application from any anchor", `StyleEditor.swift`); anything
/// else the dynamic current-value row re-affirming a foreign name or a custom hex writes
/// directly, since it is not the "palette pick" recents was ever meant to remember.
private func commitBackground(_ newValue: String?) {
let target = Self.target(forCard: cardID)
guard let newValue else {
store.applyStyle(to: target, background: .remove, icon: .keep, on: undo)
return
}
if Palette.backgrounds.contains(where: { $0.name == newValue }) {
StyleCommand.apply(background: .set(newValue), to: target, in: store, recents: recents, on: undo)
} else {
store.applyStyle(to: target, background: .set(newValue), icon: .keep, on: undo)
}
}
/// One tick of a live colour-panel drag: cancels whatever commit was pending and schedules a new
/// one ~400ms out, so a drag writes once it settles rather than on every pixel it passes through.
/// Never routed through `StyleCommand.apply` a drag that passes through a palette-exact hex
/// mid-gesture must not spam the recents row the way a deliberate pick would.
private func debounceBackground(_ newValue: String?) {
backgroundPanelCommit?.cancel()
let target = Self.target(forCard: cardID)
backgroundPanelCommit = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
if let newValue {
store.applyStyle(to: target, background: .set(newValue), icon: .keep, on: undo)
} else {
store.applyStyle(to: target, background: .remove, icon: .keep, on: undo)
}
}
}
} }
// MARK: - Actions // MARK: - Actions
+614
View File
@@ -0,0 +1,614 @@
import AppKit
import SwiftUI
/// A reusable colour-picker combo: a collapsed face split into **two zones**, Xcode's inspector
/// colour combo's own shape a flat swatch of the current value filling almost the whole control,
/// and a narrow chevron trigger at the trailing edge. Clicking the swatch opens
/// `NSColorPanel.shared` directly; clicking the trigger pops a dropdown of **None**, the role's
/// twelve palette colours, an off-palette current value stated verbatim when there is one, and
/// **Other**, which hands off to that same panel the swatch and **Other** are two doors onto
/// one panel takeover (`ColorComboView.Coordinator.openColorPanel()`).
///
/// It is the second surface `background`/`iconColor` can be set from, beside the well grid
/// (`StyleEditor.swift`'s `StyleEditorView`) the well grid stays exactly as it is; this is a
/// narrower, single-value control for a context where a whole grid would not fit (`CardStyleSection`'s
/// own labeled row).
///
/// ### Two halves, the same split every other file here draws
///
/// `ColorComboRole`, `ColorComboItem`, `ColorComboMatch` and `ColorComboModel` are the **pure model**
/// item lists, selection matching, hex normalization, display-name casing every rule a test can
/// hold without an `NSView` in sight. `ColorComboView` is the thin AppKit bridge that draws it and
/// answers clicks, exactly the `StyleEditorLayout`/`StyleEditorView` split in `StyleEditor.swift`.
/// (Its collapsed face has its *own*, unrelated two-zone split swatch versus trigger,
/// `ColorComboControl`'s own doc comment which has nothing to do with this pure-model/view one.)
// MARK: - Role
/// Which of the two palettes a combo offers `Palette.backgrounds` for `background`,
/// `Palette.foregrounds` for `iconColor`/icon tints. Both tables already answer either field
/// (`Palette.nsColor(for:)`), so a combo's *role* is only about which twelve it lists, never about
/// which values it can resolve.
enum ColorComboRole: Sendable, Equatable {
case background
case foreground
/// The twelve rows this picker offers.
var palette: [PaletteColor] {
switch self {
case .background: Palette.backgrounds
case .foreground: Palette.foregrounds
}
}
/// The *other* picker's twelve consulted only to name a foreign palette value in the dynamic
/// current-value row (`ColorComboModel.match`). Never offered as a row of this picker's own,
/// which is what keeps "background lists backgrounds" true even though `Palette.nsColor(for:)`
/// itself would happily resolve a foreground name.
var otherPalette: [PaletteColor] {
switch self {
case .background: Palette.foregrounds
case .foreground: Palette.backgrounds
}
}
}
// MARK: - Rows and matching
/// One row of a `ColorComboView`'s dropdown, in display order.
enum ColorComboItem: Equatable {
/// Clears the field the well grid's own leading None well, same removal.
case none
case separator
/// One of `role`'s own twelve, by name. `ColorComboModel.displayName(_:)` is its title; the row
/// is never built with anything the role's own palette doesn't list.
case palette(String)
/// The live value's own row present only when the stored value matches neither `.none` nor a
/// `.palette` row (`ColorComboModel.match` decides). `swatchValue` is the raw stored string a
/// swatch draws from (`Palette.nsColor(for:)`, lenient exactly like `PaletteSwatch`); `title` is
/// the display text `ColorComboModel.match` already worked out.
case current(swatchValue: String, title: String)
/// Opens `NSColorPanel.shared`.
case other
}
/// Which row a stored value checks computed once and shared by the item list (`ColorComboModel.
/// menu`) and by anything that just wants to know "what does this resolve to" without building
/// rows, which is most of what a test wants to assert.
enum ColorComboMatch: Equatable {
case none
case palette(String)
case current(swatchValue: String, title: String)
}
/// The full dropdown for one role at one value: its rows, and the index of the checked one.
struct ColorComboMenu: Equatable {
let items: [ColorComboItem]
/// Always a valid index into `items` the None row exists in every menu, so there is always at
/// least one candidate to fall back to.
let selectedIndex: Int
}
// MARK: - The pure model
/// The whole of what a `ColorComboView` shows, as pure functions of `role` and a stored value
/// no `NSView`, no store, nothing a `ColorComboTests` case can't hold still.
enum ColorComboModel {
// MARK: Display
/// Kebab-case palette name Title Case with hyphens as spaces: `"light-cayenne"`
/// `"Light Cayenne"`, `"smokey-rich-eggplant"` `"Smokey Rich Eggplant"` the one place a
/// palette name becomes a row's title rather than its stored spelling.
static func displayName(_ name: String) -> String {
name.split(separator: "-")
.map { $0.isEmpty ? "" : $0.prefix(1).uppercased() + $0.dropFirst() }
.joined(separator: " ")
}
// MARK: Matching
/// Which row `value` checks, given `role`:
/// - `nil` `.none`.
/// - a name in `role`'s own palette `.palette(name)`, matched exactly `Palette`'s own
/// case-sensitive rule, unchanged here.
/// - a hex that, normalized, equals one of `role`'s palette hexes that colour's `.palette`
/// match, **by name** a panel pick landing exactly on a palette colour selects the name, so
/// picking it again from the panel later re-emits the name rather than drifting to a hex.
/// - anything else (a foreign palette name, a custom hex, or unresolvable garbage) `.current`,
/// titled with the other picker's display name when `value` is one of *its* twelve, else
/// `value` itself, uppercased when it looks like hex and left verbatim otherwise.
static func match(role: ColorComboRole, value: String?) -> ColorComboMatch {
guard let value else { return .none }
if role.palette.contains(where: { $0.name == value }) {
return .palette(value)
}
if let normalized = normalizedHex(value),
let hit = role.palette.first(where: { normalizedHex($0.hex) == normalized }) {
return .palette(hit.name)
}
return .current(swatchValue: value, title: currentTitle(role: role, value: value))
}
/// The dynamic current-value row's title the other table's display name when `value` is one
/// of its twelve, the raw string otherwise (hex shown uppercase, matching `NSColor.
/// paletteHexString`'s own casing so a stored value and a freshly panel-picked one read alike).
private static func currentTitle(role: ColorComboRole, value: String) -> String {
if let foreign = role.otherPalette.first(where: { $0.name == value }) {
return displayName(foreign.name)
}
return value.hasPrefix("#") ? value.uppercased() : value
}
// MARK: Item list
/// The dropdown's full row list and which row is checked, for `role` at `value`: **None**,
/// separator, the twelve, then only when `match` lands on `.current` that dynamic row,
/// separator, **Other**.
static func menu(role: ColorComboRole, value: String?) -> ColorComboMenu {
var items: [ColorComboItem] = [.none, .separator]
items.append(contentsOf: role.palette.map { .palette($0.name) })
let selectedIndex: Int
switch match(role: role, value: value) {
case .none:
selectedIndex = 0
case let .palette(name):
selectedIndex = items.firstIndex(of: .palette(name)) ?? 0
case let .current(swatchValue, title):
items.append(.current(swatchValue: swatchValue, title: title))
selectedIndex = items.count - 1
}
items.append(.separator)
items.append(.other)
return ColorComboMenu(items: items, selectedIndex: selectedIndex)
}
// MARK: Hex normalization
/// `#RRGGBB`/`#RRGGBBAA` uppercase, alpha-`FF` collapsed to six digits the string-side half
/// of the round trip `NSColor.paletteHexString` builds (Palette.swift), used here purely for
/// **comparison**: two spellings of the same opaque colour normalize to the same string, so a
/// stored `#b6071eff` matches a palette entry's `#B6071E` exactly as a bare `#b6071e` would.
/// `nil` for anything that isn't `#` followed by six or eight hex digits, so a malformed value
/// never accidentally matches a palette colour by coincidence.
static func normalizedHex(_ value: String) -> String? {
var upper = value.uppercased()
guard upper.hasPrefix("#") else { return nil }
let digits = upper.dropFirst()
guard digits.count == 6 || digits.count == 8, digits.allSatisfy(\.isHexDigit) else { return nil }
if digits.count == 8, digits.hasSuffix("FF") {
upper.removeLast(2)
}
return upper
}
}
// MARK: - View
/// The AppKit bridge: a two-zone collapsed face (`ColorComboControl`) whose dropdown is
/// `ColorComboModel.menu(role:value:)` built exactly as it always was, just handed to the control
/// to pop instead of being assigned as an `NSPopUpButton`'s own `menu`. Two click targets sharing one
/// menu-plus-panel contract is the one thing `NSPopUpButton` cannot do on its own: it has exactly one
/// hit zone for exactly one action.
struct ColorComboView: NSViewRepresentable {
let role: ColorComboRole
/// The raw stored value a palette name or a hand-written hex, exactly as the frontmatter field
/// carries it. Never a resolved `Color`: matching needs the string, not what it renders as.
let value: String?
let isEnabled: Bool
/// One discrete row picked **None**, one of the twelve, or the dynamic current-value row.
/// Fired once, synchronously; the call site commits it immediately.
var onChange: @MainActor (String?) -> Void
/// One tick of a live `NSColorPanel` drag opened from **Other** or the swatch zone fires
/// repeatedly while the user is still adjusting the colour. Kept separate from `onChange`
/// because the two halves of this control's contract differ at the call site
/// (`CardStyleSection`): a discrete pick commits immediately, a panel tick is the caller's to
/// debounce and never feeds style recents.
var onPanelChange: @MainActor (String?) -> Void
/// The width `sizeThatFits` hands back when SwiftUI has no concrete proposal to fill an
/// unconstrained measuring pass, not the normal case. The normal case is a finite proposal (this
/// view sits under `.frame(maxWidth: .infinity)` in the sidebar row, `CardActionsSection`'s
/// Delete button's own trick), which this default never has to stand in for.
private static let defaultFaceWidth: CGFloat = 120
func makeNSView(context: Context) -> ColorComboControl {
let control = ColorComboControl(frame: .zero)
// The swatch zone's one job: open the same panel takeover **Other** does. A closure, not a
// target/action pair there is exactly one caller and no `NSMenuItem`-style Objective-C
// boundary to cross for it.
control.onSwatchClick = { [weak coordinator = context.coordinator] in
coordinator?.openColorPanel()
}
return control
}
func updateNSView(_ control: ColorComboControl, context: Context) {
context.coordinator.role = role
context.coordinator.onChange = onChange
context.coordinator.onPanelChange = onPanelChange
control.isEnabled = isEnabled
context.coordinator.rebuild(control, value: value)
}
/// Obeys whatever width SwiftUI proposes, like any other control never the widest menu item,
/// which is what the old caller-supplied `width` input existed to work around. Height is the
/// control's own fitting height (`ColorComboControl.intrinsicContentSize`, about half the old
/// regular `NSPopUpButton`'s the whole point of this rework); width is the proposal's when it
/// is an actual number, else `defaultFaceWidth`, since a `nil`/infinite proposal happens on an
/// unconstrained measuring pass, not a sidebar row.
func sizeThatFits(_ proposal: ProposedViewSize, nsView: ColorComboControl, context: Context) -> CGSize? {
let width: CGFloat
if let proposed = proposal.width, proposed.isFinite {
width = proposed
} else {
width = Self.defaultFaceWidth
}
return CGSize(width: width, height: nsView.intrinsicContentSize.height)
}
/// Detaches the colour panel's target/action if this coordinator still holds them "last-writer
/// wins" for any control that took the panel over afterward (this view's own doc comment).
static func dismantleNSView(_ control: ColorComboControl, coordinator: Coordinator) {
coordinator.detachColorPanel()
}
func makeCoordinator() -> Coordinator {
Coordinator(role: role, onChange: onChange, onPanelChange: onPanelChange)
}
// MARK: Coordinator
/// The one object every menu action and the colour panel's action target a class because
/// `NSColorPanel.setTarget(_:)` needs something with reference identity to detach from later,
/// and `@MainActor` because every AppKit call it makes has to be.
@MainActor
final class Coordinator: NSObject {
fileprivate var role: ColorComboRole
fileprivate var currentValue: String?
fileprivate var onChange: @MainActor (String?) -> Void
fileprivate var onPanelChange: @MainActor (String?) -> Void
/// ~44×14pt a menu row's swatch, wide enough beside its title to read as a colour sample
/// rather than a bullet.
private static let menuSwatchSize = NSSize(width: 44, height: 14)
/// Whichever coordinator most recently took the shared panel over `NSColorPanel` exposes
/// `setTarget(_:)`/`setAction(_:)` but no matching getter, so "is it still mine to detach"
/// has nowhere to live but here. `weak`, so a coordinator that never got around to detaching
/// (a window closed from under it) does not keep the next owner from being collected either.
private static weak var currentPanelOwner: Coordinator?
init(
role: ColorComboRole,
onChange: @escaping @MainActor (String?) -> Void,
onPanelChange: @escaping @MainActor (String?) -> Void
) {
self.role = role
self.onChange = onChange
self.onPanelChange = onPanelChange
}
/// Rebuilds the dropdown for `value` and hands the control the menu, its checked item (the
/// popup anchor `ColorComboControl.popUpMenu()` positions against, and the source of its
/// accessibility value), and the value its swatch zone should draw. Cheap enough a dozen
/// rows, a fistful of small menu-row images to redo wholesale on every SwiftUI update
/// rather than diffing against what was there before.
func rebuild(_ control: ColorComboControl, value: String?) {
currentValue = value
let menu = NSMenu()
let built = ColorComboModel.menu(role: role, value: value)
var checkedItem: NSMenuItem?
for (index, item) in built.items.enumerated() {
if item == .separator {
menu.addItem(.separator())
continue
}
let menuItem = self.menuItem(for: item)
let isChecked = index == built.selectedIndex
menuItem.state = isChecked ? .on : .off
menu.addItem(menuItem)
if isChecked { checkedItem = menuItem }
}
control.comboMenu = menu
control.checkedItem = checkedItem
control.swatchValue = value
}
/// See `ColorComboView.dismantleNSView(_:coordinator:)`.
func detachColorPanel() {
guard Coordinator.currentPanelOwner === self else { return }
let panel = NSColorPanel.shared
panel.setTarget(nil)
panel.setAction(nil)
Coordinator.currentPanelOwner = nil
}
private func menuItem(for item: ColorComboItem) -> NSMenuItem {
switch item {
case .none:
let menuItem = NSMenuItem(title: "None", action: #selector(selectNone), keyEquivalent: "")
menuItem.target = self
menuItem.image = PaletteSwatch.rectImage(for: nil, size: Self.menuSwatchSize)
return menuItem
case let .palette(name):
let menuItem = NSMenuItem(
title: ColorComboModel.displayName(name),
action: #selector(selectValue(_:)),
keyEquivalent: ""
)
menuItem.target = self
menuItem.representedObject = name
menuItem.image = PaletteSwatch.rectImage(for: name, size: Self.menuSwatchSize)
return menuItem
case let .current(swatchValue, title):
let menuItem = NSMenuItem(title: title, action: #selector(selectValue(_:)), keyEquivalent: "")
menuItem.target = self
menuItem.representedObject = swatchValue
menuItem.image = PaletteSwatch.rectImage(for: swatchValue, size: Self.menuSwatchSize)
return menuItem
case .other:
let menuItem = NSMenuItem(title: "Other…", action: #selector(openColorPanel), keyEquivalent: "")
menuItem.target = self
return menuItem
case .separator:
// Unreached: `rebuild` handles `.separator` before calling this. Kept so the switch
// stays total against a case list a future row could still grow.
return NSMenuItem.separator()
}
}
@objc private func selectNone() {
onChange(nil)
}
@objc private func selectValue(_ sender: NSMenuItem) {
onChange(sender.representedObject as? String)
}
/// Seeds the shared panel with the current resolved colour (black when there isn't one),
/// takes it over "don't fight over the panel if something else takes it later" (this
/// view's own doc comment) and asks for continuous updates, which is what makes a drag on
/// the panel's own sliders call `changeColor(_:)` on every tick rather than only on release.
///
/// Two callers, one takeover: the dropdown's own **Other** row (`#selector` target above)
/// and `ColorComboControl`'s swatch-zone click (wired in `ColorComboView.makeNSView`)
/// Xcode's own two-zone combo opens the same panel from either half, and this is the one
/// place that happens.
@objc func openColorPanel() {
let panel = NSColorPanel.shared
panel.showsAlpha = true
panel.color = currentValue.flatMap(Palette.nsColor(for:)) ?? .black
panel.setTarget(self)
panel.setAction(#selector(changeColor(_:)))
Coordinator.currentPanelOwner = self
panel.makeKeyAndOrderFront(nil)
}
/// The panel's own action, continuous while the user drags: normalizes what it picked to
/// this app's stored-value vocabulary and hands it to `onPanelChange` the palette name
/// when the colour lands exactly on one of `role`'s twelve, the hex otherwise. The name-wins
/// rule is the same one `ColorComboModel.match` applies to a value already on disk.
@objc private func changeColor(_ sender: NSColorPanel) {
guard let hex = sender.color.paletteHexString else { return }
onPanelChange(Palette.name(forHex: hex, in: role.palette) ?? hex)
}
}
}
// MARK: - Two-zone NSControl
/// The collapsed face: a custom control in Xcode's inspector colour combo's own shape a flat
/// swatch filling almost the whole control, and a fixed-width chevron trigger at the trailing edge.
/// The swatch zone opens the Colors panel directly (`ColorComboView.Coordinator.openColorPanel()`);
/// the trigger zone pops the dropdown `ColorComboView.Coordinator.rebuild(_:value:)` builds. Neither
/// zone owns a bezel or a cell of its own everything both draw and hit-test is computed straight
/// from `bounds` on every pass, so there is nothing cached here the way the face image the
/// `NSPopUpButton` this replaces used to keep (`swatchValue`'s `didSet` just marks a redraw).
///
/// Plain internal, not `private`/`fileprivate`, even though nothing outside this file constructs one
/// directly: it is `ColorComboView`'s `NSViewType`, an associated-type witness the compiler requires
/// to be at least as visible as `ColorComboView` itself (internal, usable module-wide) same
/// reasoning as the un-modified-access `Coordinator` a few lines up.
final class ColorComboControl: NSControl {
/// The value the swatch zone currently draws a palette name or a hand-written hex, exactly as
/// `ColorComboView.Coordinator.rebuild(_:value:)` hands it over on every SwiftUI update.
var swatchValue: String? {
didSet {
guard swatchValue != oldValue else { return }
needsDisplay = true
}
}
/// The dropdown the trigger zone pops, and the row within it that should read as checked both
/// `Coordinator.rebuild(_:value:)`'s to hand over on every rebuild, always together (`checkedItem`
/// is always one of `comboMenu`'s own items). This control never builds a row itself; it only
/// positions and pops what it is given.
var comboMenu: NSMenu?
var checkedItem: NSMenuItem?
/// Fired by a click anywhere in the swatch zone wired once, in `ColorComboView.makeNSView`, to
/// the coordinator's `openColorPanel()`. `@MainActor`, this file's own established convention for
/// a stored closure an AppKit callback fires (`onChange`/`onPanelChange` above), even though this
/// control's own methods are already implicitly MainActor-isolated as an `NSResponder` subclass.
var onSwatchClick: (@MainActor () -> Void)?
override var isEnabled: Bool {
get { super.isEnabled }
set {
super.isEnabled = newValue
needsDisplay = true
}
}
/// About half the old regular `NSPopUpButton`'s height the whole point of this rework. Width
/// is `NSView.noIntrinsicMetric`: this control obeys whatever SwiftUI proposes, exactly as the
/// button it replaces did.
override var intrinsicContentSize: NSSize {
NSSize(width: NSView.noIntrinsicMetric, height: 14)
}
/// The fixed-width trigger strip at the trailing edge, full height the geometry this whole
/// control exists to draw: "a flat swatch occupying the control, a chevron trigger at the
/// trailing edge."
private static let triggerWidth: CGFloat = 16
/// The swatch zone's padding before its rounded rect two points rather than a bare hairline,
/// so the control's own field (`drawField()`) reads as a visible ring around the colour instead
/// of being covered by it.
private static let swatchPadding: CGFloat = 2
private static let cornerRadius: CGFloat = 3
/// The field's own radius a point more than the swatch's, so the two rounded rects run
/// concentric instead of pinching at the corners.
private static let fieldRadius: CGFloat = 4
private var triggerRect: NSRect {
NSRect(x: bounds.maxX - Self.triggerWidth, y: bounds.minY, width: Self.triggerWidth, height: bounds.height)
}
private var swatchZone: NSRect {
NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width - Self.triggerWidth, height: bounds.height)
}
// MARK: Drawing
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
context.saveGState()
defer { context.restoreGState() }
// A transparency layer, not a flat `setAlpha` around each shape: the swatch's underlay,
// fill and stroke overlap, and drawing each at reduced alpha independently would let the
// stroke double up over the fill beneath it. Compositing the whole disabled face as one
// layer avoids that.
if !isEnabled {
context.setAlpha(0.35)
context.beginTransparencyLayer(auxiliaryInfo: nil)
}
drawField()
drawSwatch()
drawTrigger()
if !isEnabled {
context.endTransparencyLayer()
}
}
/// The control's own field: a bordered, filled rounded rect over the whole bounds, under both
/// zones what makes the swatch and the trigger read as one control rather than two shapes
/// floating beside each other. Standard control materials: `controlBackgroundColor` fill,
/// `separatorColor` hairline, the half-point inset keeping the stroke on whole pixels.
private func drawField() {
let path = NSBezierPath(
roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5),
xRadius: Self.fieldRadius,
yRadius: Self.fieldRadius
)
NSColor.controlBackgroundColor.setFill()
path.fill()
NSColor.separatorColor.setStroke()
path.lineWidth = 1
path.stroke()
}
/// The colour rect, drawn exactly like `PaletteSwatch.rectImage`: a `textBackgroundColor`
/// underlay so a translucent stored colour composites the same way in light and dark, the
/// resolved colour on top, a `separatorColor` hairline stroke last. `nil`/unresolvable value
/// underlay + stroke only, the same "there is no colour, so show none" rule.
private func drawSwatch() {
let inset = swatchZone.insetBy(dx: Self.swatchPadding, dy: Self.swatchPadding)
let path = NSBezierPath(roundedRect: inset, xRadius: Self.cornerRadius, yRadius: Self.cornerRadius)
NSColor.textBackgroundColor.setFill()
path.fill()
if let swatchValue, let color = Palette.nsColor(for: swatchValue) {
color.setFill()
path.fill()
}
NSColor.separatorColor.setStroke()
path.lineWidth = 1
path.stroke()
}
/// The trigger: a small vertically-centred rounded **square**, `controlAccentColor`-filled, with
/// a white `chevron.up.chevron.down` centred inside the standard `NSPopUpButton` indicator's
/// own look, redrawn here since this control has no bezel of its own to borrow one from.
private func drawTrigger() {
let side = triggerRect.height - 2 * Self.swatchPadding
let square = NSRect(
x: triggerRect.midX - side / 2,
y: triggerRect.midY - side / 2,
width: side,
height: side
)
let path = NSBezierPath(roundedRect: square, xRadius: Self.cornerRadius, yRadius: Self.cornerRadius)
NSColor.controlAccentColor.setFill()
path.fill()
let config = NSImage.SymbolConfiguration(pointSize: 7, weight: .bold)
.applying(.init(paletteColors: [.white]))
guard let chevron = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: nil)?
.withSymbolConfiguration(config)
else { return }
let size = chevron.size
chevron.draw(in: NSRect(
x: square.midX - size.width / 2,
y: square.midY - size.height / 2,
width: size.width,
height: size.height
))
}
// MARK: Events
/// Point-in-trigger-zone pops the dropdown; anywhere else in the control fires the swatch click
/// the two-zone split this whole rework exists for. A disabled control answers neither.
override func mouseDown(with event: NSEvent) {
guard isEnabled else { return }
let point = convert(event.locationInWindow, from: nil)
if triggerRect.contains(point) {
popUpMenu()
} else {
onSwatchClick?()
}
}
override var acceptsFirstResponder: Bool { isEnabled }
/// Space and Return pop the dropdown the one keyboard path into this control. There is
/// currently no keyboard equivalent for the swatch zone's direct panel launch; see this class's
/// own doc comment and the file's top-level report for what a full accessibility pass would add.
override func keyDown(with event: NSEvent) {
guard isEnabled else {
super.keyDown(with: event)
return
}
switch event.keyCode {
case 49, 36, 76: // Space, Return, keypad Enter
popUpMenu()
default:
super.keyDown(with: event)
}
}
/// Standard popup placement: `comboMenu` is asked to land `checkedItem` at the control's own top
/// edge, the same non-pulldown anchor `NSPopUpButton` itself uses so the checked row appears
/// where the control's own face is rather than wherever the pointer happened to be.
private func popUpMenu() {
guard let comboMenu else { return }
comboMenu.popUp(positioning: checkedItem, at: NSPoint(x: 0, y: bounds.height), in: self)
}
// MARK: Accessibility
override func accessibilityRole() -> NSAccessibility.Role? { .popUpButton }
/// The checked row's own title "Light Cayenne", "None", a bare hex exactly what the dropdown
/// itself would show ticked, since `Coordinator.rebuild(_:value:)` hands this control the very
/// item it built the menu from rather than a copy.
override func accessibilityValue() -> Any? { checkedItem?.title }
}
+55 -6
View File
@@ -62,11 +62,13 @@ enum Palette {
] ]
} }
// The pathfinder's panel round-trip helpers (`NSColor.paletteHexString`, `Palette.name(forHex:)`) // The pathfinder's panel round-trip helpers, ported below (`NSColor.paletteHexString`,
// stay unported: they exist to turn a colour the *system picker* returned back into a palette name, // `Palette.name(forHex:in:)`): the reusable colour-picker combo (`ColorComboView`, ColorCombo.swift)
// and this app has no colour picker "custom hex is not pickable in-app" (03 § Styling Controls) // is the surface that finally needs them a colour the *system picker* returns has to become a
// makes the whole round trip a surface that doesn't exist. Its swatch drawing, on the other hand, is // stored value the same way a palette pick already does: the palette NAME when the colour lands
// below: a menu can only render `Image`/`Text`, so the quick-style row's dots have to be pictures. // exactly on one of the twelve, the hex otherwise. Its swatch drawing, unchanged in spirit, is
// below: a menu can only render `Image`/`Text`, so both the quick-style row's dots and the combo's
// rows have to be pictures.
// MARK: - Menu swatches // MARK: - Menu swatches
@@ -102,6 +104,28 @@ enum PaletteSwatch {
return true return true
} }
} }
/// A wide rectangular swatch for `value` `ColorComboView`'s own rows and its collapsed face,
/// which are wide and short rather than the quick-style row's small dots (hence a sibling
/// function rather than a parameter on `circleImage`: the two shapes are never interchangeable at
/// their call sites). `nil` draws the border alone, exactly `circleImage`'s "there is no colour,
/// so show none" the collapsed face's **None** state and the dropdown's own **None** row both
/// call this with `nil` rather than a sentinel string.
static func rectImage(for value: String?, size: NSSize) -> NSImage {
let color = value.flatMap(Palette.nsColor(for:))
return NSImage(size: size, flipped: false) { rect in
let inset = rect.insetBy(dx: 0.5, dy: 0.5)
let path = NSBezierPath(rect: inset)
NSColor.textBackgroundColor.setFill()
path.fill()
color?.setFill()
path.fill()
NSColor.separatorColor.setStroke()
path.lineWidth = 1
path.stroke()
return true
}
}
} }
extension Palette { extension Palette {
@@ -134,9 +158,18 @@ extension Palette {
guard let value = field.value else { return nil } guard let value = field.value else { return nil }
return color(named: value) return color(named: value)
} }
/// The name of `palette`'s entry whose hex matches `hex`, case-insensitively the pathfinder's
/// round trip, ported for `ColorComboView`'s panel handoff: a colour the system picker returns
/// comes back as `NSColor.paletteHexString`'s canonical `#RRGGBB[AA]`, and this is what turns
/// that back into "the user picked Light Cayenne" instead of leaving it as an anonymous hex.
/// `nil` when nothing in `palette` matches, which the caller reads as "store the hex instead."
static func name(forHex hex: String, in palette: [PaletteColor]) -> String? {
palette.first { $0.hex.caseInsensitiveCompare(hex) == .orderedSame }?.name
}
} }
// MARK: - Hex colour // MARK: - Hex colour
extension NSColor { extension NSColor {
/// `#RRGGBB` or `#RRGGBBAA` `NSColor` in **sRGB** the colour space the hex digits name, /// `#RRGGBB` or `#RRGGBBAA` `NSColor` in **sRGB** the colour space the hex digits name,
@@ -160,4 +193,20 @@ extension NSColor {
alpha: alpha alpha: alpha
) )
} }
/// The reverse of `init?(paletteHex:)`: `#RRGGBB`, or `#RRGGBBAA` when the colour is
/// translucent full opacity collapses to six digits rather than a trailing `FF`, so a colour
/// that round-trips through the panel without the user touching the opacity slider is written
/// exactly as a curated palette entry would be. `nil` only for a colour space **sRGB** cannot
/// convert into, which no picker swatch or palette entry here ever is.
var paletteHexString: String? {
guard let srgb = usingColorSpace(.sRGB) else { return nil }
let red = Int((srgb.redComponent * 255).rounded())
let green = Int((srgb.greenComponent * 255).rounded())
let blue = Int((srgb.blueComponent * 255).rounded())
let alpha = Int((srgb.alphaComponent * 255).rounded())
return alpha >= 255
? String(format: "#%02X%02X%02X", red, green, blue)
: String(format: "#%02X%02X%02X%02X", red, green, blue, alpha)
}
} }
+20 -1
View File
@@ -216,7 +216,8 @@ struct StyleEditorLayout: Equatable {
// MARK: - The editor // MARK: - The editor
/// The style editor: a background section and a symbol section, each a leading "no value" well /// The style editor: a background section and a symbol section (each omissible `showsBackground`/
/// `showsSymbols`, since 03's anchors compose the halves they need), each a leading "no value" well
/// followed by its grid, with the target set's current value stated beside the section title. /// followed by its grid, with the target set's current value stated beside the section title.
/// ///
/// ### What it shows for a batch /// ### What it shows for a batch
@@ -250,6 +251,18 @@ struct StyleEditorView: View {
/// a colour chosen in a card window is one of that window's session gestures. /// a colour chosen in a card window is one of that window's session gestures.
var undo: CardWindowUndo? var undo: CardWindowUndo?
/// Whether the symbol section appears at all. On everywhere but the board info popover, whose
/// inline `SymbolPicker` beside the rename field owns the board's glyph now two surfaces
/// writing the same key in one popover would make the second read as a different setting.
var showsSymbols: Bool = true
/// Whether the background section appears at all. On everywhere but the card window sidebar,
/// where the labeled `ColorComboView` row is the background story (03 Styling Controls,
/// the 2026-08-06 anchor-ownership rule): the sidebar is the narrow context the combo was
/// built for, and grid-plus-combo over one value read as two settings `showsSymbols`'
/// reasoning, pointed the other way.
var showsBackground: Bool = true
/// The live body metric, read here rather than passed in `CardStyleSection`'s pattern, so /// The live body metric, read here rather than passed in `CardStyleSection`'s pattern, so
/// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling /// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling
/// rule). /// rule).
@@ -263,10 +276,16 @@ struct StyleEditorView: View {
VStack(alignment: .leading, spacing: StyleEditorLayout.sectionSpacing(bodyPointSize: pointSize)) { VStack(alignment: .leading, spacing: StyleEditorLayout.sectionSpacing(bodyPointSize: pointSize)) {
targetCaption(count: subjects.count) targetCaption(count: subjects.count)
if showsBackground {
backgroundSection(background, layout: layout) backgroundSection(background, layout: layout)
}
if showsBackground && showsSymbols {
Divider() Divider()
}
if showsSymbols {
symbolSection(icon, layout: layout) symbolSection(icon, layout: layout)
} }
}
.padding(layout.padding) .padding(layout.padding)
.frame(width: layout.width) .frame(width: layout.width)
// The read-only lock and the focused-editor rule disable every mutating surface, not only // The read-only lock and the focused-editor rule disable every mutating surface, not only
+467
View File
@@ -0,0 +1,467 @@
import Foundation
import SwiftUI
/// **A reusable SF Symbol picker** a single well showing the resolved symbol, opening a curated
/// grid with a search escape hatch (03-board-ui.md § Styling Controls: "its leading well is the
/// level's default symbol and removes the `icon` key Any other SF Symbol name works written by
/// hand No full-browser escape hatch in-app; the raw file is the escape hatch"). `StyleEditor.swift`
/// already builds that grid once, aimed at `background`/`icon` together and multiplexed across three
/// anchors; this file builds the *symbol half alone*, aimed at any single field a caller names, so a
/// control that only ever needs one glyph a saved search, a smart filter, a future per-item
/// affordance is not forced to carry the style editor's background section or its `BoardStore`
/// coupling to get one.
///
/// ### Why the curated set differs from `CuratedSymbols`
///
/// `CuratedSymbols.all` is grouped by what a *board item* is (status/flow, containers, people)
/// this control has no board item in mind, so `SymbolPickerCatalog.defaultSet` is a smaller,
/// ungrouped 36 chosen for the general "boards and projects" case instead. The two lists are free to
/// diverge; nothing here reads the other.
///
/// ### The one thing `CuratedSymbols` never needed
///
/// The style editor's curated grid has no search and no full-catalog fallback ("no full-browser
/// escape hatch in-app" is a statement about *that* surface). This picker adds one anyway, because a
/// general-purpose control cannot assume its 36 will always contain what the caller is after a
/// search with nothing to search would just move the dead end from "no matching well" to "no way to
/// look further".
// MARK: - The symbol catalogs
/// The picker's two symbol lists: the curated 36-glyph grid it opens with, and the OS's full
/// inventory it searches into once the grid alone isn't enough.
enum SymbolPickerCatalog {
/// The picker's curated grid, in order a general "boards and projects" set rather than the
/// style editor's kanban-item groupings, chosen so a first-run picker with no caller-supplied
/// `symbols` still shows something broadly useful. A stored constant, not a computed property,
/// for `CuratedSymbols.all`'s own reason: the list is the design decision, and `available` is the
/// only thing the OS gets a say in.
static let defaultSet: [String] = [
"star", "flag", "heart", "bolt", "flame", "leaf", "drop", "sun.max", "moon", "sparkles",
"tag", "bookmark", "pin", "bell", "paperplane", "tray", "folder", "archivebox", "doc.text",
"list.bullet", "checklist", "calendar", "clock", "hammer", "wrench.and.screwdriver",
"paintbrush", "lightbulb", "brain", "book", "graduationcap", "briefcase", "cart", "house",
"airplane", "gamecontroller", "globe",
]
/// The set this Mac can actually draw `CuratedSymbols.available`'s rule, mirrored: a curated
/// list is a convenience, never a claim about the running system.
static var available: [String] { defaultSet.filter(ItemSymbol.exists) }
/// Where the OS keeps the full SF Symbols inventory read-only system metadata, present on
/// every Mac that ships SF Symbols at all.
private static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"
/// The default path's catalog, loaded once. A `static let` rather than a `lazy var`: the load is
/// synchronous and the result is a plain `[String]` Sendable, immutable once computed so
/// Swift's usual thread-safe one-time global initialization is the whole of the "cache" this
/// needs, with no actor to hang it off.
private static let cachedFullCatalog: [String] = load(bundlePath: defaultBundlePath)
/// Every SF Symbol name the running OS knows, sorted and deduplicated the search grid's source.
///
/// **Not filtered through `ItemSymbol.exists`.** The plist this reads already reflects the
/// running OS's own inventory (it *is* the OS's inventory), and running a few thousand
/// `NSImage(systemSymbolName:)` lookups against it on every search keystroke would be pure cost
/// for an answer the file has already given for free. A curated list is different: it is a
/// hand-written guess that might be stale, and only guesses need checking.
///
/// `bundlePath` defaults to the real system location and is cached there; any other path the
/// test suite's nonexistent one, chiefly reloads (and re-falls-back) on every call, which is
/// the honest cost of asking a question the cache was never built to answer.
static func fullCatalog(bundlePath: String = defaultBundlePath) -> [String] {
bundlePath == defaultBundlePath ? cachedFullCatalog : load(bundlePath: bundlePath)
}
/// The plist read, and its one fallback: a bundle that won't open, a resource that isn't there,
/// or a `"symbols"` key that isn't the dictionary this format has always used all read the same
/// way as "no inventory to read" rather than as three different failure modes to chase. The
/// merged curated set is never empty, so the picker always has *something* to search, even on a
/// system whose metadata this reader cannot make sense of.
private static func load(bundlePath: String) -> [String] {
guard
let bundle = Bundle(path: bundlePath),
let plistPath = bundle.path(forResource: "name_availability", ofType: "plist"),
let data = FileManager.default.contents(atPath: plistPath),
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil),
let root = plist as? [String: Any],
let symbols = root["symbols"] as? [String: Any]
else {
return Set(defaultSet + CuratedSymbols.all).sorted()
}
return symbols.keys.sorted()
}
/// `symbols` narrowed to the names matching `query` pure, so the AND semantics and the
/// order-preservation are assertable without a picker on screen.
///
/// Whitespace-trimmed first, and an empty result of that is "no query", not "match nothing" a
/// freshly opened search field must show the full catalog, not a blank grid. A non-empty query
/// splits into whitespace-separated tokens, every one of which must appear, case-insensitively,
/// somewhere in the name: `"wrench screw"` finds `wrench.and.screwdriver` the way a Spotlight-style
/// search would, rather than requiring the words adjacent or in order.
static func filter(_ query: String, in symbols: [String]) -> [String] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return symbols }
let tokens = trimmed.split(whereSeparator: { $0.isWhitespace }).map { $0.lowercased() }
return symbols.filter { name in
let lowered = name.lowercased()
return tokens.allSatisfy { lowered.contains($0) }
}
}
}
// MARK: - Geometry
/// The picker's font-derived geometry well side, well spacing, the fixed 6×6 grid, and the
/// popover's own padding following `StyleEditorLayout`'s derivation rather than restating it: the
/// base well side and spacing are read straight off `StyleEditorLayout`'s statics, then the grid's
/// wells and glyphs scale up by `gridScale` a deliberate, user-tuned enlargement (the picker's grid
/// is this popover's whole subject, where the style editor's is one section among several), still
/// anchored to the shared base so the two components move together at every text size. The at-rest
/// button keeps the unscaled side (`restSide`) it sits inline with a text field and matches that
/// field's height, not the grid's. Only the shape wraps a picker's own frame around them six
/// columns fixed (not a
/// caller-configurable count, since a picker has no anchor-width story the way `StyleEditorLayout`'s
/// sidebar/popover split does), and a total padded width that is fixed for the same reason the
/// style editor's popover frame is: a popover is a window this app sizes, and a resizing one across
/// keystrokes would be distracting rather than helpful.
struct SymbolPickerLayout: Equatable {
static let columns = 6
static let rows = 6
/// The grid's enlargement over the style editor's well size glyphs read at a glance rather
/// than in miniature.
static let gridScale: CGFloat = 1.3
/// The at-rest button's side the unscaled base, matched to the style editor's wells and to
/// the text-field height the button sits beside.
var restSide: CGFloat
/// The glyph's own point size inside a grid well the body size under `gridScale`, since a
/// symbol renders at the font size, not the frame; a bigger well alone would just add margin.
var glyphPointSize: CGFloat
var wellSide: CGFloat
var wellSpacing: CGFloat
/// The gap between the search field and the grid below it one figure rather than a pixel
/// literal, so Dynamic Type moves it with everything else (10-accessibility.md's full-relative-
/// scaling rule).
var searchSpacing: CGFloat
/// The popover's own inset, on all four sides.
var contentPadding: CGFloat
var gridWidth: CGFloat
/// The search grid's scroll cap six rows tall, so a long result list scrolls inside the popover
/// rather than growing it.
var gridHeight: CGFloat
/// The grid's width plus its padding on both sides the popover's fixed width.
var popoverWidth: CGFloat
static func metrics(bodyPointSize: CGFloat) -> SymbolPickerLayout {
let baseSide = StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize)
let side = (baseSide * gridScale).rounded()
let spacing = StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
let padding = StyleEditorLayout.sectionSpacing(bodyPointSize: bodyPointSize)
let gridWidth = (side * CGFloat(columns) + spacing * CGFloat(columns - 1)).rounded()
let gridHeight = (side * CGFloat(rows) + spacing * CGFloat(rows - 1)).rounded()
return SymbolPickerLayout(
restSide: baseSide,
glyphPointSize: (bodyPointSize * gridScale).rounded(),
wellSide: side,
wellSpacing: spacing,
searchSpacing: spacing,
contentPadding: padding,
gridWidth: gridWidth,
gridHeight: gridHeight,
popoverWidth: (gridWidth + padding * 2).rounded()
)
}
}
// MARK: - The control
/// A single symbol well that opens a curated grid the reusable primitive `03-board-ui.md`'s
/// full-browser refusal ("the raw file is the escape hatch") leaves room for: not a new in-app way to
/// hand-edit `icon`, but a control any caller can aim at one symbol field without wiring up a
/// `BoardStore`, a `StyleTarget`, or the two-dimension batch machinery `StyleEditorView` carries for
/// the board's own background+icon editor.
///
/// **View-local state only** the popover's presented flag lives here, its search text lives with
/// the popover content. Nothing about a store, an undo stack, or a target set is known to this type;
/// `onSelect` is the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding
/// would be.
struct SymbolPicker: View {
/// The committed symbol name, or `nil` for "no override" read alongside `fallback` rather than
/// pre-resolved by the caller, so this view (and only this view) has to know the lenient-render
/// rule (`ItemSymbol.name(_:fallback:)`'s rule, restated for a plain `String?` since a caller here
/// may have no `FieldValue` at all).
let current: String?
/// The level default shown when `current` is absent or unresolvable, and the grid's leading well.
let fallback: String
/// The curated grid's contents. Defaults to `SymbolPickerCatalog.available` so a caller with no
/// opinion gets the general-purpose set; a caller styling a specific domain (a template chooser,
/// say) can supply its own.
var symbols: [String] = SymbolPickerCatalog.available
/// Whether the popover offers the search field and full-catalog fallback at all. `false` collapses
/// the picker to the curated grid alone a caller with no use for the OS's whole inventory
/// (a fixed small vocabulary) is not forced to carry the search chrome anyway.
var searchable: Bool = true
/// The name to set, or `nil` to clear back to the default mirrors `StyleChange`'s `set`/`remove`
/// split without importing that type, since a caller outside the styling system has no `StyleChange`
/// to hand back.
let onSelect: (String?) -> Void
@State private var isPresented = false
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
/// What the well actually draws `current` if this system can resolve it, `fallback` otherwise.
/// The same lenient rule `ItemSymbol.name(_:fallback:)` states for a `FieldValue`, restated here
/// because this control's `current` is already a plain optional string by the time it arrives.
private var resolvedName: String {
if let current, ItemSymbol.exists(current) { return current }
return fallback
}
var body: some View {
let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize)
Button {
isPresented = true
} label: {
Image(systemName: resolvedName)
.imageScale(.medium)
.frame(width: layout.restSide, height: layout.restSide)
}
.buttonStyle(.bordered)
.help("Symbol")
.accessibilityLabel("Symbol")
.accessibilityValue(resolvedName)
.popover(isPresented: $isPresented, arrowEdge: .bottom) {
SymbolPickerPopoverContent(
current: current,
fallback: fallback,
symbols: symbols,
searchable: searchable,
layout: layout,
onSelect: { name in
onSelect(name)
isPresented = false
}
)
}
}
}
// MARK: - The popover's content
/// The popover's body: the search field (when `searchable`), and either the curated grid or a
/// live search result never both, since a query and the at-rest curated set answer the same
/// question two different ways.
private struct SymbolPickerPopoverContent: View {
let current: String?
let fallback: String
let symbols: [String]
let searchable: Bool
let layout: SymbolPickerLayout
let onSelect: (String?) -> Void
@State private var query = ""
var body: some View {
VStack(alignment: .leading, spacing: layout.searchSpacing) {
if searchable {
searchField
}
resultBody
}
.padding(layout.contentPadding)
.frame(width: layout.popoverWidth)
}
private var searchField: some View {
TextField("Search Symbols", text: $query)
.textFieldStyle(.roundedBorder)
// **Escape steps outward one layer per press** (`BoardRenameField`'s idiom, the app's
// standing Escape grammar): a non-empty query clears itself and keeps the popover open,
// an empty one lets the press through to the popover's own dismissal.
.onKeyPress(.escape) {
guard !query.isEmpty else { return .ignored }
query = ""
return .handled
}
}
@ViewBuilder
private var resultBody: some View {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
SymbolWellGrid(wells: curatedWells, layout: layout) { well in
onSelect(well.isDefault ? nil : well.name)
}
} else {
let matches = SymbolPickerCatalog.filter(query, in: SymbolPickerCatalog.fullCatalog())
if matches.isEmpty {
Text("No matches")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, layout.wellSpacing)
} else {
ScrollView(.vertical) {
SymbolWellGrid(wells: matchWells(matches), layout: layout) { well in
onSelect(well.name)
}
}
.frame(height: layout.gridHeight)
}
}
}
/// The at-rest grid: the leading default well, then up to 35 more from `symbols` 03-board-ui.md
/// § Styling Controls' "leading well is the level's default symbol" rule, restated for this
/// control's plain-optional `current`/`fallback` pair.
///
/// `fallback` is dropped from the trailing set if present, so the default is never drawn twice
/// which is also why the trailing set is 35 rather than 36: the two together fill the 6×6 grid
/// exactly when `fallback` was one of `symbols` to begin with (as it is for the card level, whose
/// default `doc.text` sits inside `SymbolPickerCatalog.defaultSet`), and fall one well short of
/// full when it wasn't (board and lane) a quieter outcome than a grid that overflows its own
/// 6×6 cap.
private var curatedWells: [SymbolPickerWell] {
let isDefaultSelected = current.map { !ItemSymbol.exists($0) } ?? true
var wells = [SymbolPickerWell(
id: 0,
name: fallback,
label: "Default (\(fallback))",
isSelected: isDefaultSelected,
isDefault: true
)]
let trailing = symbols.filter { $0 != fallback }
for (index, name) in trailing.prefix(SymbolPickerLayout.columns * SymbolPickerLayout.rows - 1).enumerated() {
wells.append(SymbolPickerWell(
id: index + 1,
name: name,
label: name,
isSelected: current == name,
isDefault: false
))
}
return wells
}
private func matchWells(_ matches: [String]) -> [SymbolPickerWell] {
matches.enumerated().map { index, name in
SymbolPickerWell(id: index, name: name, label: name, isSelected: current == name, isDefault: false)
}
}
}
// MARK: - Wells
/// One well in either grid: what it draws, what it is called, and whether it is the leading default.
private struct SymbolPickerWell: Identifiable {
let id: Int
let name: String
let label: String
let isSelected: Bool
/// Whether this is the leading "no override" well drawn quieter (`StyleWellFace`'s
/// `.defaultSymbol` treatment) so "no symbol set" and "this symbol set" read differently at a
/// glance, and selected by `onSelect(nil)` rather than `onSelect(well.name)`.
let isDefault: Bool
}
/// One well's face: the glyph, tinted by whether it is the default. `StyleEditor.swift`'s
/// `StyleWellFace` already draws this exact shape, but as a `private` type it is not this file's to
/// reach a small sibling here, rather than widening that file's access for one caller outside it.
private struct SymbolWellFace: View {
let name: String
let isDefault: Bool
let size: CGFloat
/// The glyph's font size set explicitly (`SymbolPickerLayout.glyphPointSize`) rather than
/// inherited, since the grid's enlargement lives in the font, not the frame.
let glyphPointSize: CGFloat
var body: some View {
Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
.font(.system(size: glyphPointSize))
.foregroundStyle(isDefault ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary))
.frame(width: size, height: size)
}
}
/// One grid of wells: Tab-reachable buttons, arrow-navigable as a grid `StyleWellGrid`'s pattern,
/// mirrored rather than shared for the same reason `SymbolWellFace` is its own type. The duplication
/// is small (one `move(_:)` handler) and the alternative exporting `StyleWellGrid` generically out
/// of the style editor would widen a file whose whole point is staying anchor-agnostic to a second,
/// unrelated caller.
private struct SymbolWellGrid: View {
let wells: [SymbolPickerWell]
let layout: SymbolPickerLayout
let onSelect: (SymbolPickerWell) -> Void
@FocusState private var focused: Int?
@Environment(\.colorSchemeContrast) private var contrast
var body: some View {
LazyVGrid(
columns: Array(
repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
count: SymbolPickerLayout.columns
),
spacing: layout.wellSpacing
) {
ForEach(wells) { well in
Button {
onSelect(well)
} label: {
SymbolWellFace(
name: well.name,
isDefault: well.isDefault,
size: layout.wellSide,
glyphPointSize: layout.glyphPointSize
)
.overlay(selectionRing(well.isSelected))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.focusable()
.focused($focused, equals: well.id)
.help(well.label)
.accessibilityLabel(well.label)
.accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
}
}
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
move(press.key)
}
}
private func selectionRing(_ isSelected: Bool) -> some View {
RoundedRectangle(cornerRadius: max(1, (layout.wellSide * 0.25).rounded()))
.strokeBorder(
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
)
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
}
/// One step per press, clamped at the ends `StyleWellGrid.move(_:)`'s rule, restated for this
/// grid's own fixed column count.
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
let delta: Int
switch key {
case .leftArrow: delta = -1
case .rightArrow: delta = 1
case .upArrow: delta = -SymbolPickerLayout.columns
case .downArrow: delta = SymbolPickerLayout.columns
default: return .ignored
}
let current = focused ?? 0
let next = min(max(0, current + delta), wells.count - 1)
focused = next
return .handled
}
}
+197
View File
@@ -0,0 +1,197 @@
import AppKit
import Testing
@testable import Kanban
/// `ColorComboView`'s pure model (`ColorCombo.swift`): item-list composition, selection matching,
/// hex normalization and display-name casing every rule stated where a test can hold it without
/// an `NSView`, matching `PaletteTests.swift`'s own split between the vocabulary and the view.
///
/// One outer suite, nested by concern swift-testing discovers nested types as sub-suites, which
/// is what lets `-only-testing:KanbanTests/ColorComboTests` run the whole file while each concern
/// still reads as its own group, `PaletteTests.swift`'s top-level-structs style scoped one level in.
struct ColorComboTests {
// MARK: - Display names
struct DisplayName {
@Test func kebabCaseBecomesTitleCaseWithHyphensAsSpaces() {
#expect(ColorComboModel.displayName("light-cayenne") == "Light Cayenne")
#expect(ColorComboModel.displayName("smokey-rich-eggplant") == "Smokey Rich Eggplant")
#expect(ColorComboModel.displayName("obsidian") == "Obsidian")
#expect(ColorComboModel.displayName("deep-sky-blue") == "Deep Sky Blue")
}
}
// MARK: - Hex normalization
struct HexNormalization {
@Test func sixDigitHexUppercasesUnchanged() {
#expect(ColorComboModel.normalizedHex("#b6071e") == "#B6071E")
#expect(ColorComboModel.normalizedHex("#B6071E") == "#B6071E")
}
/// Alpha below full opacity is meaningful and stays only a fully-opaque suffix collapses.
@Test func eightDigitHexWithPartialAlphaStaysEightDigits() {
#expect(ColorComboModel.normalizedHex("#b6071e80") == "#B6071E80")
}
/// `#RRGGBBFF` fully opaque, spelled with an explicit alpha byte collapses to the
/// six-digit form, so it compares equal to a bare `#RRGGBB` written for the same colour.
@Test func fullyOpaqueEightDigitHexCollapsesToSixDigits() {
#expect(ColorComboModel.normalizedHex("#b6071eFF") == "#B6071E")
#expect(ColorComboModel.normalizedHex("#B6071EFF") == ColorComboModel.normalizedHex("#B6071E"))
}
@Test func malformedOrUnprefixedValuesNormalizeToNil() {
for value in ["", "#", "#12", "#12345", "#1234567", "#GGGGGG", "B6071E", "light-cayenne"] {
#expect(ColorComboModel.normalizedHex(value) == nil, "'\(value)' should not normalize")
}
}
}
// MARK: - Selection matching
struct Matching {
@Test func nilValueMatchesNone() {
#expect(ColorComboModel.match(role: .background, value: nil) == .none)
#expect(ColorComboModel.match(role: .foreground, value: nil) == .none)
}
@Test func aNameInTheRolesOwnPaletteMatchesThatRowExactly() {
#expect(ColorComboModel.match(role: .background, value: "light-cayenne") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .foreground, value: "fern") == .palette("fern"))
}
/// Names are matched exactly, like `Palette.nsColor(for:)` a near-miss is not "the same
/// row", it is an off-palette value with its own dynamic row.
@Test func aNearMissNameIsNotAPaletteMatch() {
let match = ColorComboModel.match(role: .background, value: "Light-Cayenne")
guard case let .current(_, title) = match else {
Issue.record("expected .current, got \(match)")
return
}
#expect(title == "Light-Cayenne")
}
/// A hex that normalizes to one of the role's own palette hexes selects the **name**, not
/// the hex case-insensitively, and with a fully-opaque `#RRGGBBFF` collapsing exactly like
/// a bare `#RRGGBB` would.
@Test func aHexEqualToAPaletteColorsHexMatchesItsNamedRow() {
#expect(ColorComboModel.match(role: .background, value: "#B6071E") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .background, value: "#b6071e") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .background, value: "#B6071EFF") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .background, value: "#b6071eff") == .palette("light-cayenne"))
}
/// Partial alpha keeps a value off the palette rows even when its RGB matches one exactly
/// the stored colour is genuinely translucent, which no palette entry is.
@Test func aTranslucentHexNeverMatchesAnOpaquePaletteColor() {
let match = ColorComboModel.match(role: .background, value: "#B6071E80")
guard case let .current(swatchValue, _) = match else {
Issue.record("expected .current, got \(match)")
return
}
#expect(swatchValue == "#B6071E80")
}
/// A name from the *other* picker's table `carnation` is foreground-only is not one of
/// `.background`'s twelve, so it falls to the dynamic row, titled with its own display name
/// since the other table does know it.
@Test func aForeignPaletteNameFallsToTheDynamicRowNamedFromTheOtherTable() {
let match = ColorComboModel.match(role: .background, value: "carnation")
#expect(match == .current(swatchValue: "carnation", title: "Carnation"))
}
/// A custom hex nowhere in either table: the dynamic row states it verbatim, uppercased.
@Test func aCustomHexFallsToTheDynamicRowUppercased() {
let match = ColorComboModel.match(role: .background, value: "#123456")
#expect(match == .current(swatchValue: "#123456", title: "#123456"))
let lowercase = ColorComboModel.match(role: .background, value: "#abcdef")
#expect(lowercase == .current(swatchValue: "#abcdef", title: "#ABCDEF"))
}
/// Unresolvable garbage neither a name either table knows nor a parseable hex falls to
/// the dynamic row exactly as written, no casing applied.
@Test func garbageFallsToTheDynamicRowVerbatim() {
let match = ColorComboModel.match(role: .background, value: "chartreuse")
#expect(match == .current(swatchValue: "chartreuse", title: "chartreuse"))
}
/// The three names shared by both tables (`obsidian`, `aluminum`, `chalk`) are in *both*
/// roles' own palettes, so they match directly and never reach the "foreign name" branch.
@Test func namesSharedByBothTablesMatchDirectlyInEitherRole() {
#expect(ColorComboModel.match(role: .background, value: "obsidian") == .palette("obsidian"))
#expect(ColorComboModel.match(role: .foreground, value: "obsidian") == .palette("obsidian"))
}
}
// MARK: - Item list composition
struct Menu {
/// None first, a separator, then exactly the role's twelve, in the palette's own order.
@Test func baseOrderIsNoneSeparatorThenTheRolesTwelve() {
let menu = ColorComboModel.menu(role: .background, value: nil)
var expected: [ColorComboItem] = [.none, .separator]
expected.append(contentsOf: Palette.backgrounds.map { .palette($0.name) })
expected.append(contentsOf: [.separator, .other])
#expect(menu.items == expected)
}
@Test func foregroundRoleListsTheForegroundTwelveNotTheBackgroundTwelve() {
let menu = ColorComboModel.menu(role: .foreground, value: nil)
let paletteNames = menu.items.compactMap { item -> String? in
if case let .palette(name) = item { return name }
return nil
}
#expect(paletteNames == Palette.foregrounds.map(\.name))
}
/// `Other` is always last, and there is never more than one dynamic row.
@Test func otherIsAlwaysLast() {
for value in [nil, "obsidian", "carnation", "#123456", "chartreuse"] {
let menu = ColorComboModel.menu(role: .background, value: value)
#expect(menu.items.last == .other)
}
}
/// No dynamic row, and no selected item beyond the palette rows, when the value is `nil` or
/// one of the role's own twelve.
@Test func noDynamicRowWhenTheValueIsNoneOrAPaletteName() {
let none = ColorComboModel.menu(role: .background, value: nil)
#expect(!none.items.contains { if case .current = $0 { return true }; return false })
#expect(none.selectedIndex == 0)
#expect(none.items[none.selectedIndex] == .none)
let named = ColorComboModel.menu(role: .background, value: "dark-teal")
#expect(!named.items.contains { if case .current = $0 { return true }; return false })
#expect(named.items[named.selectedIndex] == .palette("dark-teal"))
}
/// A foreign or unresolvable value inserts exactly one dynamic row, immediately before the
/// trailing separator and `Other`, and it is the checked row.
@Test func dynamicRowAppearsOnlyForAForeignValueAndIsSelected() {
let menu = ColorComboModel.menu(role: .background, value: "#123456")
let dynamicRows = menu.items.filter { if case .current = $0 { return true }; return false }
#expect(dynamicRows.count == 1)
#expect(menu.items[menu.selectedIndex] == .current(swatchValue: "#123456", title: "#123456"))
// Immediately before the trailing separator + Other
#expect(
Array(menu.items.suffix(3)) ==
[.current(swatchValue: "#123456", title: "#123456"), .separator, .other]
)
}
/// A hex landing exactly on a palette colour selects that named row and adds no dynamic row
/// at all the same shape as picking the name directly.
@Test func anExactHexMatchProducesNoDynamicRow() {
let byName = ColorComboModel.menu(role: .background, value: "light-cayenne")
let byHex = ColorComboModel.menu(role: .background, value: "#B6071E")
#expect(byName.items == byHex.items)
#expect(byName.selectedIndex == byHex.selectedIndex)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import Testing
@testable import Kanban
/// **The symbol picker's pure seams** (03-board-ui.md § Styling Controls, the general-purpose
/// picker `SymbolPicker.swift` builds beside the style editor's own curated grid): the curated
/// default set, the full-catalog loader and its cache, and the search filter's AND semantics.
/// SwiftUI rendering the well grid, the popover's arrow-key navigation is deliberately untested,
/// exactly as `StyleEditor.swift`'s own wells are.
@Suite("SymbolPicker ▸ the curated default set")
struct SymbolPickerCatalogDefaultSetTests {
@Test("Exactly 36 entries, all unique")
func shape() {
#expect(SymbolPickerCatalog.defaultSet.count == 36)
#expect(Set(SymbolPickerCatalog.defaultSet).count == SymbolPickerCatalog.defaultSet.count)
}
@Test("Every entry is one this system can actually draw — pins the list against typos")
func everyNameResolves() {
let missing = SymbolPickerCatalog.defaultSet.filter { !ItemSymbol.exists($0) }
#expect(missing.isEmpty, "unknown SF Symbol names: \(missing)")
#expect(SymbolPickerCatalog.available.count == SymbolPickerCatalog.defaultSet.count)
}
}
@Suite("SymbolPicker ▸ filter")
struct SymbolPickerFilterTests {
@Test("An empty or whitespace-only query returns the input unchanged")
func emptyQueryIsANoOp() {
let symbols = ["star", "flag", "heart"]
#expect(SymbolPickerCatalog.filter("", in: symbols) == symbols)
#expect(SymbolPickerCatalog.filter(" ", in: symbols) == symbols)
#expect(SymbolPickerCatalog.filter("\t\n", in: symbols) == symbols)
}
@Test("A single token matches case-insensitively, as a substring")
func singleTokenSubstring() {
let symbols = ["star", "star.fill", "flag", "flag.checkered"]
#expect(SymbolPickerCatalog.filter("star", in: symbols) == ["star", "star.fill"])
#expect(SymbolPickerCatalog.filter("STAR", in: symbols) == ["star", "star.fill"])
#expect(SymbolPickerCatalog.filter("Fla", in: symbols) == ["flag", "flag.checkered"])
}
@Test("Multiple tokens are an AND — every token must appear somewhere in the name")
func multiTokenIsAnAnd() {
let symbols = ["wrench.and.screwdriver", "screwdriver", "wrench"]
#expect(SymbolPickerCatalog.filter("wrench screw", in: symbols) == ["wrench.and.screwdriver"])
#expect(SymbolPickerCatalog.filter("screw wrench", in: symbols) == ["wrench.and.screwdriver"])
}
@Test("Input order is preserved")
func orderPreserved() {
let symbols = ["zebra.star", "apple.star", "mango.star"]
#expect(SymbolPickerCatalog.filter("star", in: symbols) == symbols)
}
@Test("No match returns an empty array")
func noMatchIsEmpty() {
#expect(SymbolPickerCatalog.filter("xyzzy-nonexistent", in: ["star", "flag"]).isEmpty)
}
}
@Suite("SymbolPicker ▸ the full catalog")
struct SymbolPickerFullCatalogTests {
@Test("The default path returns a sorted, unique, non-empty list containing well-known names")
func defaultPathLoads() {
let catalog = SymbolPickerCatalog.fullCatalog()
#expect(!catalog.isEmpty)
#expect(catalog == catalog.sorted())
#expect(Set(catalog).count == catalog.count)
#expect(catalog.contains("star"))
#expect(catalog.contains("folder"))
}
@Test("Two calls against the default path return identical results — the cache is coherent")
func cacheIsCoherent() {
#expect(SymbolPickerCatalog.fullCatalog() == SymbolPickerCatalog.fullCatalog())
}
@Test("A nonexistent bundle path falls back to the merged curated set, sorted and unique")
func nonexistentPathFallsBack() {
let expected = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.all).sorted()
#expect(SymbolPickerCatalog.fullCatalog(bundlePath: "/nonexistent") == expected)
}
}