The board's symbol takes a tint — a 4×2 colour row under the picker's glyphs, and the glyph itself moves into the titlebar

iconColor stops being hand-written-only (user-ruled, superseding 03's
"schema yes, control no"): it rides StyleCommand.apply → applyStyle as
the third styled dimension — per-dimension no-op skip, one bracket, one
history step, ExpectedField.iconColor for staleness. The SymbolPicker
grows an opt-in colour row (leading None plus seven Palette.foregrounds
hues, None removes the key); the board popover is its one caller. The
window-title widget now draws the board's resolved glyph in that tint
beside the name. Doc realignment filed on the Redesign board (Minor).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-07 18:36:49 -04:00
parent 3d231d6454
commit 7ac34651a2
12 changed files with 342 additions and 23 deletions
+10 -3
View File
@@ -367,8 +367,10 @@ extension BoardStore {
///
/// `.remove` is spelled as `nil`, which is the after-value the None well leaves: the key is gone,
/// and the item renders the level's default (03-board-ui.md § Styling Controls).
static func styledFields(background: StyleChange, icon: StyleChange) -> [ExpectedField] {
field(background, as: ExpectedField.background) + field(icon, as: ExpectedField.icon)
static func styledFields(background: StyleChange, icon: StyleChange, iconColor: StyleChange) -> [ExpectedField] {
field(background, as: ExpectedField.background)
+ field(icon, as: ExpectedField.icon)
+ field(iconColor, as: ExpectedField.iconColor)
}
/// The same dimensions, holding the values the *inverse* restores what the redo half validates
@@ -381,7 +383,9 @@ extension BoardStore {
background: StyleChange,
priorBackground: FieldValue<String>,
icon: StyleChange,
priorIcon: FieldValue<String>
priorIcon: FieldValue<String>,
iconColor: StyleChange,
priorIconColor: FieldValue<String>
) -> [ExpectedField] {
var fields: [ExpectedField] = []
if background != .keep {
@@ -390,6 +394,9 @@ extension BoardStore {
if icon != .keep {
fields.append(.icon(priorIcon.value))
}
if iconColor != .keep {
fields.append(.iconColor(priorIconColor.value))
}
return fields
}
+1 -1
View File
@@ -272,7 +272,7 @@ public final class CardWindowUndo {
}
private static let fieldOrder: [ExpectedField.Kind] = [
.title, .order, .width, .background, .backgroundImage, .icon, .body,
.title, .order, .width, .background, .backgroundImage, .icon, .iconColor, .body,
]
}
}
+8
View File
@@ -42,6 +42,11 @@ public enum ExpectedField: Sendable, Equatable {
/// `icon` the styling gesture's symbol dimension.
case icon(String?)
/// `iconColor` the symbol's tint, the styling gesture's third dimension since the board
/// popover's symbol picker grew its colour row (2026-08-07; previously "schema yes, control
/// no"). `nil` is the removed key the None well exactly as everywhere else here.
case iconColor(String?)
/// The body span, **byte for byte** the Edit session's step, and the one inverse in the app
/// whose fidelity is not field-level (13: "body steps compare bytes").
case body(String)
@@ -57,6 +62,7 @@ public enum ExpectedField: Sendable, Equatable {
case .background: .background
case .backgroundImage: .backgroundImage
case .icon: .icon
case .iconColor: .iconColor
case .body: .body
}
}
@@ -70,6 +76,7 @@ public enum ExpectedField: Sendable, Equatable {
case background
case backgroundImage
case icon
case iconColor
case body
}
}
@@ -332,6 +339,7 @@ public enum HistoryStaleness {
case let .background(expected): equal(document.background, expected)
case let .backgroundImage(expected): equal(document.backgroundImage, expected)
case let .icon(expected): equal(document.icon, expected)
case let .iconColor(expected): equal(document.iconColor, expected)
case let .body(expected): document.body == expected
}
}
+33 -13
View File
@@ -1698,8 +1698,8 @@ public final class BoardStore: HealHost {
// MARK: - Styling
/// One item a style gesture is about to act on: where its `index.md` is, and what the two styled
/// keys currently say there.
/// One item a style gesture is about to act on: where its `index.md` is, and what the three
/// styled keys currently say there.
///
/// The editor reads these for its per-dimension current-value display (`StyleFieldState.resolve`)
/// and `applyStyle` reads the *same* values to decide what is a no-op, so the display and the
@@ -1710,6 +1710,7 @@ public final class BoardStore: HealHost {
public let folder: URL
public let background: FieldValue<String>
public let icon: FieldValue<String>
public let iconColor: FieldValue<String>
}
/// The live items `target` names, in display order lanes left to right, each lane's cards top
@@ -1727,7 +1728,8 @@ public final class BoardStore: HealHost {
id: nil,
folder: rootURL,
background: snapshot.background,
icon: snapshot.icon
icon: snapshot.icon,
iconColor: snapshot.iconColor
)]
case let .items(ids):
@@ -1739,7 +1741,8 @@ public final class BoardStore: HealHost {
id: lane.id,
folder: laneFolder,
background: lane.background,
icon: lane.icon
icon: lane.icon,
iconColor: lane.iconColor
))
}
for card in lane.cards where ids.contains(card.id) {
@@ -1747,7 +1750,8 @@ public final class BoardStore: HealHost {
id: card.id,
folder: laneFolder.appendingPathComponent(card.id.rawValue),
background: card.background,
icon: card.icon
icon: card.icon,
iconColor: card.iconColor
))
}
}
@@ -1790,9 +1794,10 @@ public final class BoardStore: HealHost {
/// no-ops is dropped entirely; and a gesture that changes nothing anywhere never opens the
/// bracket at all.
///
/// **`iconColor` is not a parameter, and that is the design**: it is "resolved schema yes,
/// control no" (§ Capabilities). The field renders when hand-written and the app offers no
/// control for it, so there is nothing here to pass.
/// **`iconColor` joined as the third dimension with the board popover's colour row**
/// (2026-08-07). It had been "resolved schema yes, control no" (§ Capabilities); the symbol
/// picker's colour grid is the control that ended that, and it rides this funnel exactly as the
/// other two dimensions do per-dimension no-op skipping, one bracket, one history step.
///
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
/// like every other gesture with no second thing to do. A batch that fails partway leaves the
@@ -1816,6 +1821,7 @@ public final class BoardStore: HealHost {
to target: StyleTarget,
background: StyleChange = .keep,
icon: StyleChange = .keep,
iconColor: StyleChange = .keep,
on window: CardWindowUndo? = nil
) {
// A session gesture anchors to the card, everything else to the folder it resolved (above).
@@ -1829,13 +1835,16 @@ public final class BoardStore: HealHost {
anchor: HistoryAnchor,
background: StyleChange,
icon: StyleChange,
iconColor: StyleChange,
priorBackground: FieldValue<String>,
priorIcon: FieldValue<String>
priorIcon: FieldValue<String>,
priorIconColor: FieldValue<String>
)] = styleSubjects(of: target)
.compactMap { subject in
let background = Self.effective(background, against: subject.background)
let icon = Self.effective(icon, against: subject.icon)
guard background != .keep || icon != .keep else { return nil }
let iconColor = Self.effective(iconColor, against: subject.iconColor)
guard background != .keep || icon != .keep || iconColor != .keep else { return nil }
let anchor: HistoryAnchor = if anchorsByIdentity, let id = subject.id {
.card(id)
} else {
@@ -1847,8 +1856,10 @@ public final class BoardStore: HealHost {
anchor: anchor,
background: background,
icon: icon,
iconColor: iconColor,
priorBackground: subject.background,
priorIcon: subject.icon
priorIcon: subject.icon,
priorIconColor: subject.iconColor
)
}
guard !edits.isEmpty else { return }
@@ -1866,6 +1877,7 @@ public final class BoardStore: HealHost {
) { document in
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
Self.apply(edit.iconColor, to: FrontmatterKeys.iconColor, in: &document)
}
}
}
@@ -1891,14 +1903,20 @@ public final class BoardStore: HealHost {
subject: subject,
on: window,
undoExpects: edits.map {
.present($0.anchor, fields: Self.styledFields(background: $0.background, icon: $0.icon))
.present($0.anchor, fields: Self.styledFields(
background: $0.background,
icon: $0.icon,
iconColor: $0.iconColor
))
},
redoExpects: edits.map {
.present($0.anchor, fields: Self.restoredStyleFields(
background: $0.background,
priorBackground: $0.priorBackground,
icon: $0.icon,
priorIcon: $0.priorIcon
priorIcon: $0.priorIcon,
iconColor: $0.iconColor,
priorIconColor: $0.priorIconColor
))
}
) { store in
@@ -1910,6 +1928,7 @@ public final class BoardStore: HealHost {
) { document in
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
Self.restore(edit.priorIconColor, to: FrontmatterKeys.iconColor, in: &document)
}
}
} redo: { store in
@@ -1921,6 +1940,7 @@ public final class BoardStore: HealHost {
) { document in
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
Self.apply(edit.iconColor, to: FrontmatterKeys.iconColor, in: &document)
}
}
}
+33
View File
@@ -115,6 +115,18 @@ struct BoardInfoWidget: View {
presentation.toggle()
} label: {
HStack(spacing: 4) {
// The board's own glyph beside its name the identity pair the popover header
// states, restated where the board is named all day (2026-08-07). Read inside
// `body` for `summary`'s reason: `icon`/`iconColor` are `@Observable` fields, so a
// restyle from the popover repaints this widget without reinstalling it. Lenient on
// both dimensions an unresolvable glyph draws the board default, an unresolvable
// tint draws the standard secondary.
Image(systemName: ItemSymbol.name(store.snapshot.icon, fallback: ItemSymbol.board))
.imageScale(.small)
.foregroundStyle(iconTint)
// Decorative beside the name it repeats the button's own label already says
// everything VoiceOver needs (`accessibilityLabel` below).
.accessibilityHidden(true)
Text(summary.title)
// Styled like a titlebar title, because that is what it now stands in for
// (`BoardWindowHost` hides the system title display in favor of this widget).
@@ -172,6 +184,15 @@ struct BoardInfoWidget: View {
guard let branch = summary.branch else { return summary.title }
return "\(summary.title), branch \(branch)"
}
/// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary
/// otherwise `CardFaceView`'s `iconTint` rule at the board's own level.
private var iconTint: AnyShapeStyle {
if let color = Palette.color(for: store.snapshot.iconColor) {
return AnyShapeStyle(color)
}
return AnyShapeStyle(.secondary)
}
}
// MARK: - The widget's strings
@@ -352,6 +373,18 @@ struct BoardInfoView: View {
in: store,
recents: recents
)
},
// The colour row the picker's 4×2 tint grid, writing `iconColor` through
// the same funnel the glyph writes `icon`: None removes the key, a well
// writes the palette name (2026-08-07).
currentColor: store.snapshot.iconColor.value,
onSelectColor: { name in
StyleCommand.apply(
iconColor: name.map { StyleChange.set($0) } ?? .remove,
to: .board,
in: store,
recents: recents
)
}
)
.disabled(!store.acceptsBoardMutations)
+2 -1
View File
@@ -36,12 +36,13 @@ enum StyleCommand {
static func apply(
background: StyleChange = .keep,
icon: StyleChange = .keep,
iconColor: StyleChange = .keep,
to target: StyleTarget,
in store: BoardStore,
recents: StyleRecents,
on undo: CardWindowUndo? = nil
) {
store.applyStyle(to: target, background: background, icon: icon, on: undo)
store.applyStyle(to: target, background: background, icon: icon, iconColor: iconColor, on: undo)
if case let .set(value) = background {
recents.record(value)
}
+181
View File
@@ -49,6 +49,16 @@ enum SymbolPickerCatalog {
/// list is a convenience, never a claim about the running system.
static var available: [String] { defaultSet.filter(ItemSymbol.exists) }
/// The colour row's seven tints `Palette.foregrounds`' hues, minus the four grayscale steps
/// (a symbol's *tint* wants colour; "no tint" is the None well's job, not a gray's) and minus
/// `deep-cool-granite`, the mutedest of the eight, dropped so the row plus its leading None
/// fills the 4×2 grid exactly. Palette names, not hexes, exactly as the style editor's wells
/// write them.
static let colorSet: [String] = [
"carnation", "rich-grapefruit", "smokey-tangerine", "fern",
"light-teal", "deep-sky-blue", "pale-violet",
]
/// Where the OS keeps the full SF Symbols inventory read-only system metadata, present on
/// every Mac that ships SF Symbols at all.
private static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"
@@ -131,6 +141,10 @@ struct SymbolPickerLayout: Equatable {
static let columns = 6
static let rows = 6
/// The colour row's own shape 4×2, the leading None plus `SymbolPickerCatalog.colorSet`'s
/// seven tints.
static let colorColumns = 4
static let colorRows = 2
/// The grid's enlargement over the style editor's well size glyphs read at a glance rather
/// than in miniature.
static let gridScale: CGFloat = 1.3
@@ -153,6 +167,10 @@ struct SymbolPickerLayout: Equatable {
/// The search grid's scroll cap six rows tall, so a long result list scrolls inside the popover
/// rather than growing it.
var gridHeight: CGFloat
/// A colour well's width: the symbol grid's width re-divided into four columns, so the colour
/// rows sit flush under the symbol grid rather than introducing a second width. Height stays
/// `wellSide` the swatch is wide, not tall.
var colorWellWidth: CGFloat
/// The grid's width plus its padding on both sides the popover's fixed width.
var popoverWidth: CGFloat
@@ -172,6 +190,7 @@ struct SymbolPickerLayout: Equatable {
contentPadding: padding,
gridWidth: gridWidth,
gridHeight: gridHeight,
colorWellWidth: ((gridWidth - spacing * CGFloat(colorColumns - 1)) / CGFloat(colorColumns)).rounded(.down),
popoverWidth: (gridWidth + padding * 2).rounded()
)
}
@@ -210,6 +229,13 @@ struct SymbolPicker: View {
/// split without importing that type, since a caller outside the styling system has no `StyleChange`
/// to hand back.
let onSelect: (String?) -> Void
/// The committed tint (`iconColor`), or `nil` for "no tint" read only when `onSelectColor` is
/// wired, since a picker with no colour row has no tint to state.
var currentColor: String? = nil
/// The colour row's contract, `onSelect`'s shape one dimension over: a palette name to set, or
/// `nil` to clear the tint. **`nil` here means no colour row at all** the grid is opt-in, so
/// the callers that wanted a symbol picker keep getting exactly one.
var onSelectColor: ((String?) -> Void)? = nil
@State private var isPresented = false
@@ -223,6 +249,13 @@ struct SymbolPicker: View {
return fallback
}
/// The at-rest well's tint, or `nil` for the standard one `Palette`'s lenient rule, gated on
/// the colour row being offered at all.
private var resolvedTint: AnyShapeStyle? {
guard onSelectColor != nil, let currentColor, let color = Palette.color(named: currentColor) else { return nil }
return AnyShapeStyle(color)
}
var body: some View {
let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize)
Button {
@@ -230,6 +263,10 @@ struct SymbolPicker: View {
} label: {
Image(systemName: resolvedName)
.imageScale(.medium)
// The tint the board actually renders with, on the well that states the board's
// glyph shown only where the picker offers the colour row, and lenient exactly
// like the glyph itself: an unresolvable value tints nothing.
.foregroundStyle(resolvedTint ?? AnyShapeStyle(.primary))
.frame(width: layout.restSide, height: layout.restSide)
}
.buttonStyle(.bordered)
@@ -246,6 +283,13 @@ struct SymbolPicker: View {
onSelect: { name in
onSelect(name)
isPresented = false
},
currentColor: currentColor,
onSelectColor: onSelectColor.map { select in
{ name in
select(name)
isPresented = false
}
}
)
}
@@ -265,6 +309,9 @@ private struct SymbolPickerPopoverContent: View {
let searchable: Bool
let layout: SymbolPickerLayout
let onSelect: (String?) -> Void
var currentColor: String? = nil
/// `nil` is "no colour row" `SymbolPicker.onSelectColor`'s opt-in, passed through.
var onSelectColor: ((String?) -> Void)? = nil
@State private var query = ""
@@ -274,6 +321,12 @@ private struct SymbolPickerPopoverContent: View {
searchField
}
resultBody
// The colour row rides below whichever grid is up a search narrows the symbols, not
// the tints, so the row keeps standing where the eye left it.
if let onSelectColor {
Divider()
SymbolColorGrid(current: currentColor, layout: layout, onSelect: onSelectColor)
}
}
.padding(layout.contentPadding)
.frame(width: layout.popoverWidth)
@@ -465,3 +518,131 @@ private struct SymbolWellGrid: View {
return .handled
}
}
// MARK: - The colour row
/// One well in the colour grid: a palette name, or `nil` for the leading None.
private struct SymbolColorWell: Identifiable {
let id: Int
/// The palette name this well writes, or `nil` for the None well the removal.
let name: String?
let label: String
let isSelected: Bool
}
/// The tint grid under the symbol grid the leading **None** well and
/// `SymbolPickerCatalog.colorSet`'s seven tints, 4×2 (the board popover's colour row, 2026-08-07).
/// `StyleWellGrid`'s pattern one more time, and `SymbolWellGrid`'s reason for restating it: the
/// style editor's grids are that file's own, and this row's shape (wide swatches on a fixed
/// four-column re-division of the symbol grid's width) fits neither.
private struct SymbolColorGrid: View {
/// The committed tint as written, or `nil` when the key is absent.
let current: String?
let layout: SymbolPickerLayout
let onSelect: (String?) -> Void
@FocusState private var focused: Int?
@Environment(\.colorSchemeContrast) private var contrast
var body: some View {
LazyVGrid(
columns: Array(
repeating: GridItem(.flexible(minimum: layout.colorWellWidth), spacing: layout.wellSpacing),
count: SymbolPickerLayout.colorColumns
),
spacing: layout.wellSpacing
) {
ForEach(wells) { well in
Button {
onSelect(well.name)
} label: {
swatch(well.name.flatMap(Palette.color(named:)))
.overlay(selectionRing(well.isSelected))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.focusable()
.focused($focused, equals: well.id)
.help(well.label)
.accessibilityLabel(well.label)
.accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
}
}
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
move(press.key)
}
}
/// The None well leads, selected whenever no tint would render a missing key and an
/// unresolvable value read the same way here, `ItemSymbol.name(_:fallback:)`'s lenient rule
/// turned on the colour dimension.
private var wells: [SymbolColorWell] {
let isNoneSelected = current.map { Palette.color(named: $0) == nil } ?? true
var wells = [SymbolColorWell(id: 0, name: nil, label: "No Color", isSelected: isNoneSelected)]
for (index, name) in SymbolPickerCatalog.colorSet.enumerated() {
wells.append(SymbolColorWell(id: index + 1, name: name, label: name, isSelected: current == name))
}
return wells
}
/// A colour swatch, always stroked (`chalk`'s lesson from the style editor's wells: a pale
/// swatch with no border is an invisible control), with the corner-to-corner slash standing in
/// for a colour on the None well Finder's own vocabulary for "there isn't one".
private func swatch(_ color: Color?) -> some View {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(color ?? Color(nsColor: .textBackgroundColor))
.overlay {
if color == nil {
ColorNoneStrike(inset: max(1, (layout.wellSide * 0.15).rounded()))
.stroke(.secondary, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
}
}
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
)
.frame(width: layout.colorWellWidth, height: layout.wellSide)
}
private var cornerRadius: CGFloat { max(1, (layout.wellSide * 0.25).rounded()) }
private func selectionRing(_ isSelected: Bool) -> some View {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
)
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
}
/// `SymbolWellGrid.move(_:)`, at this grid's own four columns.
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
let delta: Int
switch key {
case .leftArrow: delta = -1
case .rightArrow: delta = 1
case .upArrow: delta = -SymbolPickerLayout.colorColumns
case .downArrow: delta = SymbolPickerLayout.colorColumns
default: return .ignored
}
let current = focused ?? 0
let next = min(max(0, current + delta), wells.count - 1)
focused = next
return .handled
}
}
/// The None well's corner-to-corner slash `StyleEditor.swift`'s `NoValueStrike`, restated as a
/// sibling for `SymbolWellFace`'s reason: that type is private to a file whose whole point is
/// staying anchor-agnostic, and one two-point path is cheaper than widening it.
private struct ColorNoneStrike: Shape {
let inset: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX + inset, y: rect.maxY - inset))
path.addLine(to: CGPoint(x: rect.maxX - inset, y: rect.minY + inset))
return path
}
}