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:
@@ -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: ", ") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user