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:
2026-08-07 18:03:06 -04:00
parent fb96e30df0
commit 3d231d6454
10 changed files with 895 additions and 440 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
**August 2026** **August 2026**
The board popover's new Background tab generates board backgrounds: filter by light or dark, color count, mesh density and saturation, then pick from eight hues. The board popover's new Theme tab dresses the board in a solid color or a generated pattern: filter by light or dark, colors and saturation, then pick from eight hues.
The board popover is now organized into three tabs — Info with the board's vital statistics, Background for styling, and Git. The board popover is now organized into three tabs — Info with the board's vital statistics, Theme for backgrounds, and Git.
The rubber band now highlights cards the moment it touches them, instead of lagging behind on large boards. The rubber band now highlights cards the moment it touches them, instead of lagging behind on large boards.
+3 -3
View File
@@ -41,7 +41,7 @@ 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. **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. One **style editor** component — a background palette grid and a curated symbol grid — presented from two anchors: **embedded** in the card window sidebar's Style section (05-card-window.md), 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 — 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 Style… popover carries both grids in full. (The board popover was briefly a third anchor — the background half embedded in its Theme tab — until the 2026-08-07 Theme rework made that tab preset-only; Style… with nothing selected is the board's manual grid now, and the popover's symbol picker beside the rename field still owns the board glyph.)
- **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-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. (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.) - **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.)
@@ -53,9 +53,9 @@ One **style editor** component — a background palette grid and a curated symbo
## Board popover ## Board popover
**Restructure 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 the settings surface for one aspect of board configuration, each designed in its own dedicated session (Git is an empty placeholder until its; Info and Background are settled). The bullets below describe the popover's pre-tab content; what of it rehomes into which tab — and where the git section lands — is the Git session's to settle. The rulings inside the bullets (rename semantics, the git postures, the popover/sheet split) stand; only their placement is in motion. **Restructure in progress (2026-08-07): the popover is going tabbed.** The symbol/name header stays at the top; below it sit three tabs — **Info**, **Theme**, **Git** — each the settings surface for one aspect of board configuration, each designed in its own dedicated session (Git is an empty placeholder until its; Info and Theme are settled). The bullets below describe the popover's pre-tab content; what of it rehomes into which tab — and where the git section lands — is the Git session's to settle. The rulings inside the bullets (rename semantics, the git postures, the popover/sheet split) stand; only their placement is in motion.
**The Background tab** (settled 2026-08-07, its dedicated session): two surfaces, manual then generated. The **Color** section is the re-homed style-editor embed — background half only, `showsSymbols: false`, exactly the pre-tab composition rule (§ Styling ▸ Controls: the popover's symbol picker beside the rename field owns the board glyph) — same write path, same recents, so the embed's re-homing changed its address and nothing else. The **Generated** section is the faceted-background picker (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery, the reviewed recipe): four segmented filters — Tone (defaults to the current appearance), Colors mono/duo/trio, Mesh coarse/medium/fine, Saturation soft/mid/rich — over a horizontal carousel of the eight wheel hues, one seed per hue, plus a Reroll button that re-mints the seeds (filters change the treatment, Reroll changes the geometry; the preview and the applied file share a seed, so what's clicked is what lands). Clicking a swatch renders the recipe at the 3072 px decode ceiling off-main and lands it in one write: the PNG into the board root as `facets.png` (Finder-ladder rename only when a foreign file owns the name), `background.image` pointed at it, and `background.color` set to the recipe's primary color — the underlay that stands in while the image decodes or if the file ever goes missing. **Backgrounds ship as static pixels, never live-rendered views** (the perf/sync ruling, 2026-08-07): the generator runs at pick time, the render loop only ever composites a decoded bitmap. Native undo restores the two fields, not the overwritten bytes — regenerating over our own PNG is destructive, documented, and accepted (the escape hatch remains the raw file). The whole tab disables under the read-only lock as one surface. **The Theme tab** (settled 2026-08-07, its dedicated session; reworked same day from the first "Background" cut — the style-editor embed was in, then ruled out): a **preset-only** surface — no palette grid, no "Board" subtitle, no manual controls (the manual surface is Style… with nothing selected; the raw file remains the image escape hatch). One **Solid color / Pattern** segmented choice at the top, then the filters that apply to the chosen kind, then one carousel of the eight wheel hues with tall skinny chevrons flanking it (compact-chevron paging buttons, ~three swatches per press, each disabling at its end of the strip). **Solid color** shows Tone (defaults to the current appearance) and Saturation only; the carousel holds flat swatches — each hue at the selected tonality's base saturation and lightness, exactly the primary color the matching facets recipe would write — and clicking one sets `background.color` and *removes* `background.image` in one write (the generated PNG stays on disk so undo can restore the field that pointed at it). **Pattern** is the faceted-background picker (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery, the reviewed recipe): four filters — Tone and Saturation shared with Solid, plus Colors mono/duo/trio and Mesh coarse/medium/fine — over rendered previews, one seed per hue, plus a Reroll button that re-mints the seeds (filters change the treatment, Reroll the geometry; preview and file share a seed, so what's clicked is what lands). Clicking a pattern swatch renders the recipe at the 3072 px decode ceiling off-main and lands it in one write: the PNG into the board root as `facets.png` (Finder-ladder rename only when a foreign file owns the name), `background.image` pointed at it, and `background.color` set to the recipe's primary color — the underlay that stands in while the image decodes or if the file ever goes missing. The mode opens on whichever kind the board currently wears (Pattern when `background.image` is the generated file, Solid otherwise). **Backgrounds ship as static pixels, never live-rendered views** (the perf/sync ruling, 2026-08-07): the generator runs at pick time, the render loop only ever composites a decoded bitmap. Native undo restores the two fields, not overwritten bytes — regenerating over our own PNG is destructive, documented, and accepted. The whole tab disables under the read-only lock as one surface.
### Info tab (settled 2026-08-07) ### Info tab (settled 2026-08-07)
+75
View File
@@ -2050,6 +2050,81 @@ public final class BoardStore: HealHost {
return true 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. /// 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 /// `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"
}
}
}
+24 -20
View File
@@ -5,14 +5,15 @@ import SwiftUI
/// widget in the window's titlebar that opens it. /// widget in the window's titlebar that opens it.
/// ///
/// **Restructuring in progress (2026-08-07): the popover is going tabbed.** The symbol/name header /// **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. /// 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; /// **Info and Theme are settled** (both 2026-08-07 `BoardInfoTabView`, the metrics dossier;
/// `BoardBackgroundTabView`, the re-homed style editor plus the generated-background picker); Git /// `BoardThemeTabView`, the Solid color / Pattern picker the Background tab's original name, before
/// remains deliberately empty until its own session. The former body's mode-aware git section is /// the same session widened it past the generated-only picker and folded manual styling back out to
/// unrendered for the interim but parked in this file (see the "Parked" mark below), because its /// Style S); Git remains deliberately empty until its own session. The former body's mode-aware
/// pure seams (`BoardGitSection`, the posture notes, `BoardSettingsAvailability`'s caller) are /// git section is unrendered for the interim but parked in this file (see the "Parked" mark below),
/// settled design and will rehome into the Git tab once that session rules. /// 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 /// ### One home, deliberately
/// ///
@@ -257,13 +258,13 @@ func boardInfoTitlebarAccessory(
// MARK: - Tabs // MARK: - Tabs
/// The popover's three aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab /// 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 /// restructure): **Info**, **Theme**, **Git**. Info and Theme are settled
/// (`BoardInfoTabView`, `BoardBackgroundTabView`); Git is a placeholder empty on purpose until /// (`BoardInfoTabView`, `BoardThemeTabView`); Git is a placeholder empty on purpose until
/// its own dedicated design session, which then only has to fill its case in. /// its own dedicated design session, which then only has to fill its case in.
enum BoardInfoTab: String, CaseIterable, Identifiable { enum BoardInfoTab: String, CaseIterable, Identifiable {
case info = "Info" case info = "Info"
case background = "Background" case theme = "Theme"
case git = "Git" case git = "Git"
var id: Self { self } 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 /// 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 /// 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. /// tabbed surface ever wants its own width remains open, but nothing has needed one yet.
struct BoardInfoView: View { struct BoardInfoView: View {
@@ -375,16 +376,16 @@ struct BoardInfoView: View {
.padding(.horizontal, inset) .padding(.horizontal, inset)
.padding(.top, inset) .padding(.top, inset)
// The selected tab's surface. Info and Background are settled (2026-08-07 // The selected tab's surface. Info and Theme are settled (2026-08-07
// `BoardInfoTabView`, `BoardBackgroundTabView`); Git stays a placeholder until its own // `BoardInfoTabView`, `BoardThemeTabView`); Git stays a placeholder until its own
// session, holding a fixed height so an empty tab reads as a surface awaiting content // 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 // rather than a collapsed sliver `Color.clear`, because an `EmptyView` inside a frame
// renders nothing at all. // renders nothing at all.
switch tab { switch tab {
case .info: case .info:
BoardInfoTabView(store: store, inset: inset) BoardInfoTabView(store: store, inset: inset)
case .background: case .theme:
BoardBackgroundTabView(store: store, recents: recents, inset: inset) BoardThemeTabView(store: store, inset: inset)
case .git: case .git:
Color.clear.frame(height: 120) Color.clear.frame(height: 120)
} }
@@ -397,11 +398,14 @@ struct BoardInfoView: View {
// MARK: Parked pending the Git tab session (2026-08-07) // 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 // Nothing below this mark renders today. The style-editor embed that once lived here briefly
// rehomed to `BoardBackgroundTabView`; what is left is the git section postures, notes, and // rehomed to `BoardThemeTabView` and has since moved back out of the popover entirely manual
// the Board Settings row waiting for the Git tab's own session. Parked rather than deleted // board styling is reachable through Style S, and the Theme tab's Solid color / Pattern picker
// because every seam it hangs on is settled, test-pinned design (`BoardGitSectionTests`, // covers the same ground its "Board" background grid did. What is left below is the git section
// `BoardSettingsAvailabilityTests`), and the tab sessions rehome surfaces, not rulings. // 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 /// The popover's closing section, whichever of the six postures this board is in see
/// `BoardGitSection`. /// `BoardGitSection`.
@@ -1,68 +0,0 @@
import SwiftUI
import Testing
@testable import Kanban
/// **`BoardBackgroundFilters`** the Background tab's generated picker: the default-tone rule and
/// the pure filters `FacetsRecipe` assembly, pinned the way the popover's other pure seams are
/// (`BoardInfoMetrics`, `BoardDiskFootprint` in `BoardInfoTabTests`). Everything else about the
/// carousel the strip's layout, the placeholder chip, the apply gesture is SwiftUI and
/// deliberately untested; `FacetsGeneratorTests` and `GeneratedBackgroundTests` already cover the
/// generator and the write path this feeds.
@Suite("Board popover ▸ Background tab filters")
struct BoardBackgroundFiltersTests {
@Test("Dark system appearance opens on Tone Dark, light opens on Tone Light")
func defaultToneFollowsTheSystem() {
#expect(BoardBackgroundFilters.defaultTone(colorScheme: .dark) == .dark)
#expect(BoardBackgroundFilters.defaultTone(colorScheme: .light) == .light)
}
@Test("The opening state is mono, medium, mid — only tone varies with the system")
func initialStateIsTheReviewedDefaults() {
let light = BoardBackgroundFilters.initial(colorScheme: .light)
#expect(light.tone == .light)
#expect(light.colors == .mono)
#expect(light.mesh == .medium)
#expect(light.saturation == .mid)
let dark = BoardBackgroundFilters.initial(colorScheme: .dark)
#expect(dark.tone == .dark)
#expect(dark.colors == .mono)
#expect(dark.mesh == .medium)
#expect(dark.saturation == .mid)
}
@Test("A hue and a seed assemble the exact recipe the filters describe")
func recipeAssemblesEveryAxis() {
let filters = BoardBackgroundFilters(tone: .dark, colors: .trio, mesh: .fine, saturation: .rich)
let recipe = filters.recipe(hue: .iris, seed: 0x5EED)
#expect(recipe.hue == .iris)
#expect(recipe.strategy == .trio)
#expect(recipe.density == .fine)
#expect(recipe.tone == .dark)
#expect(recipe.saturation == .rich)
#expect(recipe.seed == 0x5EED)
}
@Test("Two hues under the same filters and seed differ only in hue")
func onlyHueChangesAcrossTheWheel() {
let filters = BoardBackgroundFilters.initial(colorScheme: .light)
let sky = filters.recipe(hue: .sky, seed: 42)
let rose = filters.recipe(hue: .rose, seed: 42)
#expect(sky.hue == .sky)
#expect(rose.hue == .rose)
#expect(sky.strategy == rose.strategy)
#expect(sky.density == rose.density)
#expect(sky.tone == rose.tone)
#expect(sky.saturation == rose.saturation)
#expect(sky.seed == rose.seed)
}
@Test("The same filters and seed recipe identically — the preview/apply agreement the carousel depends on")
func sameInputsRecipeIdentically() {
let filters = BoardBackgroundFilters(tone: .light, colors: .duo, mesh: .coarse, saturation: .soft)
#expect(filters.recipe(hue: .forest, seed: 7) == filters.recipe(hue: .forest, seed: 7))
}
}
+117
View File
@@ -0,0 +1,117 @@
import SwiftUI
import Testing
@testable import Kanban
/// **`BoardThemeFilters`** and **`BoardThemeMode`** the Theme tab's shared filter state and its
/// Solid color / Pattern picker: the default-tone rule, the pure filters `FacetsRecipe` assembly,
/// and solid colour's seed-independence, pinned the way the popover's other pure seams are
/// (`BoardInfoMetrics`, `BoardDiskFootprint` in `BoardInfoTabTests`). Everything else about the
/// carousel the strip's layout, the placeholder chip, the chevron paging, the apply gesture is
/// SwiftUI and deliberately untested; `FacetsGeneratorTests` and `GeneratedBackgroundTests` already
/// cover the generator and the write paths this feeds.
@Suite("Board popover ▸ Theme tab filters")
struct BoardThemeFiltersTests {
@Test("Dark system appearance opens on Tone Dark, light opens on Tone Light")
func defaultToneFollowsTheSystem() {
#expect(BoardThemeFilters.defaultTone(colorScheme: .dark) == .dark)
#expect(BoardThemeFilters.defaultTone(colorScheme: .light) == .light)
}
@Test("The opening state is mono, medium, mid — only tone varies with the system")
func initialStateIsTheReviewedDefaults() {
let light = BoardThemeFilters.initial(colorScheme: .light)
#expect(light.tone == .light)
#expect(light.colors == .mono)
#expect(light.mesh == .medium)
#expect(light.saturation == .mid)
let dark = BoardThemeFilters.initial(colorScheme: .dark)
#expect(dark.tone == .dark)
#expect(dark.colors == .mono)
#expect(dark.mesh == .medium)
#expect(dark.saturation == .mid)
}
@Test("A hue and a seed assemble the exact recipe the filters describe")
func recipeAssemblesEveryAxis() {
let filters = BoardThemeFilters(tone: .dark, colors: .trio, mesh: .fine, saturation: .rich)
let recipe = filters.recipe(hue: .iris, seed: 0x5EED)
#expect(recipe.hue == .iris)
#expect(recipe.strategy == .trio)
#expect(recipe.density == .fine)
#expect(recipe.tone == .dark)
#expect(recipe.saturation == .rich)
#expect(recipe.seed == 0x5EED)
}
@Test("Two hues under the same filters and seed differ only in hue")
func onlyHueChangesAcrossTheWheel() {
let filters = BoardThemeFilters.initial(colorScheme: .light)
let sky = filters.recipe(hue: .sky, seed: 42)
let rose = filters.recipe(hue: .rose, seed: 42)
#expect(sky.hue == .sky)
#expect(rose.hue == .rose)
#expect(sky.strategy == rose.strategy)
#expect(sky.density == rose.density)
#expect(sky.tone == rose.tone)
#expect(sky.saturation == rose.saturation)
#expect(sky.seed == rose.seed)
}
@Test("The same filters and seed recipe identically — the preview/apply agreement the carousel depends on")
func sameInputsRecipeIdentically() {
let filters = BoardThemeFilters(tone: .light, colors: .duo, mesh: .coarse, saturation: .soft)
#expect(filters.recipe(hue: .forest, seed: 7) == filters.recipe(hue: .forest, seed: 7))
}
// MARK: - Mode
@Test("Solid color and Pattern are the picker's two cases, labeled as the segmented control shows them")
func modeCasesAreTheSegmentedLabels() {
#expect(BoardThemeMode.allCases == [.solid, .pattern])
#expect(BoardThemeMode.solid.rawValue == "Solid color")
#expect(BoardThemeMode.pattern.rawValue == "Pattern")
#expect(BoardThemeMode.solid.id == .solid)
#expect(BoardThemeMode.pattern.id == .pattern)
}
// MARK: - Solid colour derivation
/// The Solid color swatch's fill (`BoardThemeTabView.solidColor(_:)`): the filters' own
/// tone/saturation level at a hue, un-jittered the same thing a Pattern swatch's ground colour
/// is (`FacetsRecipe.primaryColor`), read directly rather than through a mesh.
@Test("The same filters and hue always yield the same solid colour")
func solidColorIsDeterministic() {
let filters = BoardThemeFilters(tone: .dark, colors: .duo, mesh: .fine, saturation: .rich)
let first = filters.recipe(hue: .teal, seed: 0).primaryColor
let second = filters.recipe(hue: .teal, seed: 0).primaryColor
#expect(first == second)
}
/// **Seed-independence**: `primaryColor` never reads `seed`, so a Solid swatch clicked at seed
/// `0` (`BoardThemeTabView.applySolid`) is exactly what a Pattern swatch's ground colour would be
/// at any seed the fact that lets Solid color skip minting a seed at all.
@Test("primaryColor is the same colour whatever the seed")
func primaryColorIsSeedIndependent() {
let filters = BoardThemeFilters(tone: .light, colors: .trio, mesh: .coarse, saturation: .soft)
let seeds: [UInt64] = [0, 1, 42, .max]
let colors = seeds.map { filters.recipe(hue: .amber, seed: $0).primaryColor }
#expect(Set(colors).count == 1)
}
/// Different hues under the same filters still land on different solid colours the carousel
/// would otherwise show eight identical swatches.
@Test("Different hues yield different solid colours under the same filters")
func differentHuesYieldDifferentSolidColors() {
let filters = BoardThemeFilters.initial(colorScheme: .light)
let sky = filters.recipe(hue: .sky, seed: 0).primaryColor
let rose = filters.recipe(hue: .rose, seed: 0).primaryColor
#expect(sky != rose)
}
}
+163 -3
View File
@@ -2,9 +2,9 @@ import Foundation
import Testing import Testing
@testable import Kanban @testable import Kanban
/// The write half of generated board backgrounds: `BoardWriter.writeBoardImage` and /// The write half of the Theme tab's two picture-adjacent gestures: `BoardWriter.writeBoardImage` and
/// `BoardStore.applyGeneratedBackground` (03-board-ui.md § Styling Capabilities; /// `BoardStore.applyGeneratedBackground` for Pattern, `BoardStore.applySolidBackground` for Solid
/// DESIGN/explorations/board-backgrounds.md). /// color (03-board-ui.md § Board popover Theme tab; DESIGN/explorations/board-backgrounds.md).
/// ///
/// Like every other write suite here these drive a real writer or a real store over a real temp /// Like every other write suite here these drive a real writer or a real store over a real temp
/// board and read the **bytes on disk** back rather than the app's own read path: the claims are /// board and read the **bytes on disk** back rather than the app's own read path: the claims are
@@ -384,3 +384,163 @@ struct GeneratedBackgroundUndoTests {
#expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip") #expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip")
} }
} }
// MARK: - Solid background
/// The Theme tab's Solid color half: `BoardStore.applySolidBackground` (03-board-ui.md § Board
/// popover Theme tab; `BoardThemeTabView.applySolid`). Modeled line-for-line on
/// `applyGeneratedBackground` minus the file write, so these suites mirror the write and undo suites
/// above with the one difference the method itself has: no picture, and `facets.png` when there is
/// one is deliberately left on disk rather than deleted.
@MainActor
@Suite("BoardStore ▸ applySolidBackground")
struct SolidBackgroundWriteTests {
@Test("The colour lands and there is no image subkey to point anywhere")
func writesTheColourAloneOnAPlainBoard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
#expect(store.applySolidBackground(colorHex: "#E0E5EB"))
let after = try document(fixture)
#expect(after.background == .valid("#E0E5EB"))
#expect(after.backgroundImage == .missing)
#expect(try fixture.indexText("").contains("background: {color: \"#E0E5EB\"}"))
#expect(store.banners.oneShots.isEmpty)
}
/// The write's whole point on a board that already carries a generated picture: the colour
/// changes, the `image` subkey goes, and every other subkey the app does not own rides through
/// untouched.
@Test("An existing image subkey is removed and unrelated subkeys survive")
func removesTheImageSubkeyAndKeepsForeignOnes() throws {
let fixture = try makeBoard(background: "{blend: multiply, color: fern, image: facets.png, opacity: 0.5}")
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.applySolidBackground(colorHex: "#513D1A")
let text = try fixture.indexText("")
#expect(text.contains("blend: \"multiply\""))
#expect(text.contains("color: \"#513D1A\""))
#expect(text.contains("opacity: 0.5"))
#expect(!text.contains("image:"))
let after = try document(fixture)
#expect(after.background == .valid("#513D1A"))
#expect(after.backgroundImage == .missing)
}
/// **The deliberate half of the contract**: choosing a solid colour over a generated background
/// does not delete the picture on disk only the field that pointed at it. Undo has to have
/// something to point back to (`SolidBackgroundUndoTests.restoresAPriorGeneratedImage`), and even
/// without undo the file is the user's now, not litter the app cleans up on its own.
@Test("facets.png stays on disk when the board had one")
func leavesTheGeneratedFileOnDisk() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
store.applySolidBackground(colorHex: "#513D1A")
#expect(try fixture.data("facets.png") == png, "the bytes are untouched")
#expect(try document(fixture).backgroundImage == .missing, "only the field is gone")
}
@Test("One bracket for the one file it touches")
func oneBracketForTheWrite() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
var begins = 0
var ends = 0
store.watcherBrackets = (begin: { begins += 1 }, end: { ends += 1 })
store.applySolidBackground(colorHex: "#E0E5EB")
#expect(begins == 1)
#expect(ends == 1)
}
/// A locked board writes nothing at all.
@Test("A read-only board refuses before anything is written")
func refusesUnderTheLock() throws {
let fixture = try makeBoard(background: "{color: fern}")
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.enterVanishedRootLock()
#expect(store.applySolidBackground(colorHex: "#E0E5EB") == false)
#expect(try document(fixture).background == .valid("fern"))
}
}
@MainActor
@Suite("Undo ▸ solid background")
struct SolidBackgroundUndoTests {
/// The first solid colour's undo is a clean return: the board had no background, and afterwards
/// it has none again.
@Test("Undo removes the colour and redo puts it back")
func roundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.applySolidBackground(colorHex: "#E0E5EB")
#expect(history.undoActionName == "Restyle Board")
history.undo()
let undone = try document(fixture)
#expect(undone.background == .missing)
#expect(undone.backgroundImage == .missing)
#expect(!undone.contains(FrontmatterKeys.background))
history.redo()
let redone = try document(fixture)
#expect(redone.background == .valid("#E0E5EB"))
#expect(redone.backgroundImage == .missing)
}
/// **Both prior fields come back** the colour a board had before, and the generated image the
/// solid choice pointed away from which is what makes the file surviving on disk
/// (`SolidBackgroundWriteTests.leavesTheGeneratedFileOnDisk`) worth doing: an undo with nothing to
/// point back at would make the surviving bytes an orphan from the moment they landed.
@Test("A prior colour and generated image are both restored")
func restoresAPriorGeneratedImage() throws {
let fixture = try makeBoard(background: "{color: fern, image: facets.png}")
defer { fixture.tearDown() }
try fixture.file("facets.png", png)
let (store, history) = try makeStore(fixture)
store.applySolidBackground(colorHex: "#E0E5EB")
history.undo()
let undone = try document(fixture)
#expect(undone.background == .valid("fern"))
#expect(undone.backgroundImage == .valid("facets.png"))
#expect(try fixture.data("facets.png") == png, "undo restored the field, not new bytes")
}
/// A foreign edit to the field the step wrote stales it the same field-level predicate
/// `GeneratedBackgroundUndoTests.foreignEditStalesTheStep` pins for the generated path, applied to
/// the colour subkey this gesture owns.
@Test("A foreign edit to the colour subkey skips the undo")
func foreignEditStalesTheStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.applySolidBackground(colorHex: "#E0E5EB")
var foreign = try document(fixture)
foreign.setStyleValue("obsidian", for: FrontmatterKeys.background)
try fixture.item("", foreign.serialized())
history.undo()
#expect(try document(fixture).background == .valid("obsidian"))
#expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip")
}
}
+2 -2
View File
@@ -23,7 +23,7 @@ Lanework is in early development. This list tracks what has actually shipped and
- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a lane carries exactly its cards, copied or moved, because the trash is board-level and there is nothing lane-nested to strip; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The clipboard works on trash cards like on any card — ⌘C yields a live copy wherever you paste it, and ⌘X in the trash followed by ⌘V is the keyboard-native restore, a card into a lane and a trashed lane row after the anchor lane — while paste never targets the trash itself, and the read-only lock blocks cut without ever blocking copy, because copying out is a read. - **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a lane carries exactly its cards, copied or moved, because the trash is board-level and there is nothing lane-nested to strip; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The clipboard works on trash cards like on any card — ⌘C yields a live copy wherever you paste it, and ⌘X in the trash followed by ⌘V is the keyboard-native restore, a card into a lane and a trashed lane row after the anchor lane — while paste never targets the trash itself, and the read-only lock blocks cut without ever blocking copy, because copying out is a read.
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched. **A board can also wear a picture.** `background` is a mapping at every level — `{color: fern}` on a card or a lane — and a board's may name an image beside its colour: `background: {color: "#112233", image: art/sunset.jpg}` paints that image over that colour, scaled to fill, across the **whole window**: the content runs under a transparent title bar with a frosted strip keeping the toolbar and the board-name widget legible on top of it. The path is relative to the board folder, so the picture travels with the document when it is copied, zipped or synced (an absolute path, or one climbing out of the folder, simply paints nothing). Either half may stand alone, the colour shows through while a large photograph decodes off the main thread, and replacing the file in Finder swaps the backdrop live. The board popover's Background tab can also *generate* the picture: a faceted triangle-mesh recipe filtered by tone (defaulting to the current appearance), color count (mono, complementary duo, contrasting trio), mesh density and saturation, previewed across an eight-hue carousel whose Reroll button re-mints the geometry — clicking a swatch renders it at full resolution into the board folder as `facets.png` and points `background` at it, with the recipe's primary color written beside it as the fallback underlay. Generated or dragged in, a background is always static pixels — the raw file stays the escape hatch — and a board with no background of its own keeps the standard window chrome exactly as before. - **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched. **A board can also wear a picture.** `background` is a mapping at every level — `{color: fern}` on a card or a lane — and a board's may name an image beside its colour: `background: {color: "#112233", image: art/sunset.jpg}` paints that image over that colour, scaled to fill, across the **whole window**: the content runs under a transparent title bar with a frosted strip keeping the toolbar and the board-name widget legible on top of it. The path is relative to the board folder, so the picture travels with the document when it is copied, zipped or synced (an absolute path, or one climbing out of the folder, simply paints nothing). Either half may stand alone, the colour shows through while a large photograph decodes off the main thread, and replacing the file in Finder swaps the backdrop live. The board popover's Theme tab dresses the board from presets: a Solid color / Pattern choice over one eight-hue carousel with chevron paging. Solid color filters by tone (defaulting to the current appearance) and saturation, and clicking a flat swatch writes that color and clears the image; Pattern adds color-count (mono, complementary duo, contrasting trio) and mesh-density filters over live previews of a faceted triangle-mesh recipe, with a Reroll button re-minting the geometry — clicking a swatch renders it at full resolution into the board folder as `facets.png` and points `background` at it, with the recipe's primary color written beside it as the fallback underlay. Preset or dragged in, a background is always static pixels — the raw file stays the escape hatch — and a board with no background of its own keeps the standard window chrome exactly as before.
- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. **Lanes delete into the trash too**: the folder travels subtree-intact and shows as one distinct dimmed row carrying its title and held-card count — an opaque unit that never expands, whose cards aren't individually addressable, and which restores whole or purges whole (its confirmation counting the cards it would take with it). The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live. - **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. **Lanes delete into the trash too**: the folder travels subtree-intact and shows as one distinct dimmed row carrying its title and held-card count — an opaque unit that never expands, whose cards aren't individually addressable, and which restores whole or purges whole (its confirmation counting the cards it would take with it). The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.
@@ -47,7 +47,7 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Raw source** — View ▸ Raw Source (⌥⌘E) swaps the card window's whole content area — title, body and sidebar — for the literal `index.md` in a monospaced editor with Cancel and Apply. It's the escape hatch that keeps everything reachable in-app: unknown keys an agent added, hand-written comments, exotic YAML the app has no control for. Entering flushes whatever you were typing and then reads the file fresh off disk, never an in-memory copy. Apply validates through the very same fail-fast parse the loader uses — a broken proposal stops with a detailed alert naming the line, source mode stays open with your text, and the file on disk is untouched — and a valid one is written byte for byte, the only write in the app that neither stamps `modified` nor clears a `modified-by` you typed or kept, because you wrote those bytes and nothing may quietly edit them. The reload then refreshes every window. Escape is Cancel, ⌘↩ is Apply, ⌥⌘E toggled off applies too, Return just types; Cancel and closing the window discard without ceremony, and a card deleted out from under an open buffer discards it rather than letting a stale Apply undelete the card. ⌘E stands down while source mode is up, ⌘F still finds, and ⌘Z is the editor's own undo. A file that isn't valid UTF-8 declines to open as source rather than showing you a lossy guess of it. - **Raw source** — View ▸ Raw Source (⌥⌘E) swaps the card window's whole content area — title, body and sidebar — for the literal `index.md` in a monospaced editor with Cancel and Apply. It's the escape hatch that keeps everything reachable in-app: unknown keys an agent added, hand-written comments, exotic YAML the app has no control for. Entering flushes whatever you were typing and then reads the file fresh off disk, never an in-memory copy. Apply validates through the very same fail-fast parse the loader uses — a broken proposal stops with a detailed alert naming the line, source mode stays open with your text, and the file on disk is untouched — and a valid one is written byte for byte, the only write in the app that neither stamps `modified` nor clears a `modified-by` you typed or kept, because you wrote those bytes and nothing may quietly edit them. The reload then refreshes every window. Escape is Cancel, ⌘↩ is Apply, ⌥⌘E toggled off applies too, Return just types; Cancel and closing the window discard without ceremony, and a card deleted out from under an open buffer discards it rather than letting a stale Apply undelete the card. ⌘E stands down while source mode is up, ⌘F still finds, and ⌘Z is the editor's own undo. A file that isn't valid UTF-8 declines to open as source rather than showing you a lossy guess of it.
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it the popover is tabbed — Info with the board's vital statistics, Background carrying the board-aimed style editor and the generated-background picker, and Git; on an ordinary free-tier board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." Under a Lanework Pro subscription that slot becomes the board's git section instead, and it follows the board's mode: a board with no repository offers **Add Git**, a board that lives inside somebody else's repository gets a short honest explanation rather than a hidden or greyed-out action, and a git board carries the branch surface: the current branch with switching and create-and-switch beside it, the plain-language explanation when an outside-the-app merge or rebase has the git surface paused, and the commit-identity name and email fields that write the repository's own `.git/config`. The read-only lock disables the surface without closing it. - **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it the popover is tabbed — Info with the board's vital statistics, Theme with the solid-color and pattern background presets, and Git; on an ordinary free-tier board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." Under a Lanework Pro subscription that slot becomes the board's git section instead, and it follows the board's mode: a board with no repository offers **Add Git**, a board that lives inside somebody else's repository gets a short honest explanation rather than a hidden or greyed-out action, and a git board carries the branch surface: the current branch with switching and create-and-switch beside it, the plain-language explanation when an outside-the-app merge or rebase has the git surface paused, and the commit-identity name and email fields that write the repository's own `.git/config`. The read-only lock disables the surface without closing it.
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Zoom In, Zoom Out, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode. - **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Zoom In, Zoom Out, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.