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
227 lines
12 KiB
Swift
227 lines
12 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - Style
|
|
|
|
/// The sidebar's **Style** section: "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…" (05-card-window.md ▸ Style).
|
|
///
|
|
/// ### How this anchor differs from the other two — in one word, geometry
|
|
///
|
|
/// It hosts `StyleEditorView` itself, not a copy of it: the wells, the batch display, the arrow-key
|
|
/// grammar, the read-only disabling, the recents the None well deliberately does not record, and the
|
|
/// single `applyStyle` bracket every well's click rides are all the shared component's, identical
|
|
/// here. The only thing this anchor supplies beyond a target is a `StyleEditorLayout` — the sidebar
|
|
/// is narrower than the popover at every text size, so the grids fall in fewer columns and the
|
|
/// symbol grid draws whole instead of scrolling inside the sidebar's own scroll view.
|
|
///
|
|
/// ### 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). Which is why there is no
|
|
/// session here to resolve, no popover to dismiss, and no way for a board-side selection change to
|
|
/// re-aim the editor a card window is showing: the window's card is the target by construction, and
|
|
/// when that card stops existing 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 shared editor takes it as an
|
|
/// 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 }
|
|
|
|
/// **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 the editor'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: "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,
|
|
target: Self.target(forCard: cardID),
|
|
layout: .sidebar(
|
|
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
|
|
bodyPointSize: pointSize
|
|
),
|
|
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
|
|
|
|
/// The sidebar's **Actions** section, at the bottom of the stack (05-card-window.md ▸ Actions):
|
|
/// **Delete** — moves the card to the trash — and **Reveal in Finder** — the card's folder.
|
|
///
|
|
/// ### Delete writes; the window's dismissal is not its business
|
|
///
|
|
/// The button calls `BoardStore.deleteCard`, which is the ⌫ delete exactly (same write op, same
|
|
/// bracket, same stamps). It does not close this window: the card's move into `.trash/` rounds back through
|
|
/// the watcher and `CardWindowHost.cardWindowFate` takes the window down, which is the same path a
|
|
/// delete from the board — or from an agent — already takes. Dismissing from here as well would be a
|
|
/// second rule able to disagree with the first, and 05's own wording is a sequence rather than a
|
|
/// pair ("moves the card to the trash; the window then dismisses itself").
|
|
///
|
|
/// Recovery is the board's trash column, which is why this needs no confirmation: the card is still
|
|
/// there to drag out or cut and paste back (03-board-ui.md § Trash — there is no Put Back), and 03
|
|
/// reserves the alert for the purge that isn't recoverable.
|
|
///
|
|
/// ### Reveal is not edit-shaped
|
|
///
|
|
/// So it stays enabled under the read-only lock, where Delete does not — inspection is a read (04 ▸
|
|
/// The trash's posture, shared by the trash row's own Reveal). What it reveals comes from
|
|
/// `CardAttachments.revealURLs`, the same rule File ▸ Reveal in Finder's card-window scope answers
|
|
/// through: this button is that rule's card-folder branch by construction, since it is the *card's*
|
|
/// action rather than the attachment list's.
|
|
struct CardActionsSection: View {
|
|
|
|
let store: BoardStore
|
|
let cardID: ItemID
|
|
/// The card's own folder — `nil` only where the window has no board to build it from, which is a
|
|
/// window on its way out.
|
|
let cardFolder: URL?
|
|
|
|
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
|
|
|
private var revealURLs: [URL] {
|
|
CardAttachments.revealURLs(cardFolder: cardFolder, selectedURL: nil, isSectionFocused: false)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
|
|
CardSidebarSectionHeader(title: "Actions")
|
|
|
|
// Delete above Reveal, which is the order 05 lists them in. Destructive styling, per 05
|
|
// — the one control in this window that takes the card away. Disabled under the
|
|
// read-only lock like every other mutation (02-architecture.md's every-entry-point
|
|
// predicate); that is the attachments section's `isEditable`, read from the store
|
|
// directly because there is no handle to route it through here and nothing else in this
|
|
// section that would want one.
|
|
Button(role: .destructive) {
|
|
store.deleteCard(cardID)
|
|
} label: {
|
|
// The width is the *label's*, not the button's: a bordered button sizes to its label,
|
|
// so a frame around the button would centre a small pill in a wide row instead of
|
|
// filling it. Both rows do it, so the two are one column rather than two widths.
|
|
Text("Delete").frame(maxWidth: .infinity)
|
|
}
|
|
.tint(.red)
|
|
.disabled(store.isReadOnly)
|
|
|
|
Button {
|
|
NSWorkspace.shared.activateFileViewerSelecting(revealURLs)
|
|
} label: {
|
|
Text("Reveal in Finder").frame(maxWidth: .infinity)
|
|
}
|
|
.disabled(revealURLs.isEmpty)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|