The board wears a picture — background becomes a mapping, and the window chrome follows it under a thin frost

background is {color:, image:} and only a mapping at every level; the board's image paints the full window under a transparent title bar, with a thin-material frost strip keeping the chrome legible and the standard accommodations intact.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-07 10:15:03 -04:00
parent 190a8e36f1
commit d5ad21c3da
37 changed files with 1182 additions and 76 deletions
+15
View File
@@ -203,6 +203,21 @@ struct BoardWindowHost: View {
.onChange(of: boardSearch.isFocused) { _, _ in
boardSearch.dismissTransientIfCleared(query: store.searchQuery)
}
// **The window chrome follows the board's background** (03-board-ui.md § Styling
// Capabilities): a board that paints one runs its content the full height of the frame
// under a transparent title bar, with `BoardView.boardBackground`'s frosted strip
// keeping the widget and the toolbar legible over it; a board that paints none keeps
// the standard chrome untouched.
//
// Here rather than in `configureWindow` because it is not a wiring fact but a *live*
// one: `background` is hand-editable, the watcher reloads on a change to `index.md`, and
// the chrome has to follow the reading in both directions. `initial: true` because the
// first render is already a level, not a change this is the board's first statement
// about its chrome, and the loading half deliberately made none
// (`HostedWindowController.extendsUnderTitlebar`).
.onChange(of: BoardBackdrop.isCustom(store.snapshot, root: store.rootURL), initial: true) { _, custom in
windowController.setExtendsContentUnderTitlebar(custom)
}
// **The board settings sheet** (03-board-ui.md Board settings sheet) presented from
// the board window's own content, which is what makes it modal to *this* board rather
// than to the app: "a board-scoped, titled, sectioned sheet on the board window".
+52
View File
@@ -109,6 +109,24 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// what the chrome draws from it.
private var titleVisibility: NSWindow.TitleVisibility?
/// Whether this window's content runs the full height of the frame, under a transparent title
/// bar **a board window carrying a custom background**, and nothing else (03-board-ui.md §
/// Styling Capabilities: the board's colour or image "paints the full window"; `BoardView
/// .boardBackground` draws the frosted strip that keeps the chrome legible over it).
///
/// `nil` leaves AppKit's own posture untouched, exactly as `titleVisibility` does the welcome,
/// bootstrap and card windows have no opinion, and neither does a board window while it loads
/// (the flag is driven off the snapshot, which does not exist yet). `nil` and `false` therefore
/// render identically; they differ only in whether this controller has *said* anything, which is
/// what keeps the loading half from having to state a default it does not own.
///
/// A slot rather than a one-shot write, and **repeat-safe rather than install-once** the
/// `hideTitle` pattern, for a stronger version of its reason: the value has to survive the
/// provisional-window swap (`detach()`), *and* it genuinely changes over a window's life. A
/// `background:` edited on disk reloads the snapshot, and the chrome follows it in both
/// directions.
private var extendsUnderTitlebar: Bool?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
// MARK: Attachment
@@ -129,6 +147,7 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
addTitlebarAccessoryIfPossible()
applyToolbarIfPossible()
applyTitleVisibilityIfPossible()
applyTitlebarExtensionIfPossible()
}
/// Puts the previous delegate back and takes the titlebar accessory and toolbar off the window
@@ -233,6 +252,39 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
window.titleVisibility = titleVisibility
}
// MARK: Content under the title bar
/// Runs this window's content the full height of its frame, under a transparent title bar or
/// puts the standard chrome back (see `extendsUnderTitlebar`).
///
/// Safe whenever the caller learns the answer before the window exists (held, applied at
/// `attach`) or after (applied now) and safe to call repeatedly with the same value, which
/// matters more here than for `hideTitle`: the board window drives this off its snapshot, so it
/// is called on every reload that changes the reading and on plenty that do not.
///
/// **Not undone at `detach`**, `titleVisibility`'s posture: the slot survives the provisional-
/// window swap and reapplies itself to whichever window attaches next, and a window that is
/// genuinely going away takes its chrome with it.
func setExtendsContentUnderTitlebar(_ flag: Bool) {
extendsUnderTitlebar = flag
applyTitlebarExtensionIfPossible()
}
/// The two AppKit knobs the effect needs, and they are one decision: `fullSizeContentView` is
/// what lets the content view reach under the title bar, and `titlebarAppearsTransparent` is
/// what stops the title bar from painting its own material over it. Either alone is a visible
/// half-state an opaque bar over the board, or a board that stops at a bar that no longer
/// draws.
private func applyTitlebarExtensionIfPossible() {
guard let window, let extendsUnderTitlebar else { return }
window.titlebarAppearsTransparent = extendsUnderTitlebar
if extendsUnderTitlebar {
window.styleMask.insert(.fullSizeContentView)
} else {
window.styleMask.remove(.fullSizeContentView)
}
}
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
/// standard path runs SwiftUI's own delegate gets its callbacks, tabbing behaves with the
/// flag telling our own `windowShouldClose` to stand aside.
+14 -10
View File
@@ -345,17 +345,21 @@ extension BoardStore {
/// the write or removes the key, which is what "before" means for a field that was not there.
///
/// A **malformed** prior reads as a removal, and that is the one place an inverse is not
/// byte-exact: the app cannot re-emit `background: [a, b]` through a document edit that only
/// knows how to set scalars. It is also the case the forward write was designed to clear
/// ("choosing any well replaces it" 03-board-ui.md § Styling Controls), so the undo lands the
/// item on the app's own reading of that field rather than resurrecting a value nothing could
/// read.
/// byte-exact: the app cannot re-emit `background: [a, b]` or a hand-written scalar
/// `background: green`, which the schema stopped reading when the key became a mapping through
/// a document edit that only writes the shapes the schema names. It is also exactly the case the
/// forward write was designed to clear ("choosing any well replaces it" 03-board-ui.md §
/// Styling Controls), so the undo lands the item on the app's own reading of that field rather
/// than resurrecting a value nothing could read.
///
/// The **mapping** case needs no branch of its own here and gets none: `setStyleValue` edits the
/// `color` subkey and leaves the rest (BackgroundField.swift), so an undo on a board with an
/// image restores the colour the board had including restoring it to *absent* without
/// disturbing the image the forward write already preserved. What the inverse does not promise
/// is the author's subkey order when the colour was absent before: a restored colour that had no
/// pair to go back to is appended, like any newly written subkey.
static func restore(_ prior: FieldValue<String>, to key: String, in document: inout FrontmatterDocument) {
if let value = prior.value {
document.set(key, to: .string(value))
} else {
document.remove(key)
}
document.setStyleValue(prior.value, for: key)
}
/// The style fields a gesture actually set **one entry per dimension it did not `.keep`**, so a
+7 -2
View File
@@ -1931,11 +1931,16 @@ public final class BoardStore: HealHost {
}
}
/// Through `setStyleValue` rather than `set`/`remove` directly, which is this gesture's whole
/// answer to `background` being a mapping (BackgroundField.swift): the colour is written *into*
/// the key rather than over it, so a board carrying `background: {color: , image: }` comes out
/// of a colour change still carrying its image. `icon` takes the plain scalar path through the
/// same call.
private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) {
switch change {
case .keep: break
case let .set(value): document.set(key, to: .string(value))
case .remove: document.remove(key)
case let .set(value): document.setStyleValue(value, for: key)
case .remove: document.setStyleValue(nil, for: key)
}
}
+146
View File
@@ -0,0 +1,146 @@
import Foundation
/// **The write side of `background`** the read side is `FrontmatterDocument.background` and
/// `.backgroundImage` (FrontmatterFields.swift).
///
/// `background` is a mapping and only a mapping (01-storage-format.md § Frontmatter, ruled
/// 2026-08-06), so the app writes one: `{color: "#112233", image: sunset.jpg}`. That one key holds
/// two independent values and the app has a control for exactly one of them the colour grid
/// (03-board-ui.md § Styling Controls). There is no image picker and none is planned; the path is
/// hand-written, "the raw file is the escape hatch" applied one field over. So a style write edits
/// the **subkey, not the key**: choosing a well on a board that carries an image leaves the image
/// where it is, and the None well removes the colour alone.
///
/// A key that is *not* a mapping a retired scalar somebody hand-wrote, a sequence has no
/// subkeys to preserve and is simply replaced by the mapping the app writes. That is the
/// malformed-value-cleared posture `icon` already has ("choosing any well replaces it",
/// § Styling Controls), which is exactly right here: the reader could not make a colour of it
/// either.
///
/// ### Preservation is per subkey, not per byte
///
/// The document's surgical editor rewrites a key's whole value lines, and `FrontmatterValue` has no
/// mapping case to rewrite them with the engine emits scalars and has never round-tripped a
/// collection. So the merged value is re-emitted as a flow mapping through the `.raw` escape, built
/// from the *parsed* subvalues: every other subkey survives as a value and in its original position,
/// while its spelling does not a block mapping collapses to flow form, quoting is normalized, and
/// a subvalue's own inline comment is lost with the lines it sat on.
///
/// That is the narrowest place in the app where 01-storage-format.md's verbatim promise yields, and
/// it yields only on the one key the write was already rewriting: a board with no `background` key,
/// or one written as a plain scalar, takes exactly the path it always took. The alternative a
/// mapping-aware span editor is a great deal of machinery for a field with two subkeys, one of
/// which the app writes.
extension FrontmatterDocument {
/// Writes a lenient string style field `title`, `background`, `icon` or removes the key
/// when `value` is `nil`, which is what "before" means for a field that was not there and what
/// the None well leaves behind.
///
/// `title` and `icon` are a plain scalar `set`/`remove`, unchanged and unchangeable: their
/// values *are* strings.
///
/// **`background` is always written as a mapping**, whatever it held before. One already written
/// as one keeps it, with the `color` subkey replaced in place, appended when it was absent, or
/// dropped every other subkey carried through either way. Anything else starts from no subkeys
/// at all, so a colour lands as `{color: ""}` and a removal simply takes the key. A mapping the
/// removal empties takes the key with it too, because `background: {}` is a key that says nothing
/// and the removal's contract is that the field is gone.
///
/// Deliberately keyed on `background` rather than on "whatever is mapping-shaped": `icon` has no
/// subkey vocabulary at all, so a hand-written `icon: {a: 1}` a malformed value the forward
/// write exists to clear must be *replaced* by the chosen symbol, never merged into.
public mutating func setStyleValue(_ value: String?, for key: String) {
guard key == FrontmatterKeys.background else {
if let value {
set(key, to: .string(value))
} else {
remove(key)
}
return
}
// Only a mapping has subkeys worth carrying; every other shape absent, the retired scalar,
// a sequence starts empty and is replaced outright by what the app writes.
var existing: [YAMLValue.Pair] = []
if case let .mapping(pairs)? = self.value(for: key) { existing = pairs }
let merged = Self.merged(existing, subkey: FrontmatterKeys.Background.color, value: value)
if merged.isEmpty {
remove(key)
} else {
set(key, to: .raw(Self.flowMapping(merged)))
}
}
/// `pairs` with `subkey` set to `value`, or removed when it is `nil` **in place**: a subkey
/// that was already there comes back at the index it occupied, so the author's own key order
/// survives a colour change. One that was not there is appended, which is the only position that
/// says nothing about what the author intended.
private static func merged(
_ pairs: [YAMLValue.Pair],
subkey: String,
value: String?
) -> [YAMLValue.Pair] {
var merged = pairs.filter { $0.key != .string(subkey) }
guard let value else { return merged }
let pair = YAMLValue.Pair(key: .string(subkey), value: .string(value))
guard let index = pairs.firstIndex(where: { $0.key == .string(subkey) }) else {
merged.append(pair)
return merged
}
// Every survivor ahead of the old occurrence kept its index, so the old index is still the
// right hole; the clamp is belt-and-braces against a shape the parser cannot actually produce
// (a nested duplicate key is `unparseableYAML`, so at most one pair was filtered out).
merged.insert(pair, at: min(index, merged.count))
return merged
}
/// The pairs as a single-line YAML flow mapping the one form the span editor can write, since
/// it replaces a key's value with one line's worth of text.
private static func flowMapping(_ pairs: [YAMLValue.Pair]) -> String {
"{" + pairs.map { "\(flowKey($0.key)): \(flowText($0.value))" }.joined(separator: ", ") + "}"
}
/// A mapping key in flow context: plain when it is a bare word a letter or `_` first, then
/// letters, digits, `-`, `_`, `.` and emitted as a value otherwise.
///
/// The pretty case is the only one that occurs (`color`, `image`, an agent's own subkey) and is
/// worth keeping pretty: this text is read by hand. The fallback is what stops a key nobody
/// anticipated from breaking the collection it is written into.
private static func flowKey(_ value: YAMLValue) -> String {
guard case let .string(text) = value, let first = text.unicodeScalars.first,
CharacterSet.letters.contains(first) || first == "_",
text.unicodeScalars.allSatisfy({
CharacterSet.alphanumerics.contains($0) || $0 == "-" || $0 == "_" || $0 == "."
})
else { return flowText(value) }
return text
}
/// One value inside a flow collection.
///
/// **Strings are always double-quoted**, which is the rule that makes this safe without a YAML
/// emitter: `FrontmatterValue.emitScalar`'s round-trip check asks whether a value survives in
/// *block* context, and flow context ends a plain scalar at `,`, `]`, `}` and `: ` too so a
/// colour or a path that round-trips fine on its own line could still break the mapping it is
/// written into. Quoting costs two characters on a hex that would not have needed them, and a
/// hex is what this almost always writes.
///
/// The scalar cases route through `FrontmatterValue` rather than re-deriving their text, so a
/// preserved subkey's number or timestamp is emitted by the same code that writes `order` and
/// `created`. Nested collections recurse, which keeps an unknown subkey holding a list from
/// being flattened into its `description`.
private static func flowText(_ value: YAMLValue) -> String {
switch value {
case .null: "null"
case let .bool(value): FrontmatterValue.bool(value).yamlText
case let .int(value): FrontmatterValue.int(value).yamlText
case let .double(value): FrontmatterValue.double(value).yamlText
case let .date(value): FrontmatterValue.date(value).yamlText
case let .string(value): FrontmatterValue.emitQuoted(value)
case let .sequence(values): "[" + values.map(flowText).joined(separator: ", ") + "]"
case let .mapping(pairs):
"{" + pairs.map { "\(flowKey($0.key)): \(flowText($0.value))" }.joined(separator: ", ") + "}"
}
}
}
+1
View File
@@ -898,6 +898,7 @@ public enum BoardLoader: Sendable {
modifiedBy: boardDocument.modifiedBy,
deleted: boardDocument.deleted,
background: boardDocument.background,
backgroundImage: boardDocument.backgroundImage,
icon: boardDocument.icon,
iconColor: boardDocument.iconColor,
template: boardDocument.value(for: templateKey),
+15
View File
@@ -88,6 +88,21 @@ public struct BoardModel: Sendable, Equatable {
public let deleted: FieldValue<Date>
public let background: FieldValue<String>
/// The `background` mapping's `image` subkey a path **relative to `rootURL`**
/// (01-storage-format.md § Frontmatter; 03-board-ui.md § Styling Capabilities).
///
/// **Board-level only**, which is why `Lane` and `Card` carry no twin: a lane's and a card's
/// colour are edge accents, and there is nothing at those levels an image could fill. The
/// shared reader still accepts the mapping at every level for the colour's sake one key, one
/// reading but this half has exactly one consumer, the board window's backdrop.
///
/// A **reading, not a location**: the path is resolved (and required to stay inside the board)
/// where it is drawn, `BoardBackdrop.imageURL(named:inBoardRoot:)`, so a value that leads
/// nowhere paints nothing and stays on disk exactly as written the same lenient degrade an
/// unrecognized colour gets.
public let backgroundImage: FieldValue<String>
public let icon: FieldValue<String>
public let iconColor: FieldValue<String>
+13
View File
@@ -568,6 +568,19 @@ public enum FrontmatterKeys {
public static let icon = "icon"
public static let iconColor = "iconColor"
/// **The `background` mapping's subkeys** (01-storage-format.md § Frontmatter the field is a
/// mapping and nothing else, ruled 2026-08-06): `{color: "#112233", image: sunset.jpg}`, either
/// half absent, and any other subkey an author writes tolerated and carried through
/// (`FrontmatterDocument.setStyleValue`). A bare scalar has no reading at all.
///
/// **Named here without joining `schemaOwned`**, and for a plainer reason than `remote`'s: that
/// set is what `unknownFields` subtracts from the document's *top-level* keys, and these two are
/// inside one. A top-level `color:` or `image:` is somebody else's key and stays an unknown one.
public enum Background {
public static let color = "color"
public static let image = "image"
}
/// The object's kind `board`, `lane`, `card` (01-storage-format.md § Frontmatter Common to
/// all levels, re-ruled 2026-07-29). Written at creation of every object, backfilled on touch
/// when absent (`IntegrityRules.healOnTouch`), and never stripped.
+82 -1
View File
@@ -106,6 +106,13 @@ extension FrontmatterDocument {
record(FrontmatterKeys.modifiedBy, modifiedBy)
record(FrontmatterKeys.author, author)
record(FrontmatterKeys.background, background)
// **`background` can contribute two entries**, because the one key holds two readings once
// it is written as a mapping (`FrontmatterDocument.backgroundImage`). Both are filed under
// the key the schema spells, which is the key an author would go and fix; they are told
// apart by their raw text, since each quotes the subvalue that could not be read. A mapping
// whose colour reads fine and whose image does not still leaves a trace, which is the whole
// contract here.
record(FrontmatterKeys.background, backgroundImage)
record(FrontmatterKeys.icon, icon)
record(FrontmatterKeys.iconColor, iconColor)
record(FrontmatterKeys.kind, kind)
@@ -143,10 +150,54 @@ extension FrontmatterDocument {
/// (`title: 2048` reads as `"2048"`). Only a sequence or mapping no scalar reading exists
/// is malformed.
public var title: FieldValue<String> { read(FrontmatterKeys.title, Self.string) }
public var background: FieldValue<String> { read(FrontmatterKeys.background, Self.string) }
public var icon: FieldValue<String> { read(FrontmatterKeys.icon, Self.string) }
public var iconColor: FieldValue<String> { read(FrontmatterKeys.iconColor, Self.string) }
/// The `background` mapping's **colour** a palette name or a `#RRGGBB[AA]` hex, read through
/// the same scalar coercion every other string field uses.
///
/// **`background` is a mapping, and only a mapping** (01-storage-format.md § Frontmatter, ruled
/// 2026-08-06): `{color: "#112233", image: sunset.jpg}`, either subkey absent, unknown subkeys
/// tolerated. A bare scalar `background: green` has **no reading at all** and is
/// `.malformed`: it renders as no colour, files a coerce-tier trace, and stays on disk exactly as
/// written, which is the same lenient degrade a colour nobody can resolve already gets.
///
/// That is a ruling about the *schema*, not a migration: nothing had shipped when it was made, so
/// there is no legacy spelling to keep alive, no version bump, and no healing machinery. One key,
/// one shape, and a field whose type does not depend on which subkeys the author happened to
/// want.
///
/// **One reader for all three levels, deliberately.** A lane's and a card's `background` mean
/// colour and nothing else they are edge accents, and only the board consumes an image
/// (03-board-ui.md § Styling Capabilities) but the *shape* is uniform, so a lane writes
/// `{color: fern}` exactly as the board does and nothing below has to know which level it is
/// reading.
///
/// A mapping carrying no `color` an image-only background, or an explicit `color: null`
/// reads `.missing`, which is exactly "no colour" and renders the level's default; that is an
/// absence, not a failure, and it files no trace. A `color` that is itself a sequence or mapping
/// has no scalar reading and is `.malformed` like any other.
public var background: FieldValue<String> {
backgroundReading(FrontmatterKeys.Background.color, reportsShape: true)
}
/// The `background` mapping's **image** a path relative to the board root, board-only in
/// meaning (`BoardModel` carries it; `Lane` and `Card` deliberately do not).
///
/// `.missing` for every shape that is not a mapping, including the retired scalar: a value the
/// schema cannot read is **one** unreadable value, and the colour reading above already reports
/// it. Two `.malformed`s off one key would file the same defect twice and say the file named an
/// image when it did nothing of the kind.
///
/// **Where the path leads is not this layer's question.** Whether it resolves inside the board
/// root, and whether the bytes are an image at all, belongs to the renderer
/// (`BoardBackdrop.imageURL(named:inBoardRoot:)`); this is the document's reading of what was
/// written, and an unresolvable path degrades exactly like an unrecognized colour paint
/// nothing, change nothing on disk.
public var backgroundImage: FieldValue<String> {
backgroundReading(FrontmatterKeys.Background.image, reportsShape: false)
}
/// Width multiplier. An exact-integer reading from an int, a double, or a numeric string
/// always coerces: at or above 1 to itself (`"2"`, `2.0` `2`), below 1 to 1 (**ranges are
/// part of the sensible reading**, 01-storage-format.md § Frontmatter, settled the table's
@@ -190,6 +241,36 @@ extension FrontmatterDocument {
// MARK: -
/// One subkey's reading out of the `background` mapping. It is `read(_:_:)`'s shape with one
/// extra step, and it cannot *be* `read(_:_:)`: that helper's transform answers `nil` for "no
/// sensible reading", where a mapping with no such subkey has to answer `.missing` an absent
/// subkey is an absent value, not an unreadable one, and reporting it as a coerce-tier fallback
/// would file a defect against every image-only background in existence.
///
/// `reportsShape` is the whole difference between the two readers above, and it is about the
/// **key's** shape rather than the subkey's: a value that is not a mapping at all the retired
/// scalar, a sequence is one unreadable value, so exactly one reader reports it. The colour is
/// that reader because the colour is what the key means when it has no subkeys to speak of; the
/// image stays `.missing`, since a file that never wrote a mapping never claimed to name a
/// picture.
///
/// **A subvalue's malformed raw is the parse's rendering, not a source span.** `rawValue(for:)`
/// addresses top-level keys, so a subkey has no span to quote; the coerce record takes what the
/// parse retained (`YAMLValue.description`), which is the most this shape can honestly offer and
/// still names what could not be read. The key's *own* malformed raw is the span, as always.
private func backgroundReading(_ subkey: String, reportsShape: Bool) -> FieldValue<String> {
guard let value = value(for: FrontmatterKeys.background) else { return .missing }
if case .null = value { return .missing }
guard case let .mapping(pairs) = value else {
guard reportsShape else { return .missing }
return .malformed(raw: rawValue(for: FrontmatterKeys.background) ?? value.description)
}
guard let subvalue = pairs.first(where: { $0.key == .string(subkey) })?.value else { return .missing }
if case .null = subvalue { return .missing }
let raw = subvalue.description
return Self.string(subvalue, raw: raw).map(FieldValue.valid) ?? .malformed(raw: raw)
}
private func read<Value>(_ key: String, _ transform: (YAMLValue, String) -> Value?) -> FieldValue<Value> {
guard let value = value(for: key) else { return .missing }
if case .null = value { return .missing }
+6
View File
@@ -61,6 +61,12 @@ extension FrontmatterValue {
return value
}
/// The double-quoted form unconditionally what a value inside a **flow collection** takes
/// (`FrontmatterDocument.setStyleValue`), where `emitScalar`'s block-context round trip is not
/// the right question: `,`, `]`, `}` and `: ` end a plain scalar in flow context and not on a
/// line of its own.
static func emitQuoted(_ value: String) -> String { quoted(value) }
private static func quoted(_ value: String) -> String {
var out = "\""
for scalar in value.unicodeScalars {
+19 -4
View File
@@ -90,19 +90,28 @@ enum Accommodations {
/// appear").
///
/// The design's own example (the card face carousel's page dots) died with the carousel
/// (03-board-ui.md § Card face's no-carousel resettlement), so the rule's one surviving subject
/// on the board is the transient search bar's `.bar` material. It is stated as a type anyway
/// rather than inlined at that one call site, because "wherever they appear" is a standing rule
/// and the next material to arrive should find the answer already written.
/// (03-board-ui.md § Card face's no-carousel resettlement), so the rule's subjects on the board
/// are the transient search bar's `.bar` material and the backdrop's title-bar frost the
/// "next material to arrive" this type was stated for, and it found the answer already written.
enum Underlay: Equatable {
/// `Material.bar` the find-bar's own backdrop, translucent over the board beneath it.
case glass
/// `Material.thin` the title-bar frost over a custom board backdrop
/// (`BoardView.boardBackground`). Deliberately not `.bar` the find-bar sits over lanes
/// the board's own plates have already calmed, where the frost sits directly on an image
/// the author may well have chosen *for* its busyness and deliberately not the heavier
/// notches either, tried and retired: `.ultraThick` read as a cloudy plate where a backdrop
/// should still show through, and `.regular` still veiled it more than the chrome needs.
/// The thin weight carries the chrome, and the dissolve below it (`BoardView.frostStrip`)
/// is what keeps the strip from reading as a bar.
case frost
/// The window's own background colour, opaque.
case solid
var style: AnyShapeStyle {
switch self {
case .glass: AnyShapeStyle(.bar)
case .frost: AnyShapeStyle(.thinMaterial)
case .solid: AnyShapeStyle(Color(nsColor: .windowBackgroundColor))
}
}
@@ -112,6 +121,12 @@ enum Accommodations {
reduceTransparency ? .solid : .glass
}
/// The title-bar frost's own reading of the same rule heavier glass, identical accommodation:
/// under Reduce Transparency both underlays take the one solid.
static func frost(reduceTransparency: Bool) -> Underlay {
reduceTransparency ? .solid : .frost
}
/// A **translucent wash** a tint laid over whatever happens to be behind it, and the shape
/// every non-material translucency on the board takes: every lane's plate, the trash column's
/// plate and hatched header, and the drag shadow's fill.
+212
View File
@@ -0,0 +1,212 @@
import CoreGraphics
import ImageIO
import SwiftUI
import os
// MARK: - BoardBackdrop
/// **The board background's image half** (03-board-ui.md § Styling Capabilities; the `background`
/// mapping's `image` subkey, 01-storage-format.md § Frontmatter).
///
/// ### The path is relative, and it stays inside the board
///
/// `image: sunset.jpg` names a file in the board folder; `image: art/sunset.jpg` names one in a
/// subfolder of it. An absolute path, or any path that climbs out with `..`, resolves to **nothing**
/// the same lenient degrade as an unrecognized colour, and for the same two reasons. A `.kanban`
/// folder is a document: it is what gets copied, zipped, synced and handed to somebody else, and a
/// background pointing at `/Users/someone/Pictures` would silently stop working the moment it left
/// this Mac. And the sandbox would refuse the read anyway the board's own security-scoped access
/// is the only thing this app holds so the rule the containment check states is the rule the
/// system would enforce one layer down, stated where it can be explained instead of failing.
///
/// The check is **lexical**, which is what makes it testable without a filesystem, and it is not the
/// security boundary: a symlink inside the board pointing anywhere at all still resolves here and is
/// still refused by the sandbox when the bytes are asked for. That is the correct division 01's
/// "symlinks are never traversed" governs what the *loader* renders as items, and this reads bytes
/// nobody has an identity claim on.
///
/// ### Nothing here decides whether the file is any good
///
/// A path that resolves, a file that is missing, and a file that is not an image all end the same
/// way: no image, no banner, no defect, bytes untouched. There is no editing UI for the field at all
/// (Controls: "the raw file is the escape hatch"), so the one person who can be wrong about it is
/// the one person looking at the folder.
enum BoardBackdrop {
/// The longest edge, in pixels, the backdrop is ever decoded at.
///
/// Generous enough for a 6K display's short side and for the Retina backing of any window a
/// board is realistically shown in, and small enough that a 60-megapixel photo dropped in the
/// folder never becomes a 240 MB decode on a window resize. ImageIO does the reduction while it
/// reads (`decode`), so the full-size bitmap is never materialized at all.
static let maximumPixelSize = 3072
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-backdrop")
/// Where `path` lands inside `root`, or `nil` when it lands nowhere this board may read.
///
/// Standardized before the comparison so `art/../sunset.jpg` is recognized as the file it names
/// the check is about where the path *ends up*, not how it is spelled. The trailing separator
/// on the root is what keeps a sibling board named `Boards/Work.kanban.backup` from passing a
/// prefix test against `Boards/Work.kanban`.
///
/// `~` is not expanded and is not special: a file honestly named `~notes.png` sitting in the
/// board folder resolves, because only the shell ever meant anything else by that character.
static func imageURL(named path: String, inBoardRoot root: URL) -> URL? {
// An absolute path has to be rejected before it is appended, not after: appending `/etc/x`
// to a root yields `<root>/etc/x`, which *passes* containment while naming a file the author
// plainly did not mean.
guard !path.isEmpty, !path.hasPrefix("/") else { return nil }
let root = root.standardizedFileURL
let candidate = root.appendingPathComponent(path).standardizedFileURL
guard candidate.path.hasPrefix(root.path + "/") else { return nil }
return candidate
}
/// This board's backdrop image, where it has a readable one to name.
static func imageURL(for board: BoardModel, root: URL) -> URL? {
guard let path = board.backgroundImage.value else { return nil }
return imageURL(named: path, inBoardRoot: root)
}
/// Whether this board paints a background of its own **the window-chrome predicate**
/// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one
/// runs its content under a transparent title bar, and a board without one keeps the standard
/// chrome exactly as it has always looked.
///
/// It asks the *resolved* image URL rather than merely whether the key reads, so a path that
/// could never paint anything absolute, or climbing out of the board leaves the chrome alone
/// instead of producing a transparent title bar over the standard background. It does **not**
/// ask whether the file exists: that is a disk touch, this is read on every board render, and a
/// declared-but-missing image renders as the frosted strip alone which is the honest picture of
/// a board that asked for a backdrop it has not got.
static func isCustom(_ board: BoardModel, root: URL) -> Bool {
Palette.color(for: board.background) != nil || imageURL(for: board, root: root) != nil
}
// MARK: Reading the bytes
/// The file's identity as far as reloading is concerned modification date and size.
///
/// Both, because either alone is forgeable by an ordinary copy: a file replaced within the
/// timestamp's resolution keeps its date, and a re-export at the same instant rarely keeps its
/// byte count too. Missing values (a file that is not there) compare equal to each other, which
/// is what stops a board naming a missing image from re-decoding on every reload.
struct Stamp: Equatable, Sendable {
var modified: Date?
var size: Int?
}
static func stamp(of url: URL) -> Stamp {
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
return Stamp(modified: values?.contentModificationDate, size: values?.fileSize)
}
/// Decodes the file at `url`, downsampled to `maximumPixelSize` on its longest edge or `nil`
/// for anything that is not a readable image.
///
/// **ImageIO's thumbnail path, not a full decode plus a resize**: `CGImageSourceCreateThumbnail
/// AtIndex` reads at a reduced scale, so the peak allocation is the *output* size rather than
/// the file's. `FromImageAlways` is what makes it a downsample rather than a lottery without
/// it a JPEG carrying its own small embedded thumbnail would answer with that instead of the
/// picture. `WithTransform` applies the EXIF orientation, so a photo shot in portrait is not
/// laid on its side.
///
/// Never call this on the main actor; see `BoardBackdropImage`'s task.
static func decode(_ url: URL) -> CGImage? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize,
]
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
logger.debug("board backdrop image could not be decoded")
return nil
}
return image
}
}
// MARK: - BoardBackdropImage
/// The decoded backdrop, drawn to fill (03-board-ui.md § Styling Capabilities).
///
/// **Fill, cropped never letterboxed and never stretched.** A background is a surface, so it
/// covers the window whatever its aspect ratio; the alternative would put bars of the underlying
/// colour along two edges and make the board look broken rather than styled.
///
/// ### The load is asynchronous, and that is the whole design of this view
///
/// A board is opened by double-clicking a folder, and the folder may contain a 60-megapixel
/// photograph. Decoding that on the main actor during a body evaluation is a visible hitch on open
/// and a worse one on every subsequent reload, so the work happens off it and the view simply has
/// nothing to draw until it lands under the board's colour, which is already painted beneath.
///
/// The task is keyed on the URL and on the store's landed-reload count, which is the board's
/// FSEvents pulse: replacing `sunset.jpg` in Finder changes no *model* value, so the snapshot comes
/// back equal and `snapshotGeneration` deliberately does not move (`BoardStore.landedReloads`)
/// keying on the generation would mean an edited image never reloaded. Every re-key costs one
/// `stat`; only a file that actually changed costs a decode.
struct BoardBackdropImage: View {
let url: URL
/// The board's landed-reload count see the type's note. Not read from a store here because
/// this view has no other reason to hold one.
let reloads: Int
/// What is on screen, and what it was decoded from. One value rather than three `@State`s so a
/// URL, its stamp and its bitmap can never disagree about which file is being shown.
@State private var loaded: Loaded?
private struct Loaded {
let url: URL
let stamp: BoardBackdrop.Stamp
let image: CGImage
}
var body: some View {
// `Color.clear` establishes the frame the image fills and is what `clipped` trims against;
// the overlay is what overflows it. Decorative, because a board background is decoration in
// the precise sense 10-accessibility.md means it carries no information VoiceOver could
// usefully say, and the ink rule keeps the text on it legible on its own.
Color.clear
.overlay {
if let loaded {
Image(decorative: loaded.image, scale: 1)
.resizable()
.aspectRatio(contentMode: .fill)
}
}
.clipped()
.task(id: Key(url: url, reloads: reloads)) { await reload() }
}
/// The `.task` identity: the file, and the board's pulse.
private struct Key: Equatable {
let url: URL
let reloads: Int
}
/// Re-decodes when the bytes have changed, and only then.
///
/// `Task.detached` rather than a bare `await` on a `nonisolated` function, so the hop off this
/// view's actor is stated rather than inferred from whatever the language mode currently makes
/// of an async call. Cancellation is checked on the way back instead of forwarded into it: both
/// halves are short, and a stale bitmap assigned to a view that has gone away is the failure
/// worth preventing.
private func reload() async {
let url = url
let stamp = await Task.detached(priority: .utility) { BoardBackdrop.stamp(of: url) }.value
if let loaded, loaded.url == url, loaded.stamp == stamp { return }
guard !Task.isCancelled else { return }
let decoded = await Task.detached(priority: .userInitiated) { BoardBackdrop.decode(url) }.value
guard !Task.isCancelled else { return }
// A failure clears what was there: the file the board names is the file it shows, and
// holding the previous picture would make a broken path look like a working one.
loaded = decoded.map { Loaded(url: url, stamp: stamp, image: $0) }
}
}
+5 -4
View File
@@ -499,10 +499,11 @@ struct BoardSearchBar: View {
let store: BoardStore
let presentation: BoardSearchPresentation
/// Reduce Transparency **this bar is the board's one glass underlay** ("glass underlays go
/// solid, wherever they appear", 10-accessibility.md; the design's own example, the card face
/// carousel's page dots, died with the carousel). `.bar` is a material, so under the setting it
/// becomes the opaque window background (`Accommodations.Underlay`).
/// Reduce Transparency this bar is one of the board's two glass underlays, beside the
/// backdrop's title-bar frost ("glass underlays go solid, wherever they appear",
/// 10-accessibility.md; the design's own example, the card face carousel's page dots, died with
/// the carousel). `.bar` is a material, so under the setting it becomes the opaque window
/// background (`Accommodations.Underlay`).
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
+99 -8
View File
@@ -106,6 +106,11 @@ struct BoardView: View {
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
@Environment(\.colorSchemeContrast) private var contrast
/// Reduce Transparency, for the backdrop's title-bar frost the one glass underlay this view
/// draws (10-accessibility.md: "glass underlays go solid, wherever they appear";
/// `Accommodations.frost`).
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
/// must go back to meaning create/rename rather than nothing at all.
@@ -418,13 +423,42 @@ struct BoardView: View {
/// is what the design asks for and it is why the board is the level 10-accessibility.md binds
/// its 4.5:1 rule to: text does sit on it.
///
/// ### Three layers, and the window is the frame
///
/// The colour is the underlay, the image draws over it, and a frosted strip sits on top of both
/// under the title bar. All of it runs the **full height of the window** `ignoresSafeArea`
/// here is what the window's own `fullSizeContentView` flip is for (`HostedWindowController
/// .setExtendsContentUnderTitlebar`, driven from `BoardWindowHost`), and the two only ever move
/// together: a board with no background of its own draws none of this and keeps the standard
/// chrome exactly as it has always looked.
///
/// The **colour is painted even while an image is loading**, and stays painted underneath it: a
/// decode is asynchronous (`BoardBackdropImage`) and a window that flashed the system background
/// on open would be the hitch that work exists to avoid. It is also what a failed or missing
/// image degrades to, with nothing said about it.
///
/// The **frost** is the price of the extended chrome: the title bar's own material is gone, so
/// the traffic lights, the board-name widget and the toolbar would otherwise sit directly on a
/// saturated colour or a photograph. It is a glass underlay (`Accommodations.frost` the
/// ladder's thin weight, both ends tried and retired: `.bar` reads as barely-there over a
/// busy image, `.regular` and up as a veil the backdrop shouldn't pay for) and takes the
/// standard accommodation: solid
/// under Reduce Transparency, "wherever they appear". Its geometry is `frostStrip`'s full
/// strength through the top safe-area inset, dissolving over a short tail below it with the
/// inset read from a `GeometryReader` that is itself inside the `ignoresSafeArea`: the proxy
/// still reports the inset it was told to ignore, which is exactly the title-bar-plus-toolbar
/// band and moves on its own when the toolbar's size class changes. Nothing here hit-tests, so
/// the widget and the toolbar above it are untouched.
///
/// ### The contrast rule is the colour's, and the image is outside it
///
/// The rule is enforced from the *text* side rather than here, because this view paints the
/// surface and draws none of the glyphs on it. Whatever colour lands below a palette name or a
/// hand-written hex, they reach the same place has its text colour computed against the
/// threshold by `BoardTextInk`, composited over the window background in the active appearance
/// and recomputed on an appearance flip; the two subtrees that sit on this fill
/// (`LaneView.header` and `TrashLaneView.header` their plates are translucent washes the
/// colour shows through, where every card carries its own opaque plate,
/// surface and draws none of the glyphs on it. Whatever colour lands below a palette name, a
/// hand-written hex, or the `color` subkey of the mapping form, they reach the same place has
/// its text colour computed against the threshold by `BoardTextInk`, composited over the window
/// background in the active appearance and recomputed on an appearance flip; the two subtrees
/// that sit on this fill (`LaneView.header` and `TrashLaneView.header` their plates are
/// translucent washes the colour shows through, where every card carries its own opaque plate,
/// `BoardSurface.cardPlate`) take the answer as a `\.colorScheme` override.
///
/// **One path, two verification stories** (`ContrastMath`): the twelve palette pairs are checked
@@ -433,12 +467,69 @@ struct BoardView: View {
/// cannot be settled by a table of colours alone); an arbitrary hex is checked only as it
/// renders, because its value arrives from a file.
///
/// **An image makes no AA claim at all**, and the ink does not try to derive one from it. Ink
/// still follows the `color` reading the colour the author chose to sit under the picture, or
/// the default when they chose none which is the same bytes-from-a-file posture an arbitrary
/// hex already has, one step further out: a photograph has no single luminance to threshold
/// against, the field has no in-app control that could warn about one, and a per-pixel answer
/// would change as the window resized. An author who lays text over a busy picture is doing what
/// the raw file exists to let them do.
///
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
/// the same lenient degrade as the other two levels, and the bytes stay as written.
@ViewBuilder
private var boardBackground: some View {
if let color = Palette.color(for: store.snapshot.background) {
color
let color = Palette.color(for: store.snapshot.background)
let image = BoardBackdrop.imageURL(for: store.snapshot, root: store.rootURL)
if color != nil || image != nil {
GeometryReader { proxy in
ZStack(alignment: .top) {
color
if let image {
BoardBackdropImage(url: image, reloads: store.landedReloads)
}
frostStrip(inset: proxy.safeAreaInsets.top)
}
}
.ignoresSafeArea()
// A background is scenery: the strip's own empty-surface gestures the click that
// clears the selection, the rubber band live in `backdrop`, one layer in, and would be
// swallowed by anything here that answered a hit test.
.allowsHitTesting(false)
}
}
/// The frost, full-strength through the title-bar band and dissolving over a short tail below
/// it a scroll-edge dissolve rather than a shelf. The chrome sits on an even material the
/// whole way down, and the strip's bottom edge is nowhere in particular, so the backdrop reads
/// as one surface the chrome floats over rather than a bar laid across a picture. The tail is a
/// fraction of the band, so it scales with the toolbar's own height and only ever reaches into
/// the strip's outer padding, not the lanes.
///
/// Under Reduce Transparency the fade goes with the glass: "solid" means an honest opaque bar
/// with the standard chrome's own hard edge (`Accommodations.frost`), not a solid that thins
/// out a partially transparent solid would be the setting's own defeat.
@ViewBuilder
private func frostStrip(inset: CGFloat) -> some View {
let underlay = Accommodations.frost(reduceTransparency: reduceTransparency)
if underlay == .solid {
Rectangle().fill(underlay.style).frame(height: inset)
} else if inset > 0 {
let tail = inset * 0.35
Rectangle()
.fill(underlay.style)
.frame(height: inset + tail)
.mask {
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black, location: inset / (inset + tail)),
.init(color: .clear, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
}
}
}