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
+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 }