The Background tab becomes Theme — solid colors or patterns, presets only, chevron-paged
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -2050,6 +2050,81 @@ public final class BoardStore: HealHost {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Solid background
|
||||
|
||||
/// **Applies a solid colour background to this board** — the `background.color` subkey set and
|
||||
/// the `background.image` subkey removed, in one bracket (03-board-ui.md § Styling ▸ Theme tab;
|
||||
/// the Solid color half of `BoardThemeTabView`'s picker).
|
||||
///
|
||||
/// Modeled line-for-line on `applyGeneratedBackground` **minus the file write**: there is no
|
||||
/// picture to land, so the bracket holds a single `updateIndex` rather than an image write ahead
|
||||
/// of one. Everything else is that gesture's, restated here rather than shared because the two
|
||||
/// brackets differ in exactly the one place that matters (one write versus two): the same
|
||||
/// undo-restores-both-fields shape, the same `WriteOperation.setBoardBackground`, the same
|
||||
/// swallowed failure.
|
||||
///
|
||||
/// ### `facets.png` survives on disk
|
||||
///
|
||||
/// Choosing a solid colour over a generated background does **not** delete the picture the
|
||||
/// generator wrote. Undo restores the `image` *field*, and a field cannot point an undo back at
|
||||
/// bytes this gesture just erased — so the file has to survive for the same reason
|
||||
/// `applyGeneratedBackground`'s own overwrite-in-place does. A board that regenerates after
|
||||
/// choosing solid still finds `facets.png` free to overwrite in place; the file becomes an orphan
|
||||
/// only when nothing in the frontmatter ever points at it again, which is the same quiet leftover
|
||||
/// a hand-deleted `image:` line already leaves.
|
||||
///
|
||||
/// - Parameter colorHex: the solid colour to write, `#RRGGBB` — one of the Theme tab's Solid color
|
||||
/// swatches, which read `FacetsRecipe.primaryColorHex` at the filters' tone/saturation level.
|
||||
/// - Returns: whether bytes reached disk (`applyGeneratedBackground`'s same rule). Discardable:
|
||||
/// the picker has nothing to do with the answer.
|
||||
@discardableResult
|
||||
public func applySolidBackground(colorHex: String) -> Bool {
|
||||
let root = rootURL
|
||||
let priorImage = snapshot.backgroundImage
|
||||
let priorColor = snapshot.background
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
Self.pointSolidBackground(color: colorHex, in: &document)
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
|
||||
// restyle → prior style (13-native-undo.md ▸ Rules). The board's own stack, never a window's:
|
||||
// there is no card here to have a session.
|
||||
registerStep(
|
||||
HistoryPhrase.name(.restyle, kind: .board),
|
||||
undoExpects: [.present(root, .background(colorHex), .backgroundImage(nil))],
|
||||
redoExpects: [.present(root, .background(priorColor.value), .backgroundImage(priorImage.value))]
|
||||
) { _ in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
// A malformed prior reads as a removal on both subkeys, `restore(_:to:in:)`'s own
|
||||
// rule and the one the redo expectation above is written against.
|
||||
document.setBackgroundImage(priorImage.value)
|
||||
Self.restore(priorColor, to: FrontmatterKeys.background, in: &document)
|
||||
}
|
||||
} redo: { _ in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
Self.pointSolidBackground(color: colorHex, in: &document)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// The one subkey write and the one subkey removal, spelled once so the gesture and its redo
|
||||
/// cannot drift apart on the order they land in — `pointBackground(at:color:in:)`'s sibling, one
|
||||
/// image write short.
|
||||
private static func pointSolidBackground(color: String, in document: inout FrontmatterDocument) {
|
||||
document.setStyleValue(color, for: FrontmatterKeys.background)
|
||||
document.setBackgroundImage(nil)
|
||||
}
|
||||
|
||||
/// The name a generated background is written under: **ours to overwrite**, or the next free one.
|
||||
///
|
||||
/// `current` is what `background.image` says now. When that is already the generated name the
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// **The board popover's Background tab** (03-board-ui.md § Board popover ▸ Background tab, settled
|
||||
/// 2026-08-07) — two surfaces sharing one tab, one manual and one generated.
|
||||
///
|
||||
/// ### Color
|
||||
///
|
||||
/// The `StyleEditorView` embed, re-homed here unchanged from the pre-tab popover body: same target
|
||||
/// (`.board`), same `showsSymbols: false` (the inline `SymbolPicker` beside the rename field owns the
|
||||
/// board's glyph, so a second symbol grid here would make the glyph read as two settings), same write
|
||||
/// path (`StyleCommand.apply` → `BoardStore.applyStyle` + `StyleRecents.record`). It brings its own
|
||||
/// header ("Background") and its own inset (`StyleEditorLayout.popover(...).padding`), so nothing
|
||||
/// here pads around it — the anti-double-pad rule `BoardInfoView.inset`'s doc names.
|
||||
///
|
||||
/// ### Generated
|
||||
///
|
||||
/// A `FacetsRecipe` names a picture (`Backgrounds/FacetsRecipe.swift`); this section is the four
|
||||
/// filters that narrow one (tone, hue strategy, mesh density, saturation) plus one seed per hue,
|
||||
/// minted fresh on appear and re-minted by Reroll. Every swatch previews the exact recipe a click
|
||||
/// would apply — same filters, same seed, only the pixel width differs (384 for the strip, 3072 for
|
||||
/// the file) — so "what's clicked is what lands" (`FacetsGenerator`'s own claim).
|
||||
|
||||
// MARK: - Filters
|
||||
|
||||
/// **The generated picker's filter state**, and the pure mapping from it (plus a hue and a seed) to a
|
||||
/// `FacetsRecipe` — pulled out of the view so the default-tone rule and the recipe assembly are each
|
||||
/// assertable without a popover on screen (`BoardBackgroundFiltersTests`).
|
||||
struct BoardBackgroundFilters: Equatable {
|
||||
|
||||
var tone: FacetsRecipe.Tone
|
||||
var colors: FacetsRecipe.Strategy
|
||||
var mesh: FacetsRecipe.Density
|
||||
var saturation: FacetsRecipe.Saturation
|
||||
|
||||
/// The picker's opening state: mono colours, medium mesh, mid saturation always — only tone
|
||||
/// follows the system, which is `defaultTone(colorScheme:)`'s own job.
|
||||
static func initial(colorScheme: ColorScheme) -> BoardBackgroundFilters {
|
||||
BoardBackgroundFilters(
|
||||
tone: defaultTone(colorScheme: colorScheme), colors: .mono, mesh: .medium, saturation: .mid
|
||||
)
|
||||
}
|
||||
|
||||
/// Light appearance opens on Tone Light, dark on Tone Dark — read once, at first appearance, so a
|
||||
/// picker opened on a dark-mode Mac starts on swatches that read correctly against the popover
|
||||
/// around them rather than ones chosen for the other appearance. `.light` covers every
|
||||
/// `ColorScheme` case but `.dark` — there is no third case today.
|
||||
static func defaultTone(colorScheme: ColorScheme) -> FacetsRecipe.Tone {
|
||||
colorScheme == .dark ? .dark : .light
|
||||
}
|
||||
|
||||
/// One hue's recipe under these filters and a given seed — the whole of "click a swatch, get a
|
||||
/// board".
|
||||
func recipe(hue: FacetsRecipe.Hue, seed: UInt64) -> FacetsRecipe {
|
||||
FacetsRecipe(hue: hue, strategy: colors, density: mesh, tone: tone, saturation: saturation, seed: seed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The tab
|
||||
|
||||
struct BoardBackgroundTabView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
|
||||
/// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s own
|
||||
/// parameter — everything here that does not already carry its own inset (the Generated section)
|
||||
/// pads by this amount instead of restating the derivation.
|
||||
let inset: CGFloat
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
@State private var filters = BoardBackgroundFilters.initial(colorScheme: .light)
|
||||
|
||||
/// One seed per hue, in wheel order — minted on appear and re-minted by Reroll. A filter change
|
||||
/// leaves these alone (same geometry, new treatment); Reroll is the one gesture that changes them
|
||||
/// (new geometry).
|
||||
@State private var seeds: [FacetsRecipe.Hue: UInt64] = [:]
|
||||
|
||||
/// The carousel's previews, keyed by hue — absent until the render for the current
|
||||
/// `(filters, seeds)` pair lands, which is what the placeholder chip is for.
|
||||
@State private var previews: [FacetsRecipe.Hue: CGImage] = [:]
|
||||
|
||||
/// Whether a swatch's 3072px render is in flight — every swatch disables and the header grows a
|
||||
/// small spinner for the duration, so a second click cannot race the first.
|
||||
@State private var isApplying = false
|
||||
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
private let swatchWidth: CGFloat = 96
|
||||
private var swatchHeight: CGFloat { (swatchWidth * 10 / 16).rounded() }
|
||||
private let swatchCornerRadius: CGFloat = 6
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Always the board, whatever is selected. The ⌥⌘S anchor is the selection-aware one
|
||||
// ("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 ▸ Controls: "the
|
||||
// board popover's target is the board itself"). No symbol section — the inline
|
||||
// `SymbolPicker` beside the rename field above owns the board glyph.
|
||||
StyleEditorView(store: store, recents: recents, target: .board, showsSymbols: false)
|
||||
|
||||
Divider()
|
||||
|
||||
generatedSection
|
||||
.padding(inset)
|
||||
}
|
||||
.onAppear {
|
||||
filters.tone = BoardBackgroundFilters.defaultTone(colorScheme: colorScheme)
|
||||
if seeds.isEmpty { seeds = Self.mintSeeds() }
|
||||
}
|
||||
.task(id: previewKey) {
|
||||
await renderPreviews()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generated section
|
||||
|
||||
private var generatedSection: some View {
|
||||
VStack(alignment: .leading, spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
|
||||
HStack(spacing: 6) {
|
||||
sectionHeader("Generated")
|
||||
if isApplying {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel("Applying")
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
reroll()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityLabel("New variations")
|
||||
}
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 8, verticalSpacing: 6) {
|
||||
filterRow("Tone", selection: $filters.tone) { $0 == .light ? "Light" : "Dark" }
|
||||
filterRow("Colors", selection: $filters.colors, label: colorsLabel)
|
||||
filterRow("Mesh", selection: $filters.mesh, label: meshLabel)
|
||||
filterRow("Saturation", selection: $filters.saturation, label: saturationLabel)
|
||||
}
|
||||
|
||||
carousel
|
||||
}
|
||||
.font(.callout)
|
||||
// The whole section disables as one surface under the read-only lock — the same coarse rule
|
||||
// `StyleEditorView` applies to itself just above: an editor whose gestures would be refused
|
||||
// should not look available, and there is nothing here worth half-enabling (a filter nobody
|
||||
// can commit is not a useful control to leave live).
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
|
||||
/// One filter: a trailing-aligned label — matching the Info tab's row shape — beside a compact
|
||||
/// segmented picker. The label is hidden from VoiceOver; the picker's own title (identical text)
|
||||
/// is what it announces, so nothing is read twice.
|
||||
private func filterRow<Value>(
|
||||
_ title: String,
|
||||
selection: Binding<Value>,
|
||||
label: @escaping (Value) -> String
|
||||
) -> some View where Value: Hashable, Value: CaseIterable, Value.AllCases: RandomAccessCollection {
|
||||
GridRow {
|
||||
Text(title)
|
||||
.foregroundStyle(.secondary)
|
||||
.gridColumnAlignment(.trailing)
|
||||
.accessibilityHidden(true)
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(Array(Value.allCases), id: \.self) { value in
|
||||
Text(label(value)).tag(value)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private func colorsLabel(_ value: FacetsRecipe.Strategy) -> String {
|
||||
switch value {
|
||||
case .mono: "Mono"
|
||||
case .duo: "Duo"
|
||||
case .trio: "Trio"
|
||||
}
|
||||
}
|
||||
|
||||
private func meshLabel(_ value: FacetsRecipe.Density) -> String {
|
||||
switch value {
|
||||
case .coarse: "Coarse"
|
||||
case .medium: "Medium"
|
||||
case .fine: "Fine"
|
||||
}
|
||||
}
|
||||
|
||||
private func saturationLabel(_ value: FacetsRecipe.Saturation) -> String {
|
||||
switch value {
|
||||
case .soft: "Soft"
|
||||
case .mid: "Mid"
|
||||
case .rich: "Rich"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Carousel
|
||||
|
||||
private var carousel: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
|
||||
ForEach(FacetsRecipe.Hue.allCases, id: \.self) { hue in
|
||||
swatch(hue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func swatch(_ hue: FacetsRecipe.Hue) -> some View {
|
||||
Button {
|
||||
apply(hue)
|
||||
} label: {
|
||||
swatchFace(hue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Layered on top of the section's own disabling: this half additionally freezes every swatch
|
||||
// for the one gesture already running, so a second click cannot race the first's write.
|
||||
.disabled(isApplying)
|
||||
.opacity(isApplying ? 0.6 : 1)
|
||||
.help(hue.displayName)
|
||||
.accessibilityLabel("\(hue.displayName) — set generated background")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func swatchFace(_ hue: FacetsRecipe.Hue) -> some View {
|
||||
Group {
|
||||
if let image = previews[hue] {
|
||||
Image(decorative: image, scale: 1)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
// The pending placeholder: the recipe's own primary colour, so the strip reads as
|
||||
// "still painting this picture" rather than as a hole — and is already the right
|
||||
// colour if the render never manages to beat a quick reroll.
|
||||
chip(hue)
|
||||
}
|
||||
}
|
||||
.frame(width: swatchWidth, height: swatchHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: swatchCornerRadius))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: swatchCornerRadius)
|
||||
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
|
||||
)
|
||||
}
|
||||
|
||||
private func chip(_ hue: FacetsRecipe.Hue) -> Color {
|
||||
guard let seed = seeds[hue] else { return Color(nsColor: .textBackgroundColor) }
|
||||
let components = filters.recipe(hue: hue, seed: seed).primaryColor.components
|
||||
return Color(red: components.red, green: components.green, blue: components.blue)
|
||||
}
|
||||
|
||||
// MARK: - Rendering and applying
|
||||
|
||||
private struct PreviewKey: Equatable {
|
||||
var filters: BoardBackgroundFilters
|
||||
var seeds: [FacetsRecipe.Hue: UInt64]
|
||||
}
|
||||
|
||||
private var previewKey: PreviewKey { PreviewKey(filters: filters, seeds: seeds) }
|
||||
|
||||
/// The strip's eight previews, off the main actor — `FacetsGenerator.render` is pure, so this is
|
||||
/// exactly the render `apply(_:)` below would do at 3072px, just smaller and for every hue at
|
||||
/// once. Re-runs whenever `previewKey` changes (`.task(id:)`), which a filter edit or a Reroll
|
||||
/// both do — the same detach-and-await shape `BoardInfoTabView`'s disk walk uses.
|
||||
private func renderPreviews() async {
|
||||
previews = [:]
|
||||
let filters = self.filters
|
||||
let seeds = self.seeds
|
||||
let rendered = await Task.detached(priority: .utility) { () -> [FacetsRecipe.Hue: CGImage] in
|
||||
var rendered: [FacetsRecipe.Hue: CGImage] = [:]
|
||||
for hue in FacetsRecipe.Hue.allCases {
|
||||
guard let seed = seeds[hue] else { continue }
|
||||
if let image = FacetsGenerator.render(recipe: filters.recipe(hue: hue, seed: seed), pixelWidth: 384) {
|
||||
rendered[hue] = image
|
||||
}
|
||||
}
|
||||
return rendered
|
||||
}.value
|
||||
// `.task(id:)` cancels this task when the key moves on, but cancellation is cooperative and
|
||||
// the detached render finishes regardless — without this gate a slow stale strip could land
|
||||
// *after* the newer key's own renders and quietly replace them.
|
||||
guard !Task.isCancelled else { return }
|
||||
previews = rendered
|
||||
}
|
||||
|
||||
/// A swatch, clicked: the same recipe the preview showed, rendered at the file's own width and
|
||||
/// written through the one gesture every generated background lands through
|
||||
/// (`BoardStore.applyGeneratedBackground`). Failures — a `nil` render, a refused write under the
|
||||
/// lock — leave the board exactly as it was; the write path's own banners cover the write half.
|
||||
private func apply(_ hue: FacetsRecipe.Hue) {
|
||||
guard let seed = seeds[hue] else { return }
|
||||
let recipe = filters.recipe(hue: hue, seed: seed)
|
||||
isApplying = true
|
||||
Task {
|
||||
let data = await Task.detached(priority: .userInitiated) {
|
||||
FacetsGenerator.pngData(recipe: recipe, pixelWidth: 3072)
|
||||
}.value
|
||||
if let data {
|
||||
_ = store.applyGeneratedBackground(png: data, colorHex: recipe.primaryColorHex)
|
||||
}
|
||||
isApplying = false
|
||||
}
|
||||
}
|
||||
|
||||
private static func mintSeeds() -> [FacetsRecipe.Hue: UInt64] {
|
||||
Dictionary(uniqueKeysWithValues: FacetsRecipe.Hue.allCases.map { ($0, UInt64.random(in: UInt64.min...UInt64.max)) })
|
||||
}
|
||||
|
||||
private func reroll() {
|
||||
seeds = Self.mintSeeds()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hue display names
|
||||
|
||||
private extension FacetsRecipe.Hue {
|
||||
/// The wheel's own names, capitalized for the carousel's `.help` and accessibility label — the
|
||||
/// same eight words 03-board-ui.md's faceted-gallery notes use.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .clay: "Clay"
|
||||
case .amber: "Amber"
|
||||
case .olive: "Olive"
|
||||
case .forest: "Forest"
|
||||
case .teal: "Teal"
|
||||
case .sky: "Sky"
|
||||
case .iris: "Iris"
|
||||
case .rose: "Rose"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// **The board popover's Theme tab** (03-board-ui.md § Board popover ▸ Theme tab, settled 2026-08-07)
|
||||
/// — reworked, same day, from the Background tab it replaces: a "Solid color" vs "Pattern" picker
|
||||
/// over one shared Tone/Saturation state, rather than a manual colour grid stacked over a
|
||||
/// generated-only picker.
|
||||
///
|
||||
/// ### No manual grid here
|
||||
///
|
||||
/// The `StyleEditorView` embed this tab carried at first — the palette grid, its own "Board"
|
||||
/// subtitle, the None well — is gone. Manual board styling stays reachable through **Style… ⌥⌘S**
|
||||
/// (`StyleEditorPopover`), the anchor every other target already uses; nothing is lost, and this tab
|
||||
/// no longer says "Board" about a surface that is, self-evidently, this board's own popover.
|
||||
///
|
||||
/// ### Solid color and Pattern share one filters struct
|
||||
///
|
||||
/// `BoardThemeFilters` carries all four axes — tone, hue strategy, mesh density, saturation — but
|
||||
/// Solid color only ever reads two of them (tone, saturation): a solid swatch is a `FacetsRecipe`'s
|
||||
/// `primaryColor` at the filters' own tone/saturation level, seed-independent by construction
|
||||
/// (`FacetsRecipe.primaryColor` never reads `seed`). Switching modes leaves tone and saturation
|
||||
/// exactly where they were — a picker that reset them on every mode flip would make "Pattern, then
|
||||
/// back to Solid" lose a choice nobody asked to undo.
|
||||
///
|
||||
/// ### One carousel, two fills
|
||||
///
|
||||
/// The eight-hue strip is one component in both modes: Pattern's swatches are the rendered mesh
|
||||
/// previews this tab has always shown (`FacetsGenerator.render`, 384px); Solid's are flat fills of
|
||||
/// the same eight hues, in the same wheel order, at the same tone/saturation. **Chevrons page it** —
|
||||
/// `chevron.compact.left`/`.right`, tall and narrow, flanking the scroll window — because eight
|
||||
/// swatches at this width run past the popover's edge and a strip with no other way to say "there is
|
||||
/// more here" than a fade nobody can click is not a discoverable one.
|
||||
///
|
||||
/// Every Pattern swatch previews the exact recipe a click would apply — same filters, same seed, only
|
||||
/// the pixel width differs (384 for the strip, 3072 for the file) — so "what's clicked is what lands"
|
||||
/// (`FacetsGenerator`'s own claim) still holds. A Solid swatch's claim is stronger still: there is no
|
||||
/// seed to differ on, so the swatch *is* the colour, exactly.
|
||||
|
||||
// MARK: - Mode
|
||||
|
||||
/// **Solid color vs Pattern** — the tab's top control (`BoardThemeTabView`). Raw values are the
|
||||
/// segmented picker's own labels, so this enum needs no separate label function the way the filter
|
||||
/// rows below do.
|
||||
enum BoardThemeMode: String, CaseIterable, Identifiable, Hashable {
|
||||
case solid = "Solid color"
|
||||
case pattern = "Pattern"
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
// MARK: - Filters
|
||||
|
||||
/// **The Theme tab's filter state**, and the pure mapping from it (plus a hue and a seed) to a
|
||||
/// `FacetsRecipe` — pulled out of the view so the default-tone rule and the recipe assembly are each
|
||||
/// assertable without a popover on screen (`BoardThemeFiltersTests`).
|
||||
///
|
||||
/// Shared by both modes (`BoardThemeMode`): Solid color reads `tone` and `saturation` alone, Pattern
|
||||
/// reads all four. A mode switch never resets this struct — that is what keeps a chosen tone and
|
||||
/// saturation live across "Pattern, then Solid, then Pattern again".
|
||||
struct BoardThemeFilters: Equatable {
|
||||
|
||||
var tone: FacetsRecipe.Tone
|
||||
var colors: FacetsRecipe.Strategy
|
||||
var mesh: FacetsRecipe.Density
|
||||
var saturation: FacetsRecipe.Saturation
|
||||
|
||||
/// The picker's opening state: mono colours, medium mesh, mid saturation always — only tone
|
||||
/// follows the system, which is `defaultTone(colorScheme:)`'s own job.
|
||||
static func initial(colorScheme: ColorScheme) -> BoardThemeFilters {
|
||||
BoardThemeFilters(
|
||||
tone: defaultTone(colorScheme: colorScheme), colors: .mono, mesh: .medium, saturation: .mid
|
||||
)
|
||||
}
|
||||
|
||||
/// Light appearance opens on Tone Light, dark on Tone Dark — read once, at first appearance, so a
|
||||
/// picker opened on a dark-mode Mac starts on swatches that read correctly against the popover
|
||||
/// around them rather than ones chosen for the other appearance. `.light` covers every
|
||||
/// `ColorScheme` case but `.dark` — there is no third case today.
|
||||
static func defaultTone(colorScheme: ColorScheme) -> FacetsRecipe.Tone {
|
||||
colorScheme == .dark ? .dark : .light
|
||||
}
|
||||
|
||||
/// One hue's recipe under these filters and a given seed — the whole of "click a swatch, get a
|
||||
/// board". Pattern's clicks pass a minted seed; Solid's pass `0` and never look at it
|
||||
/// (`FacetsRecipe.primaryColor` is seed-independent).
|
||||
func recipe(hue: FacetsRecipe.Hue, seed: UInt64) -> FacetsRecipe {
|
||||
FacetsRecipe(hue: hue, strategy: colors, density: mesh, tone: tone, saturation: saturation, seed: seed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The tab
|
||||
|
||||
struct BoardThemeTabView: View {
|
||||
|
||||
let store: BoardStore
|
||||
|
||||
/// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s own
|
||||
/// parameter — everything here pads by this amount instead of restating the derivation.
|
||||
let inset: CGFloat
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
/// Solid color vs Pattern. Defaulted in `init`, not `onAppear`: unlike the tone default below,
|
||||
/// which needs the colour-scheme environment and so can only be read once the view is actually on
|
||||
/// screen, `store.snapshot` is already loaded the moment this view is built, so there is no reason
|
||||
/// to let the picker flash open on one mode and settle on another a frame later.
|
||||
@State private var mode: BoardThemeMode
|
||||
|
||||
@State private var filters = BoardThemeFilters.initial(colorScheme: .light)
|
||||
|
||||
/// One seed per hue, in wheel order — minted on appear and re-minted by Reroll. A filter change
|
||||
/// leaves these alone (same geometry, new treatment); Reroll is the one gesture that changes them
|
||||
/// (new geometry). Solid color never reads this — its swatches are seed-independent by
|
||||
/// construction — but the dictionary is still minted eagerly so Pattern has seeds ready the
|
||||
/// instant a user switches to it.
|
||||
@State private var seeds: [FacetsRecipe.Hue: UInt64] = [:]
|
||||
|
||||
/// The carousel's Pattern previews, keyed by hue — absent until the render for the current
|
||||
/// `(filters, seeds)` pair lands, which is what the placeholder chip is for. Unused in Solid mode.
|
||||
@State private var previews: [FacetsRecipe.Hue: CGImage] = [:]
|
||||
|
||||
/// Whether a Pattern swatch's 3072px render is in flight — every swatch disables and the header
|
||||
/// grows a small spinner for the duration, so a second click cannot race the first. Solid applies
|
||||
/// synchronously (there is no picture to render), so this never goes true for a Solid click.
|
||||
@State private var isApplying = false
|
||||
|
||||
/// The carousel's paging position, in swatch indices — what the chevrons move and what disables
|
||||
/// them at either end of the eight-hue strip (`carousel`).
|
||||
@State private var scrollIndex = 0
|
||||
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
private let swatchWidth: CGFloat = 96
|
||||
private var swatchHeight: CGFloat { (swatchWidth * 10 / 16).rounded() }
|
||||
private let swatchCornerRadius: CGFloat = 6
|
||||
/// The chevrons' own width — narrow enough to read as a paging control rather than a third
|
||||
/// swatch, per `chevron.compact`'s own "tall and skinny" form.
|
||||
private let chevronWidth: CGFloat = 15
|
||||
private var lastSwatchIndex: Int { FacetsRecipe.Hue.allCases.count - 1 }
|
||||
|
||||
init(store: BoardStore, inset: CGFloat) {
|
||||
self.store = store
|
||||
self.inset = inset
|
||||
// The simplest honest default: a board whose image *is* this generator's own output opens on
|
||||
// Pattern, since that is the surface that made it; every other board — no image, or a
|
||||
// hand-placed one the app does not own — opens on Solid color, which is also the mode that
|
||||
// never overwrites a foreign picture by accident.
|
||||
_mode = State(initialValue: store.snapshot.backgroundImage.value == FacetsGenerator.fileName ? .pattern : .solid)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
|
||||
modePicker
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 8, verticalSpacing: 6) {
|
||||
filterRow("Tone", selection: $filters.tone) { $0 == .light ? "Light" : "Dark" }
|
||||
if mode == .pattern {
|
||||
filterRow("Colors", selection: $filters.colors, label: colorsLabel)
|
||||
filterRow("Mesh", selection: $filters.mesh, label: meshLabel)
|
||||
}
|
||||
filterRow("Saturation", selection: $filters.saturation, label: saturationLabel)
|
||||
}
|
||||
|
||||
carouselAccessories
|
||||
carousel
|
||||
}
|
||||
.padding(inset)
|
||||
.font(.callout)
|
||||
// The whole tab disables as one surface under the read-only lock — the same coarse rule
|
||||
// `StyleEditorView` applies to itself: an editor whose gestures would be refused should not
|
||||
// look available, and there is nothing here worth half-enabling.
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
.onAppear {
|
||||
filters.tone = BoardThemeFilters.defaultTone(colorScheme: colorScheme)
|
||||
if seeds.isEmpty { seeds = Self.mintSeeds() }
|
||||
}
|
||||
.task(id: previewKey) {
|
||||
await renderPreviews()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mode picker
|
||||
|
||||
private var modePicker: some View {
|
||||
Picker("Theme", selection: $mode) {
|
||||
ForEach(BoardThemeMode.allCases) { value in
|
||||
Text(value.rawValue).tag(value)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
// MARK: - Filter rows
|
||||
|
||||
/// One filter: a trailing-aligned label — matching the Info tab's row shape — beside a compact
|
||||
/// segmented picker. The label is hidden from VoiceOver; the picker's own title (identical text)
|
||||
/// is what it announces, so nothing is read twice.
|
||||
private func filterRow<Value>(
|
||||
_ title: String,
|
||||
selection: Binding<Value>,
|
||||
label: @escaping (Value) -> String
|
||||
) -> some View where Value: Hashable, Value: CaseIterable, Value.AllCases: RandomAccessCollection {
|
||||
GridRow {
|
||||
Text(title)
|
||||
.foregroundStyle(.secondary)
|
||||
.gridColumnAlignment(.trailing)
|
||||
.accessibilityHidden(true)
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(Array(Value.allCases), id: \.self) { value in
|
||||
Text(label(value)).tag(value)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private func colorsLabel(_ value: FacetsRecipe.Strategy) -> String {
|
||||
switch value {
|
||||
case .mono: "Mono"
|
||||
case .duo: "Duo"
|
||||
case .trio: "Trio"
|
||||
}
|
||||
}
|
||||
|
||||
private func meshLabel(_ value: FacetsRecipe.Density) -> String {
|
||||
switch value {
|
||||
case .coarse: "Coarse"
|
||||
case .medium: "Medium"
|
||||
case .fine: "Fine"
|
||||
}
|
||||
}
|
||||
|
||||
private func saturationLabel(_ value: FacetsRecipe.Saturation) -> String {
|
||||
switch value {
|
||||
case .soft: "Soft"
|
||||
case .mid: "Mid"
|
||||
case .rich: "Rich"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Carousel accessories
|
||||
|
||||
/// The spinner and the Reroll button — Reroll only matters in Pattern mode (Solid color has no
|
||||
/// geometry to reroll), so it is hidden rather than disabled in Solid mode, on the same "nothing
|
||||
/// here worth half-enabling" reasoning the tab's own `.disabled` applies.
|
||||
private var carouselAccessories: some View {
|
||||
HStack(spacing: 6) {
|
||||
Spacer()
|
||||
if isApplying {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel("Applying")
|
||||
}
|
||||
if mode == .pattern {
|
||||
Button {
|
||||
reroll()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityLabel("New variations")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Carousel
|
||||
|
||||
/// The eight-hue strip, flanked by paging chevrons — one component for both modes
|
||||
/// (`swatchFace(_:)` is the only thing that reads `mode`).
|
||||
///
|
||||
/// **Chevrons page by index, not by offset.** `scrollIndex` names the swatch nearest the leading
|
||||
/// edge; a press moves it three hues (roughly three swatch widths, since consecutive swatches are
|
||||
/// laid out one `swatchWidth + spacing` apart) and clamps at either end of the wheel, which is
|
||||
/// also what disables a chevron that has nothing left to reveal. `ScrollViewProxy.scrollTo` — not
|
||||
/// the newer `scrollPosition(id:)` binding — because a proxy scroll is a one-shot "go there" the
|
||||
/// tracked index already drives, while the binding form exists to *report back* which item is
|
||||
/// visible, a question this carousel never asks.
|
||||
private var carousel: some View {
|
||||
ScrollViewReader { proxy in
|
||||
HStack(spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
|
||||
chevron(
|
||||
systemName: "chevron.compact.left",
|
||||
accessibilityLabel: "Earlier hues",
|
||||
isDisabled: scrollIndex <= 0
|
||||
) { page(by: -3, proxy: proxy) }
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
|
||||
ForEach(Array(FacetsRecipe.Hue.allCases.enumerated()), id: \.offset) { index, hue in
|
||||
swatch(hue)
|
||||
.id(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chevron(
|
||||
systemName: "chevron.compact.right",
|
||||
accessibilityLabel: "Later hues",
|
||||
isDisabled: scrollIndex >= lastSwatchIndex
|
||||
) { page(by: 3, proxy: proxy) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One paging button — tall and narrow, full swatch height, vertically centered beside the scroll
|
||||
/// window. `chevron.compact.left`/`.right` are SF Symbols' own tall-skinny variants, so no custom
|
||||
/// shape is needed to get the form the design asks for.
|
||||
private func chevron(
|
||||
systemName: String,
|
||||
accessibilityLabel: String,
|
||||
isDisabled: Bool,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.frame(width: chevronWidth, height: swatchHeight)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(isDisabled)
|
||||
.help(accessibilityLabel)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
}
|
||||
|
||||
/// Moves `scrollIndex` by `delta`, clamped to the strip's ends, and animates the scroll view to
|
||||
/// the swatch that lands on — a no-op past either end, which is what the chevron's own `disabled`
|
||||
/// state already promises but costs nothing to restate here.
|
||||
private func page(by delta: Int, proxy: ScrollViewProxy) {
|
||||
let newIndex = min(max(scrollIndex + delta, 0), lastSwatchIndex)
|
||||
guard newIndex != scrollIndex else { return }
|
||||
scrollIndex = newIndex
|
||||
withAnimation {
|
||||
proxy.scrollTo(newIndex, anchor: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func swatch(_ hue: FacetsRecipe.Hue) -> some View {
|
||||
Button {
|
||||
apply(hue)
|
||||
} label: {
|
||||
swatchFace(hue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Layered on top of the tab's own disabling: this half additionally freezes every swatch for
|
||||
// the one Pattern render already running, so a second click cannot race the first's write.
|
||||
// Always `false` in Solid mode, where nothing is ever in flight.
|
||||
.disabled(isApplying)
|
||||
.opacity(isApplying ? 0.6 : 1)
|
||||
.help(hue.displayName)
|
||||
.accessibilityLabel(accessibilityLabel(for: hue))
|
||||
}
|
||||
|
||||
private func accessibilityLabel(for hue: FacetsRecipe.Hue) -> String {
|
||||
switch mode {
|
||||
case .solid: "\(hue.displayName) — set solid background"
|
||||
case .pattern: "\(hue.displayName) — set generated background"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func swatchFace(_ hue: FacetsRecipe.Hue) -> some View {
|
||||
Group {
|
||||
switch mode {
|
||||
case .solid:
|
||||
solidColor(hue)
|
||||
case .pattern:
|
||||
if let image = previews[hue] {
|
||||
Image(decorative: image, scale: 1)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
// The pending placeholder: the recipe's own primary colour, so the strip reads as
|
||||
// "still painting this picture" rather than as a hole — and is already the right
|
||||
// colour if the render never manages to beat a quick reroll.
|
||||
chip(hue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: swatchWidth, height: swatchHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: swatchCornerRadius))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: swatchCornerRadius)
|
||||
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
|
||||
)
|
||||
}
|
||||
|
||||
/// A Solid color swatch's fill — the filters' own tone/saturation level at this hue, seed `0` and
|
||||
/// seed-independent (`FacetsRecipe.primaryColor` never reads it): the same colour every time, for
|
||||
/// the same filters and hue, which is the whole of what makes a flat swatch honest about what
|
||||
/// clicking it applies.
|
||||
private func solidColor(_ hue: FacetsRecipe.Hue) -> Color {
|
||||
let components = filters.recipe(hue: hue, seed: 0).primaryColor.components
|
||||
return Color(red: components.red, green: components.green, blue: components.blue)
|
||||
}
|
||||
|
||||
private func chip(_ hue: FacetsRecipe.Hue) -> Color {
|
||||
guard let seed = seeds[hue] else { return Color(nsColor: .textBackgroundColor) }
|
||||
let components = filters.recipe(hue: hue, seed: seed).primaryColor.components
|
||||
return Color(red: components.red, green: components.green, blue: components.blue)
|
||||
}
|
||||
|
||||
// MARK: - Rendering and applying
|
||||
|
||||
private struct PreviewKey: Equatable {
|
||||
var filters: BoardThemeFilters
|
||||
var seeds: [FacetsRecipe.Hue: UInt64]
|
||||
}
|
||||
|
||||
private var previewKey: PreviewKey { PreviewKey(filters: filters, seeds: seeds) }
|
||||
|
||||
/// The strip's eight Pattern previews, off the main actor — `FacetsGenerator.render` is pure, so
|
||||
/// this is exactly the render `applyPattern(_:)` below would do at 3072px, just smaller and for
|
||||
/// every hue at once. Re-runs whenever `previewKey` changes (`.task(id:)`), which a filter edit or
|
||||
/// a Reroll both do — the same detach-and-await shape `BoardInfoTabView`'s disk walk uses.
|
||||
///
|
||||
/// Runs regardless of `mode`: tone and saturation are shared, so a filter edited while Solid color
|
||||
/// is showing still has to leave Pattern's previews correct for the instant the user switches back
|
||||
/// to it, and eight 384px renders are cheap enough that gating them on the visible mode would save
|
||||
/// little for the complexity it would cost.
|
||||
private func renderPreviews() async {
|
||||
previews = [:]
|
||||
let filters = self.filters
|
||||
let seeds = self.seeds
|
||||
let rendered = await Task.detached(priority: .utility) { () -> [FacetsRecipe.Hue: CGImage] in
|
||||
var rendered: [FacetsRecipe.Hue: CGImage] = [:]
|
||||
for hue in FacetsRecipe.Hue.allCases {
|
||||
guard let seed = seeds[hue] else { continue }
|
||||
if let image = FacetsGenerator.render(recipe: filters.recipe(hue: hue, seed: seed), pixelWidth: 384) {
|
||||
rendered[hue] = image
|
||||
}
|
||||
}
|
||||
return rendered
|
||||
}.value
|
||||
// `.task(id:)` cancels this task when the key moves on, but cancellation is cooperative and
|
||||
// the detached render finishes regardless — without this gate a slow stale strip could land
|
||||
// *after* the newer key's own renders and quietly replace them.
|
||||
guard !Task.isCancelled else { return }
|
||||
previews = rendered
|
||||
}
|
||||
|
||||
/// A swatch, clicked — Solid color writes a colour and clears the image subkey synchronously;
|
||||
/// Pattern renders and writes a picture off the main actor, exactly as this tab always has.
|
||||
private func apply(_ hue: FacetsRecipe.Hue) {
|
||||
switch mode {
|
||||
case .solid:
|
||||
applySolid(hue)
|
||||
case .pattern:
|
||||
applyPattern(hue)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Solid color half: no render, no detour off the main actor — just the colour this hue's
|
||||
/// level names, through the one gesture every solid background lands through
|
||||
/// (`BoardStore.applySolidBackground`). A refused write under the lock leaves the board exactly as
|
||||
/// it was; the write path's own banners cover that half.
|
||||
private func applySolid(_ hue: FacetsRecipe.Hue) {
|
||||
let colorHex = filters.recipe(hue: hue, seed: 0).primaryColorHex
|
||||
_ = store.applySolidBackground(colorHex: colorHex)
|
||||
}
|
||||
|
||||
/// The Pattern half: the same recipe the preview showed, rendered at the file's own width and
|
||||
/// written through the one gesture every generated background lands through
|
||||
/// (`BoardStore.applyGeneratedBackground`). Failures — a `nil` render, a refused write under the
|
||||
/// lock — leave the board exactly as it was; the write path's own banners cover the write half.
|
||||
private func applyPattern(_ hue: FacetsRecipe.Hue) {
|
||||
guard let seed = seeds[hue] else { return }
|
||||
let recipe = filters.recipe(hue: hue, seed: seed)
|
||||
isApplying = true
|
||||
Task {
|
||||
let data = await Task.detached(priority: .userInitiated) {
|
||||
FacetsGenerator.pngData(recipe: recipe, pixelWidth: 3072)
|
||||
}.value
|
||||
if let data {
|
||||
_ = store.applyGeneratedBackground(png: data, colorHex: recipe.primaryColorHex)
|
||||
}
|
||||
isApplying = false
|
||||
}
|
||||
}
|
||||
|
||||
private static func mintSeeds() -> [FacetsRecipe.Hue: UInt64] {
|
||||
Dictionary(uniqueKeysWithValues: FacetsRecipe.Hue.allCases.map { ($0, UInt64.random(in: UInt64.min...UInt64.max)) })
|
||||
}
|
||||
|
||||
private func reroll() {
|
||||
seeds = Self.mintSeeds()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hue display names
|
||||
|
||||
private extension FacetsRecipe.Hue {
|
||||
/// The wheel's own names, capitalized for the carousel's `.help` and accessibility label — the
|
||||
/// same eight words 03-board-ui.md's faceted-gallery notes use.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .clay: "Clay"
|
||||
case .amber: "Amber"
|
||||
case .olive: "Olive"
|
||||
case .forest: "Forest"
|
||||
case .teal: "Teal"
|
||||
case .sky: "Sky"
|
||||
case .iris: "Iris"
|
||||
case .rose: "Rose"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,15 @@ import SwiftUI
|
||||
/// widget in the window's titlebar that opens it.
|
||||
///
|
||||
/// **Restructuring in progress (2026-08-07): the popover is going tabbed.** The symbol/name header
|
||||
/// stays at the top; below it sit three tabs — **Info**, **Background**, **Git** — each a settings
|
||||
/// stays at the top; below it sit three tabs — **Info**, **Theme**, **Git** — each a settings
|
||||
/// surface for one aspect of board configuration, each settled in its own dedicated design session.
|
||||
/// **Info and Background are settled** (both 2026-08-07 — `BoardInfoTabView`, the metrics dossier;
|
||||
/// `BoardBackgroundTabView`, the re-homed style editor plus the generated-background picker); Git
|
||||
/// remains deliberately empty until its own session. The former body's mode-aware git section is
|
||||
/// unrendered for the interim but parked in this file (see the "Parked" mark below), because its
|
||||
/// pure seams (`BoardGitSection`, the posture notes, `BoardSettingsAvailability`'s caller) are
|
||||
/// settled design and will rehome into the Git tab once that session rules.
|
||||
/// **Info and Theme are settled** (both 2026-08-07 — `BoardInfoTabView`, the metrics dossier;
|
||||
/// `BoardThemeTabView`, the Solid color / Pattern picker — the Background tab's original name, before
|
||||
/// the same session widened it past the generated-only picker and folded manual styling back out to
|
||||
/// Style… ⌥⌘S); Git remains deliberately empty until its own session. The former body's mode-aware
|
||||
/// git section is unrendered for the interim but parked in this file (see the "Parked" mark below),
|
||||
/// because its pure seams (`BoardGitSection`, the posture notes, `BoardSettingsAvailability`'s
|
||||
/// caller) are settled design and will rehome into the Git tab once that session rules.
|
||||
///
|
||||
/// ### One home, deliberately
|
||||
///
|
||||
@@ -257,13 +258,13 @@ func boardInfoTitlebarAccessory(
|
||||
// MARK: - Tabs
|
||||
|
||||
/// The popover's three aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
|
||||
/// restructure): **Info**, **Background**, **Git**. Info and Background are settled
|
||||
/// (`BoardInfoTabView`, `BoardBackgroundTabView`); Git is a placeholder — empty on purpose — until
|
||||
/// restructure): **Info**, **Theme**, **Git**. Info and Theme are settled
|
||||
/// (`BoardInfoTabView`, `BoardThemeTabView`); Git is a placeholder — empty on purpose — until
|
||||
/// its own dedicated design session, which then only has to fill its case in.
|
||||
enum BoardInfoTab: String, CaseIterable, Identifiable {
|
||||
|
||||
case info = "Info"
|
||||
case background = "Background"
|
||||
case theme = "Theme"
|
||||
case git = "Git"
|
||||
|
||||
var id: Self { self }
|
||||
@@ -276,7 +277,7 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
|
||||
///
|
||||
/// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit
|
||||
/// beside a card — kept through the restructure so the popover's footprint doesn't wander while Git
|
||||
/// is still a placeholder; both tabs settled so far (Info, Background) kept it too, so whether the
|
||||
/// is still a placeholder; both tabs settled so far (Info, Theme) kept it too, so whether the
|
||||
/// tabbed surface ever wants its own width remains open, but nothing has needed one yet.
|
||||
struct BoardInfoView: View {
|
||||
|
||||
@@ -375,16 +376,16 @@ struct BoardInfoView: View {
|
||||
.padding(.horizontal, inset)
|
||||
.padding(.top, inset)
|
||||
|
||||
// The selected tab's surface. Info and Background are settled (2026-08-07 —
|
||||
// `BoardInfoTabView`, `BoardBackgroundTabView`); Git stays a placeholder until its own
|
||||
// The selected tab's surface. Info and Theme are settled (2026-08-07 —
|
||||
// `BoardInfoTabView`, `BoardThemeTabView`); Git stays a placeholder until its own
|
||||
// session, holding a fixed height so an empty tab reads as a surface awaiting content
|
||||
// rather than a collapsed sliver — `Color.clear`, because an `EmptyView` inside a frame
|
||||
// renders nothing at all.
|
||||
switch tab {
|
||||
case .info:
|
||||
BoardInfoTabView(store: store, inset: inset)
|
||||
case .background:
|
||||
BoardBackgroundTabView(store: store, recents: recents, inset: inset)
|
||||
case .theme:
|
||||
BoardThemeTabView(store: store, inset: inset)
|
||||
case .git:
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
@@ -397,11 +398,14 @@ struct BoardInfoView: View {
|
||||
|
||||
// MARK: Parked pending the Git tab session (2026-08-07)
|
||||
//
|
||||
// Nothing below this mark renders today. The style-editor embed that once lived here has
|
||||
// rehomed to `BoardBackgroundTabView`; what is left is the git section — postures, notes, and
|
||||
// the Board Settings… row — waiting for the Git tab's own session. Parked rather than deleted
|
||||
// because every seam it hangs on is settled, test-pinned design (`BoardGitSectionTests`,
|
||||
// `BoardSettingsAvailabilityTests`), and the tab sessions rehome surfaces, not rulings.
|
||||
// Nothing below this mark renders today. The style-editor embed that once lived here briefly
|
||||
// rehomed to `BoardThemeTabView` and has since moved back out of the popover entirely — manual
|
||||
// board styling is reachable through Style… ⌥⌘S, and the Theme tab's Solid color / Pattern picker
|
||||
// covers the same ground its "Board" background grid did. What is left below is the git section —
|
||||
// postures, notes, and the Board Settings… row — waiting for the Git tab's own session. Parked
|
||||
// rather than deleted because every seam it hangs on is settled, test-pinned design
|
||||
// (`BoardGitSectionTests`, `BoardSettingsAvailabilityTests`), and the tab sessions rehome
|
||||
// surfaces, not rulings.
|
||||
|
||||
/// The popover's closing section, whichever of the six postures this board is in — see
|
||||
/// `BoardGitSection`.
|
||||
|
||||
Reference in New Issue
Block a user