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:
@@ -1,5 +1,9 @@
|
|||||||
**August 2026**
|
**August 2026**
|
||||||
|
|
||||||
|
The board's symbol now appears in the title bar beside the board's name, in its chosen tint.
|
||||||
|
|
||||||
|
The board's symbol can now wear a color: the symbol picker carries a row of tints below the glyphs, with None to clear it.
|
||||||
|
|
||||||
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'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, Theme for backgrounds, and Git.
|
The board popover is now organized into three tabs — Info with the board's vital statistics, Theme for backgrounds, and Git.
|
||||||
|
|||||||
@@ -367,8 +367,10 @@ extension BoardStore {
|
|||||||
///
|
///
|
||||||
/// `.remove` is spelled as `nil`, which is the after-value the None well leaves: the key is gone,
|
/// `.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).
|
/// and the item renders the level's default (03-board-ui.md § Styling ▸ Controls).
|
||||||
static func styledFields(background: StyleChange, icon: StyleChange) -> [ExpectedField] {
|
static func styledFields(background: StyleChange, icon: StyleChange, iconColor: StyleChange) -> [ExpectedField] {
|
||||||
field(background, as: ExpectedField.background) + field(icon, as: ExpectedField.icon)
|
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
|
/// The same dimensions, holding the values the *inverse* restores — what the redo half validates
|
||||||
@@ -381,7 +383,9 @@ extension BoardStore {
|
|||||||
background: StyleChange,
|
background: StyleChange,
|
||||||
priorBackground: FieldValue<String>,
|
priorBackground: FieldValue<String>,
|
||||||
icon: StyleChange,
|
icon: StyleChange,
|
||||||
priorIcon: FieldValue<String>
|
priorIcon: FieldValue<String>,
|
||||||
|
iconColor: StyleChange,
|
||||||
|
priorIconColor: FieldValue<String>
|
||||||
) -> [ExpectedField] {
|
) -> [ExpectedField] {
|
||||||
var fields: [ExpectedField] = []
|
var fields: [ExpectedField] = []
|
||||||
if background != .keep {
|
if background != .keep {
|
||||||
@@ -390,6 +394,9 @@ extension BoardStore {
|
|||||||
if icon != .keep {
|
if icon != .keep {
|
||||||
fields.append(.icon(priorIcon.value))
|
fields.append(.icon(priorIcon.value))
|
||||||
}
|
}
|
||||||
|
if iconColor != .keep {
|
||||||
|
fields.append(.iconColor(priorIconColor.value))
|
||||||
|
}
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ public final class CardWindowUndo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let fieldOrder: [ExpectedField.Kind] = [
|
private static let fieldOrder: [ExpectedField.Kind] = [
|
||||||
.title, .order, .width, .background, .backgroundImage, .icon, .body,
|
.title, .order, .width, .background, .backgroundImage, .icon, .iconColor, .body,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ public enum ExpectedField: Sendable, Equatable {
|
|||||||
/// `icon` — the styling gesture's symbol dimension.
|
/// `icon` — the styling gesture's symbol dimension.
|
||||||
case icon(String?)
|
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
|
/// 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").
|
/// whose fidelity is not field-level (13: "body steps compare bytes").
|
||||||
case body(String)
|
case body(String)
|
||||||
@@ -57,6 +62,7 @@ public enum ExpectedField: Sendable, Equatable {
|
|||||||
case .background: .background
|
case .background: .background
|
||||||
case .backgroundImage: .backgroundImage
|
case .backgroundImage: .backgroundImage
|
||||||
case .icon: .icon
|
case .icon: .icon
|
||||||
|
case .iconColor: .iconColor
|
||||||
case .body: .body
|
case .body: .body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +76,7 @@ public enum ExpectedField: Sendable, Equatable {
|
|||||||
case background
|
case background
|
||||||
case backgroundImage
|
case backgroundImage
|
||||||
case icon
|
case icon
|
||||||
|
case iconColor
|
||||||
case body
|
case body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,6 +339,7 @@ public enum HistoryStaleness {
|
|||||||
case let .background(expected): equal(document.background, expected)
|
case let .background(expected): equal(document.background, expected)
|
||||||
case let .backgroundImage(expected): equal(document.backgroundImage, expected)
|
case let .backgroundImage(expected): equal(document.backgroundImage, expected)
|
||||||
case let .icon(expected): equal(document.icon, expected)
|
case let .icon(expected): equal(document.icon, expected)
|
||||||
|
case let .iconColor(expected): equal(document.iconColor, expected)
|
||||||
case let .body(expected): document.body == expected
|
case let .body(expected): document.body == expected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1698,8 +1698,8 @@ public final class BoardStore: HealHost {
|
|||||||
|
|
||||||
// MARK: - Styling
|
// MARK: - Styling
|
||||||
|
|
||||||
/// One item a style gesture is about to act on: where its `index.md` is, and what the two styled
|
/// One item a style gesture is about to act on: where its `index.md` is, and what the three
|
||||||
/// keys currently say there.
|
/// styled keys currently say there.
|
||||||
///
|
///
|
||||||
/// The editor reads these for its per-dimension current-value display (`StyleFieldState.resolve`)
|
/// 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
|
/// 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 folder: URL
|
||||||
public let background: FieldValue<String>
|
public let background: FieldValue<String>
|
||||||
public let icon: 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
|
/// 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,
|
id: nil,
|
||||||
folder: rootURL,
|
folder: rootURL,
|
||||||
background: snapshot.background,
|
background: snapshot.background,
|
||||||
icon: snapshot.icon
|
icon: snapshot.icon,
|
||||||
|
iconColor: snapshot.iconColor
|
||||||
)]
|
)]
|
||||||
|
|
||||||
case let .items(ids):
|
case let .items(ids):
|
||||||
@@ -1739,7 +1741,8 @@ public final class BoardStore: HealHost {
|
|||||||
id: lane.id,
|
id: lane.id,
|
||||||
folder: laneFolder,
|
folder: laneFolder,
|
||||||
background: lane.background,
|
background: lane.background,
|
||||||
icon: lane.icon
|
icon: lane.icon,
|
||||||
|
iconColor: lane.iconColor
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
for card in lane.cards where ids.contains(card.id) {
|
for card in lane.cards where ids.contains(card.id) {
|
||||||
@@ -1747,7 +1750,8 @@ public final class BoardStore: HealHost {
|
|||||||
id: card.id,
|
id: card.id,
|
||||||
folder: laneFolder.appendingPathComponent(card.id.rawValue),
|
folder: laneFolder.appendingPathComponent(card.id.rawValue),
|
||||||
background: card.background,
|
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
|
/// no-ops is dropped entirely; and a gesture that changes nothing anywhere never opens the
|
||||||
/// bracket at all.
|
/// bracket at all.
|
||||||
///
|
///
|
||||||
/// **`iconColor` is not a parameter, and that is the design**: it is "resolved — schema yes,
|
/// **`iconColor` joined as the third dimension with the board popover's colour row**
|
||||||
/// control no" (§ Capabilities). The field renders when hand-written and the app offers no
|
/// (2026-08-07). It had been "resolved — schema yes, control no" (§ Capabilities); the symbol
|
||||||
/// control for it, so there is nothing here to pass.
|
/// 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
|
/// 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
|
/// 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,
|
to target: StyleTarget,
|
||||||
background: StyleChange = .keep,
|
background: StyleChange = .keep,
|
||||||
icon: StyleChange = .keep,
|
icon: StyleChange = .keep,
|
||||||
|
iconColor: StyleChange = .keep,
|
||||||
on window: CardWindowUndo? = nil
|
on window: CardWindowUndo? = nil
|
||||||
) {
|
) {
|
||||||
// A session gesture anchors to the card, everything else to the folder it resolved (above).
|
// 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,
|
anchor: HistoryAnchor,
|
||||||
background: StyleChange,
|
background: StyleChange,
|
||||||
icon: StyleChange,
|
icon: StyleChange,
|
||||||
|
iconColor: StyleChange,
|
||||||
priorBackground: FieldValue<String>,
|
priorBackground: FieldValue<String>,
|
||||||
priorIcon: FieldValue<String>
|
priorIcon: FieldValue<String>,
|
||||||
|
priorIconColor: FieldValue<String>
|
||||||
)] = styleSubjects(of: target)
|
)] = styleSubjects(of: target)
|
||||||
.compactMap { subject in
|
.compactMap { subject in
|
||||||
let background = Self.effective(background, against: subject.background)
|
let background = Self.effective(background, against: subject.background)
|
||||||
let icon = Self.effective(icon, against: subject.icon)
|
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 {
|
let anchor: HistoryAnchor = if anchorsByIdentity, let id = subject.id {
|
||||||
.card(id)
|
.card(id)
|
||||||
} else {
|
} else {
|
||||||
@@ -1847,8 +1856,10 @@ public final class BoardStore: HealHost {
|
|||||||
anchor: anchor,
|
anchor: anchor,
|
||||||
background: background,
|
background: background,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
|
iconColor: iconColor,
|
||||||
priorBackground: subject.background,
|
priorBackground: subject.background,
|
||||||
priorIcon: subject.icon
|
priorIcon: subject.icon,
|
||||||
|
priorIconColor: subject.iconColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
guard !edits.isEmpty else { return }
|
guard !edits.isEmpty else { return }
|
||||||
@@ -1866,6 +1877,7 @@ public final class BoardStore: HealHost {
|
|||||||
) { document in
|
) { document in
|
||||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, 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,
|
subject: subject,
|
||||||
on: window,
|
on: window,
|
||||||
undoExpects: edits.map {
|
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 {
|
redoExpects: edits.map {
|
||||||
.present($0.anchor, fields: Self.restoredStyleFields(
|
.present($0.anchor, fields: Self.restoredStyleFields(
|
||||||
background: $0.background,
|
background: $0.background,
|
||||||
priorBackground: $0.priorBackground,
|
priorBackground: $0.priorBackground,
|
||||||
icon: $0.icon,
|
icon: $0.icon,
|
||||||
priorIcon: $0.priorIcon
|
priorIcon: $0.priorIcon,
|
||||||
|
iconColor: $0.iconColor,
|
||||||
|
priorIconColor: $0.priorIconColor
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
) { store in
|
) { store in
|
||||||
@@ -1910,6 +1928,7 @@ public final class BoardStore: HealHost {
|
|||||||
) { document in
|
) { document in
|
||||||
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
|
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
|
||||||
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
|
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
|
||||||
|
Self.restore(edit.priorIconColor, to: FrontmatterKeys.iconColor, in: &document)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} redo: { store in
|
} redo: { store in
|
||||||
@@ -1921,6 +1940,7 @@ public final class BoardStore: HealHost {
|
|||||||
) { document in
|
) { document in
|
||||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
||||||
|
Self.apply(edit.iconColor, to: FrontmatterKeys.iconColor, in: &document)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,18 @@ struct BoardInfoWidget: View {
|
|||||||
presentation.toggle()
|
presentation.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 4) {
|
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)
|
Text(summary.title)
|
||||||
// Styled like a titlebar title, because that is what it now stands in for
|
// 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).
|
// (`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 }
|
guard let branch = summary.branch else { return summary.title }
|
||||||
return "\(summary.title), branch \(branch)"
|
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
|
// MARK: - The widget's strings
|
||||||
@@ -352,6 +373,18 @@ struct BoardInfoView: View {
|
|||||||
in: store,
|
in: store,
|
||||||
recents: recents
|
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)
|
.disabled(!store.acceptsBoardMutations)
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ enum StyleCommand {
|
|||||||
static func apply(
|
static func apply(
|
||||||
background: StyleChange = .keep,
|
background: StyleChange = .keep,
|
||||||
icon: StyleChange = .keep,
|
icon: StyleChange = .keep,
|
||||||
|
iconColor: StyleChange = .keep,
|
||||||
to target: StyleTarget,
|
to target: StyleTarget,
|
||||||
in store: BoardStore,
|
in store: BoardStore,
|
||||||
recents: StyleRecents,
|
recents: StyleRecents,
|
||||||
on undo: CardWindowUndo? = nil
|
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 {
|
if case let .set(value) = background {
|
||||||
recents.record(value)
|
recents.record(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ enum SymbolPickerCatalog {
|
|||||||
/// list is a convenience, never a claim about the running system.
|
/// list is a convenience, never a claim about the running system.
|
||||||
static var available: [String] { defaultSet.filter(ItemSymbol.exists) }
|
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
|
/// Where the OS keeps the full SF Symbols inventory — read-only system metadata, present on
|
||||||
/// every Mac that ships SF Symbols at all.
|
/// every Mac that ships SF Symbols at all.
|
||||||
private static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"
|
private static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"
|
||||||
@@ -131,6 +141,10 @@ struct SymbolPickerLayout: Equatable {
|
|||||||
|
|
||||||
static let columns = 6
|
static let columns = 6
|
||||||
static let rows = 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
|
/// The grid's enlargement over the style editor's well size — glyphs read at a glance rather
|
||||||
/// than in miniature.
|
/// than in miniature.
|
||||||
static let gridScale: CGFloat = 1.3
|
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
|
/// The search grid's scroll cap — six rows tall, so a long result list scrolls inside the popover
|
||||||
/// rather than growing it.
|
/// rather than growing it.
|
||||||
var gridHeight: CGFloat
|
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.
|
/// The grid's width plus its padding on both sides — the popover's fixed width.
|
||||||
var popoverWidth: CGFloat
|
var popoverWidth: CGFloat
|
||||||
|
|
||||||
@@ -172,6 +190,7 @@ struct SymbolPickerLayout: Equatable {
|
|||||||
contentPadding: padding,
|
contentPadding: padding,
|
||||||
gridWidth: gridWidth,
|
gridWidth: gridWidth,
|
||||||
gridHeight: gridHeight,
|
gridHeight: gridHeight,
|
||||||
|
colorWellWidth: ((gridWidth - spacing * CGFloat(colorColumns - 1)) / CGFloat(colorColumns)).rounded(.down),
|
||||||
popoverWidth: (gridWidth + padding * 2).rounded()
|
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`
|
/// split without importing that type, since a caller outside the styling system has no `StyleChange`
|
||||||
/// to hand back.
|
/// to hand back.
|
||||||
let onSelect: (String?) -> Void
|
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
|
@State private var isPresented = false
|
||||||
|
|
||||||
@@ -223,6 +249,13 @@ struct SymbolPicker: View {
|
|||||||
return fallback
|
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 {
|
var body: some View {
|
||||||
let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize)
|
let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize)
|
||||||
Button {
|
Button {
|
||||||
@@ -230,6 +263,10 @@ struct SymbolPicker: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: resolvedName)
|
Image(systemName: resolvedName)
|
||||||
.imageScale(.medium)
|
.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)
|
.frame(width: layout.restSide, height: layout.restSide)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
@@ -246,6 +283,13 @@ struct SymbolPicker: View {
|
|||||||
onSelect: { name in
|
onSelect: { name in
|
||||||
onSelect(name)
|
onSelect(name)
|
||||||
isPresented = false
|
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 searchable: Bool
|
||||||
let layout: SymbolPickerLayout
|
let layout: SymbolPickerLayout
|
||||||
let onSelect: (String?) -> Void
|
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 = ""
|
@State private var query = ""
|
||||||
|
|
||||||
@@ -274,6 +321,12 @@ private struct SymbolPickerPopoverContent: View {
|
|||||||
searchField
|
searchField
|
||||||
}
|
}
|
||||||
resultBody
|
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)
|
.padding(layout.contentPadding)
|
||||||
.frame(width: layout.popoverWidth)
|
.frame(width: layout.popoverWidth)
|
||||||
@@ -465,3 +518,131 @@ private struct SymbolWellGrid: View {
|
|||||||
return .handled
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ private func styled(order: String, title: String, keys: [String] = []) -> String
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The board root's own `index.md` — styled like any other item (`StyleTarget.board`), and carrying
|
/// The board root's own `index.md` — styled like any other item (`StyleTarget.board`), and carrying
|
||||||
/// an `iconColor` the app must never touch (schema yes, control no).
|
/// an `iconColor` a gesture that never names that dimension must leave alone (it stopped being
|
||||||
|
/// hand-written-only when the board popover's symbol picker grew its colour row, 2026-08-07).
|
||||||
private let boardIndex = """
|
private let boardIndex = """
|
||||||
---
|
---
|
||||||
schema: 1
|
schema: 1
|
||||||
@@ -143,13 +144,38 @@ struct StyleWriteTests {
|
|||||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||||
#expect(after.contains("background: {color: \"dark-teal\"}"))
|
#expect(after.contains("background: {color: \"dark-teal\"}"))
|
||||||
#expect(after.contains("icon: flag"))
|
#expect(after.contains("icon: flag"))
|
||||||
// "iconColor: resolved — schema yes, control no" (03 § Styling ▸ Capabilities): the field
|
// The third dimension is a control now (the symbol picker's colour row), but a gesture
|
||||||
// renders when hand-written and the app offers no control for it, so a style write must
|
// that never names it must still carry it through untouched — per-dimension `.keep`.
|
||||||
// carry it through untouched like any unknown key.
|
|
||||||
#expect(after.contains("iconColor: chalk"))
|
#expect(after.contains("iconColor: chalk"))
|
||||||
#expect(!after.contains("fern"), "the old value is replaced, not duplicated")
|
#expect(!after.contains("fern"), "the old value is replaced, not duplicated")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("The colour row's dimension: iconColor sets as a plain scalar, removes as an absent key, and no-ops in place")
|
||||||
|
func iconColorIsTheThirdDimension() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let log = BracketLog()
|
||||||
|
log.attach(to: store)
|
||||||
|
|
||||||
|
// The fixture's board already carries `iconColor: carnation` — re-choosing it is a no-op
|
||||||
|
// against the loaded snapshot, `skipsNoOps`'s rule at the third dimension.
|
||||||
|
store.applyStyle(to: .board, iconColor: .set("carnation"))
|
||||||
|
#expect(log.begins == 0, "re-choosing the tint an item already carries opens no bracket")
|
||||||
|
|
||||||
|
store.applyStyle(to: .board, iconColor: .set("fern"))
|
||||||
|
let set = try fixture.indexText("")
|
||||||
|
#expect(set.contains("iconColor: fern"))
|
||||||
|
#expect(!set.contains("carnation"), "the old tint is replaced, not duplicated")
|
||||||
|
#expect(!set.contains("icon: "), "the untouched icon dimension writes no key of its own")
|
||||||
|
|
||||||
|
store.applyStyle(to: .board, iconColor: .remove)
|
||||||
|
let removed = try fixture.indexText("")
|
||||||
|
#expect(!removed.contains("iconColor"))
|
||||||
|
#expect(!removed.contains("\"\""), "a removal is a missing key, never an empty string")
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("The None and default wells remove their key rather than writing a blank value")
|
@Test("The None and default wells remove their key rather than writing a blank value")
|
||||||
func removesTheKey() throws {
|
func removesTheKey() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
|
|||||||
@@ -23,6 +23,23 @@ struct SymbolPickerCatalogDefaultSetTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suite("SymbolPicker ▸ the colour row")
|
||||||
|
struct SymbolPickerColorSetTests {
|
||||||
|
|
||||||
|
@Test("Seven unique tints — the leading None plus these fills the 4×2 grid exactly")
|
||||||
|
func shape() {
|
||||||
|
#expect(SymbolPickerCatalog.colorSet.count
|
||||||
|
== SymbolPickerLayout.colorColumns * SymbolPickerLayout.colorRows - 1)
|
||||||
|
#expect(Set(SymbolPickerCatalog.colorSet).count == SymbolPickerCatalog.colorSet.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every entry is a palette name that resolves — pins the list against typos and palette drift")
|
||||||
|
func everyNameResolves() {
|
||||||
|
let missing = SymbolPickerCatalog.colorSet.filter { Palette.color(named: $0) == nil }
|
||||||
|
#expect(missing.isEmpty, "unresolvable palette names: \(missing)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suite("SymbolPicker ▸ filter")
|
@Suite("SymbolPicker ▸ filter")
|
||||||
struct SymbolPickerFilterTests {
|
struct SymbolPickerFilterTests {
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ order: 3072
|
|||||||
project: lanework # agent overlay
|
project: lanework # agent overlay
|
||||||
background: {color: blue}
|
background: {color: blue}
|
||||||
icon: star
|
icon: star
|
||||||
|
iconColor: chalk
|
||||||
created: 2026-01-01T09:00:00Z
|
created: 2026-01-01T09:00:00Z
|
||||||
---
|
---
|
||||||
Styled body.
|
Styled body.
|
||||||
@@ -249,6 +250,27 @@ struct RestyleUndoTests {
|
|||||||
let undone = try document(fixture, card3Path)
|
let undone = try document(fixture, card3Path)
|
||||||
#expect(undone.background.value == "blue")
|
#expect(undone.background.value == "blue")
|
||||||
#expect(undone.icon.value == "star", "a dimension the gesture did not touch is not touched back")
|
#expect(undone.icon.value == "star", "a dimension the gesture did not touch is not touched back")
|
||||||
|
#expect(undone.iconColor.value == "chalk", "the third dimension rides the same rule")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The colour row's dimension round-trips: a tint undoes to the prior tint, and to absence where there was none")
|
||||||
|
func iconColorRoundTrip() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let (store, history) = try makeStore(fixture)
|
||||||
|
|
||||||
|
// card3 carries `iconColor: chalk`; card1 carries none.
|
||||||
|
store.applyStyle(to: .items([card3]), iconColor: .set("fern"))
|
||||||
|
#expect(try document(fixture, card3Path).iconColor.value == "fern")
|
||||||
|
|
||||||
|
history.undo()
|
||||||
|
#expect(try document(fixture, card3Path).iconColor.value == "chalk")
|
||||||
|
history.redo()
|
||||||
|
#expect(try document(fixture, card3Path).iconColor.value == "fern")
|
||||||
|
|
||||||
|
store.applyStyle(to: .items([card1]), iconColor: .set("carnation"))
|
||||||
|
history.undo()
|
||||||
|
#expect(try document(fixture, card1Path).iconColor.isMissing, "no prior tint undoes to no key at all")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A styling batch is one step, with a plural title")
|
@Test("A styling batch is one step, with a plural title")
|
||||||
|
|||||||
@@ -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, 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.
|
- **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. Beside the name field sits the board's symbol: a well that opens a curated 36-glyph grid with a search into the OS's full SF Symbols inventory (its leading well restores the board default by removing the `icon` key), and below the glyphs a 4×2 colour row — a leading None plus seven palette tints — writes the symbol's `iconColor` the same way; the chosen glyph, in its tint, also shows in the title bar beside the board's name, so the identity pair the popover header edits is the one the window wears. 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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user