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: ", ") + "}" } } }