Compare commits

...
3 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
12 changed files with 1562 additions and 21 deletions
+4 -4
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.
+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.
+24 -3
View File
@@ -309,8 +309,27 @@ 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) {
BoardRenameField(store: store) // 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)
}
} }
.padding(inset) .padding(inset)
@@ -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)
}
} }
+23 -4
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,9 +276,15 @@ 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)
backgroundSection(background, layout: layout) if showsBackground {
Divider() backgroundSection(background, layout: layout)
symbolSection(icon, layout: layout) }
if showsBackground && showsSymbols {
Divider()
}
if showsSymbols {
symbolSection(icon, layout: layout)
}
} }
.padding(layout.padding) .padding(layout.padding)
.frame(width: layout.width) .frame(width: layout.width)
+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)
}
}