Files
lanework/Kanban/UI/Card/CardSidebarSections.swift
T
rzen f191e5c3ce Two widths, one height — the symbol picker squares to 5:4, colour holds 6:4, and None's slash follows the swatch out of the grid
The owner's follow-up review on the shipped rectangles (Pipeline card
5004c540): height was close, so it stays exactly 2.1em on both controls;
width now diverges by role via PickerRectMetrics.WidthRatio (color 1.5,
symbol 1.25) instead of one shared multiplier. The symbol glyph gets a
small em-derived inset back (SymbolGlyphControl.glyphInset) — a sliver of
breath, not the old padded ring — applied to the face rect before sizing
and fitting, still centred on the same midpoint. The collapsed colour
swatch reuses ColorSwatchNoneStrike's own geometry to draw the popover's
None slash whenever the stored value is nil or unresolvable, rather than
sitting empty. Both mounting anchors (the card sidebar's side-by-side
columns, the board popover beside the rename field) pick up .fixedSize()
so the controls render at their own intrinsic size instead of stretching
into whatever slack an HStack proposal leaves them.

KanbanTests/SymbolCatalogTests.swift and ColorSwatchPickerTests.swift
updated for the per-role widths, the shared-height claim, the glyph
inset rule, and the None-face predicate.
2026-08-09 21:37:29 -04:00

270 lines
16 KiB
Swift

import AppKit
import SwiftUI
// MARK: - Appearance
/// The sidebar's **Appearance** section (renamed from **Style**, owner's ruling 2026-08-10, Pipeline
/// card 5004c540 — the sidebar-only rename; the board context menu's **Style…** item and
/// `StyleEditorView`'s own naming are unaffected, per that ruling's own scope): the **`SymbolPicker`**
/// rectangle beside the **`ColorSwatchPicker`** rectangle, side by side — Symbol left, Background
/// right, each captioned *above* rather than leading (05-card-window.md ▸ Style; 03-board-ui.md ▸
/// Styling ▸ Controls, its 2026-08-06 anchor-ownership rule for the background half, extended to the
/// symbol half on 2026-08-08). Card styling is discoverable here without a context menu — both
/// rectangles are the same reusable controls the board popover's glyph picker beside the rename field
/// already uses (`BoardInfoPopover`) and the Style… popover's own grid reuses (`ColorSwatchPicker`'s
/// own header).
///
/// ### Why the curated grid left (history, unaffected by this rename)
///
/// `StyleEditorView`'s curated grid used to draw **whole, uncollapsed** in this column
/// (`StyleEditorLayout.sidebar`'s `symbolGridMaximumHeight: nil` — "the sidebar is already a scroll
/// view, and a scroll view inside a scroll view is a scroll view that fights") — a five-to-seven-row
/// grid, permanently open, ahead of every other section the sidebar carries. `SymbolPicker` is the
/// general-purpose control built for precisely this shape of problem (its own header: "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") — one small rectangle at rest, the grid
/// only while its popover is open. This section is the first caller to aim it at a store-backed field
/// rather than a plain binding, which is why it does the store plumbing below that `SymbolPicker`
/// itself deliberately knows nothing about.
///
/// **`StyleEditorView` is no longer instantiated here at all.** It was already `showsBackground:
/// false` — the background rectangle has owned that half since 2026-08-06 (as `ColorComboView`, now
/// `ColorSwatchPicker`) — so the curated symbol grid was its only remaining job in this section, and
/// that job moved out to `SymbolPicker`. The shared component's `.sidebar` layout variant and
/// `showsBackground` toggle stay in `StyleEditor.swift` regardless: they are still correct and still
/// tested (`StyleEditorLayoutTests`), and that file's own stance is to be anchor-agnostic about
/// callers it does not currently have.
///
/// ### The curated set does not shrink
///
/// `SymbolPicker`'s own default (`SymbolPickerCatalog.defaultSet`, 36 general "boards and projects"
/// glyphs) is deliberately smaller than `CuratedSymbols.cards` (~40, work-item/content-flavored — one
/// of the three level-specific sets `CuratedSymbols` split into, 2026-08-09) — `SymbolPicker.swift`'s
/// own doc comment states why the lists are free to diverge. A card is exactly the level
/// `CuratedSymbols.cards` is grouped for, so this section passes it explicitly (`symbols:
/// CuratedSymbols.availableCards`) rather than taking the picker's domain-agnostic default: the
/// Style… popover's card-level grid and this section now offer the identical curated vocabulary,
/// through two different controls.
///
/// ### Writes still ride the one funnel, on this window's own stack
///
/// `onSelect`/`onSelectColor`/`onChange` below call `StyleCommand.apply(icon:iconColor:to:in:store:
/// recents:on:)` — the same funnel `StyleEditorView`'s wells rode, `on: undo` passed through exactly
/// as before (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 reaches board history only
/// inside the coarse close step). Nothing about `BoardStore.applyStyle`'s batch semantics, its no-op
/// skipping, or its `HistoryAnchor` choice moved; only the views that call it did —
/// `CardSessionUndoTests.sidebarStylingIsASessionGesture` calls that same funnel directly and needed
/// no change.
///
/// **The background rectangle's Colors-panel door no longer needs this section's own debounce.**
/// `ColorComboView`'s old panel handoff was a per-view-owned `SystemColorPanel`, undebounced at the
/// source, so this section carried its own trailing-debounce `Task` (`backgroundPanelCommit`) around
/// it. `ColorSwatchPicker`'s **Other…** row opens `SharedColorPanelSession` instead — the same
/// pre-debounced (~400ms) shared session the Style… popover's own background section and the symbol
/// popover's tint row already use — so the settled value arrives already debounced, and this section
/// only has to write it (`applyPanelBackground(_:)`), raw, never through the recents funnel, exactly
/// as `StyleEditorView.openBackgroundPanel` writes its own.
///
/// ### The target is fixed, and that is the whole difference in behavior
///
/// The Style… popover *tracks*: its target is the selection at the moment the gesture named it,
/// re-resolved against every snapshot, dismissing when it empties (`StyleEditorSession`). This
/// section tracks nothing. Its target is this window's card, always — "the two embedded anchors need
/// none of this and get none: the card sidebar dismisses with its card's window, and the board
/// popover's target is the board itself" (`StyleEditorSession`'s own note). `SymbolPicker`'s and
/// `ColorSwatchPicker`'s own popover presentations are plain view-local `@State`, needing no session
/// for the same reason: the window's card cannot vanish out from under its own window, and when it
/// does the window goes with it (`CardWindowFate`).
struct CardStyleSection: View {
let store: BoardStore
let recents: StyleRecents
let cardID: ItemID
/// **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
/// reaches board history only inside the coarse close step. The background rectangle below and
/// the symbol rectangle both take it as a parameter, for the same reason.
let undo: CardWindowUndo
/// 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.
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
/// **What this section styles: this card, and nothing else.**
///
/// A one-line seam because it is a claim worth pinning rather than a computation worth reading:
/// every other anchor derives its target from something that moves (the selection, the board),
/// and the mistake this window could make is inheriting one of those. `.items` of exactly one id
/// also means `applyStyle`'s own batch machinery is a no-op here — one subject, so the display is
/// never mixed and the write bracket holds one file.
nonisolated static func target(forCard id: ItemID) -> StyleTarget {
.items([id])
}
var body: some View {
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
CardSidebarSectionHeader(title: "Appearance")
// Side by side, Symbol left of Background — the owner's sketch (2026-08-10 ruling):
// "Symbol Background" over the two rectangles. `.top` alignment keeps the two
// captions, and the two rectangles below them, on one shared baseline even though
// neither column reserves a fixed width for the other.
HStack(alignment: .top, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
symbolColumn
backgroundColumn
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Symbol
/// The **Symbol** column: its caption *above* the rectangle rather than leading it — the owner's
/// sketch, and `CardDetailsSection.row`'s own caption-over-value shape (`CardDetailsSection.
/// swift`) applied here instead of `CardSidebarSectionHeader`'s title-and-divider shape, which is
/// a section-level header rather than a per-control caption. `4` is `CardSidebarSectionHeader`'s
/// own literal spacing between its title and its divider, restated for the same small gap here.
private var symbolColumn: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Symbol")
.font(.caption)
.foregroundStyle(.secondary)
SymbolPicker(
current: currentSymbol,
fallback: ItemSymbol.card,
symbols: CuratedSymbols.availableCards,
onSelect: { applySymbol($0) },
currentColor: currentIconColor,
onSelectColor: { applyIconColor($0) }
)
// The same lock `StyleEditorView`'s whole body disabled under
// (`.disabled(!store.acceptsBoardMutations)`, `StyleEditor.swift`) — the read-only lock
// and the board's inline-editing rule alike, unchanged across the layout reshape and the
// control's own two-zone-to-one-zone change.
.disabled(!store.acceptsBoardMutations)
// **Pinned to the control's own intrinsic size** (owner's 2026-08-10 follow-up: the two
// pickers' ratios have to actually render). `SymbolGlyphView.sizeThatFits` honours *any*
// explicit finite width proposal, which is right for a caller that wants to constrain it —
// but this row's own `HStack` sits under an outer `.frame(maxWidth: .infinity)`, and
// without `.fixedSize()` here the row can propose more than the control's intrinsic width
// once there is slack to give it, silently drowning the 5:4 ratio the owner asked for.
// `.fixedSize()` makes SwiftUI always ask for the ideal size instead.
.fixedSize()
}
}
/// The card's `icon` field, exactly as written — `currentBackground`'s rule, restated for the
/// field `SymbolPicker.current` reads. Resolution (an unresolvable name falling back to the level
/// default) is `SymbolPicker`'s own job, not this property's — `ItemSymbol.name(_:fallback:)`'s
/// lenient rule, which the picker's `resolvedName` already applies.
private var currentSymbol: String? {
StyleFieldState.written(currentSubject?.icon ?? .missing)
}
/// The card's `iconColor` field, exactly as written — one dimension over from `currentSymbol`,
/// feeding `SymbolPicker.currentColor`.
private var currentIconColor: String? {
StyleFieldState.written(currentSubject?.iconColor ?? .missing)
}
/// `nil` clears back to the card default (`icon` removed); a name sets it — `StyleChange`'s
/// `set`/`remove` split, `SymbolPicker.onSelect`'s own contract turned into the vocabulary
/// `StyleCommand.apply` speaks. Routed through the funnel rather than `store.applyStyle` directly
/// so a symbol pick joins `StyleRecents` exactly as a background pick does — moot today (only
/// `.set(background:)` records, `StyleCommand.apply`'s own rule), but future-proof against that
/// rule ever widening to symbols.
private func applySymbol(_ name: String?) {
StyleCommand.apply(
icon: name.map { StyleChange.set($0) } ?? .remove,
to: Self.target(forCard: cardID),
in: store,
recents: recents,
on: undo
)
}
/// `nil` clears the tint (`iconColor` removed); a palette name sets it — `applySymbol`'s sibling,
/// one dimension over, exactly as `BoardInfoPopover`'s own `onSelectColor` writes the board's.
private func applyIconColor(_ name: String?) {
StyleCommand.apply(
iconColor: name.map { StyleChange.set($0) } ?? .remove,
to: Self.target(forCard: cardID),
in: store,
recents: recents,
on: undo
)
}
// MARK: - Background
/// The **Background** column, `symbolColumn`'s own caption-above shape mirrored — both
/// rectangles read their geometry off the identical `PickerRectMetrics`
/// (`PickerRect.swift`), so the two columns line up at the same height without either one
/// naming a size of its own.
private var backgroundColumn: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Background")
.font(.caption)
.foregroundStyle(.secondary)
ColorSwatchPicker(
role: .background,
value: currentBackground,
isEnabled: !store.isReadOnly,
onChange: { commitBackground($0) },
onPanelChange: { applyPanelBackground($0) }
)
// `symbolColumn`'s own fix, restated: pins this rectangle to its intrinsic 6:4 size
// rather than whatever width the row's outer `.frame(maxWidth: .infinity)` leaves slack
// for `ColorSwatchView.sizeThatFits` to honour.
.fixedSize()
}
}
/// 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: the grid's selection matching needs the bytes, not what they
/// render as (`ColorSwatchPicker`'s own header on why that matching is now literal-name only).
private var currentBackground: String? {
StyleFieldState.written(currentSubject?.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 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)
}
}
/// The Colors panel's settled value, from `ColorSwatchPicker`'s **Other…** door
/// (`SharedColorPanelSession`, already debounced ~400ms at the source — this section carries no
/// debounce of its own any more, `CardStyleSection`'s own header). Written raw, **never** through
/// `StyleCommand.apply` — a panel pick must not enter `StyleRecents`, the same rule
/// `StyleEditorView.openBackgroundPanel` applies to its own **Other…** row.
private func applyPanelBackground(_ newValue: String?) {
let target = Self.target(forCard: cardID)
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)
}
}
/// One `styleSubjects` lookup behind `currentBackground`, `currentSymbol` and `currentIconColor`
/// above — a card window names exactly one target, so there is exactly one subject to read for
/// all three fields.
private var currentSubject: BoardStore.StyleSubject? {
store.styleSubjects(of: Self.target(forCard: cardID)).first
}
}