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
This commit is contained in:
2026-08-06 22:05:48 -04:00
parent 73698cd77b
commit 9766e1f61c
9 changed files with 981 additions and 18 deletions
+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
/// 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
/// 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
/// 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,
/// so every section in this sidebar derives its geometry the same way.
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
@@ -56,6 +63,10 @@ struct CardStyleSection: View {
var body: some View {
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
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(
store: store,
recents: recents,
@@ -64,11 +75,82 @@ struct CardStyleSection: View {
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
bodyPointSize: pointSize
),
undo: undo
undo: undo,
showsBackground: false
)
}
.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