A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
352 lines
20 KiB
Swift
352 lines
20 KiB
Swift
import Foundation
|
|
import Yams
|
|
|
|
/// One top-level frontmatter entry: what YAML made of it plus the text as written.
|
|
public struct FrontmatterField: Sendable, Equatable {
|
|
public let key: String
|
|
public let value: YAMLValue
|
|
public let rawValue: String
|
|
}
|
|
|
|
/// The result of reading a typed field — **the document's reading, before any policy**. Lenient
|
|
/// fields (colors, icons, `width`) coerce where a sensible reading exists (01-storage-format.md
|
|
/// § Frontmatter) and only fall back to `.malformed` — rendered as the field's default — when none
|
|
/// does. Structural fields (`schema`, `order`) have no coercion at all at this layer: a value that
|
|
/// is not an integer, or not a finite number, is `.malformed` and nothing here decides what that
|
|
/// costs. **What it costs is the rulebook's** (`IntegrityRules`): a malformed `schema` refuses the
|
|
/// load, while a malformed or missing `order` below the board root reads as append-at-end
|
|
/// (re-ruled 2026-07-31) — one shape, two policies, and this type carries neither.
|
|
public enum FieldValue<Value: Sendable & Equatable>: Sendable, Equatable {
|
|
case missing
|
|
case valid(Value)
|
|
case malformed(raw: String)
|
|
|
|
public var value: Value? {
|
|
if case let .valid(value) = self { return value }
|
|
return nil
|
|
}
|
|
|
|
public var isMissing: Bool {
|
|
if case .missing = self { return true }
|
|
return false
|
|
}
|
|
|
|
public var isMalformed: Bool {
|
|
if case .malformed = self { return true }
|
|
return false
|
|
}
|
|
|
|
/// The value as written, for a malformed field.
|
|
public var rawText: String? {
|
|
if case let .malformed(raw) = self { return raw }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// One **lenient** field that had no sensible reading and fell back to its default — the coerce
|
|
/// tier's observability record (01-storage-format.md § Frontmatter, ruled 2026-07-29: "A
|
|
/// no-sensible-reading fallback logs: field, path, and raw text, carried as coerce-tier entries in the
|
|
/// integrity service's Defect stream").
|
|
///
|
|
/// **Two of the three facts, because this layer only has two.** `FrontmatterFields` is a pure reading
|
|
/// of one document's bytes and has no idea which file it came from, so the *path* is attached by the
|
|
/// loader, which does (`CoercedFrontmatter`). Threading a path down here to satisfy the record's shape
|
|
/// would put filesystem context into the one layer that is deliberately free of it.
|
|
public struct CoercedField: Sendable, Equatable {
|
|
/// The frontmatter key, as the schema spells it.
|
|
public let key: String
|
|
/// The value exactly as written — the raw source span, which is the only form worth recording:
|
|
/// this exists so that a shape observed in the wild can later be promoted to a heuristic heal, and
|
|
/// a normalized rendering of a value nobody could read would defeat that.
|
|
public let raw: String
|
|
|
|
public init(key: String, raw: String) {
|
|
self.key = key
|
|
self.raw = raw
|
|
}
|
|
}
|
|
|
|
extension FrontmatterDocument {
|
|
|
|
// MARK: - The coerce tier's own report
|
|
|
|
/// Every **lenient** field in this document that had no sensible reading, in schema order — the
|
|
/// coerce tier's whole observability contribution (01-storage-format.md § Frontmatter, ruled
|
|
/// 2026-07-29: "the family posture: every silent recovery leaves a trace").
|
|
///
|
|
/// **Read-side only, and nothing branches on it.** The values still render as their defaults —
|
|
/// untitled placeholder, width 1, no color, no icon, no timestamp — the bytes on disk are still
|
|
/// preserved verbatim, and no banner is raised. The list exists so the fallback is *visible*: it is
|
|
/// "the one place where an observed-in-the-wild shape can later be promoted to a heuristic heal or
|
|
/// a notice".
|
|
///
|
|
/// **The structural fields are absent, and so is `deleted`.** `schema` and `order` are read
|
|
/// through the rulebook rather than here (`IntegrityRules.resolvedSchema`/`resolvedOrder`),
|
|
/// because their readings depend on something this layer cannot see — *which level the file sits
|
|
/// at*: the root's `schema` refuses, a lane's or card's defaults to 1, and `order` below the root
|
|
/// reads as append-at-end (re-ruled 2026-07-31). Those two make their own `CoercedField`s, which
|
|
/// the loader concatenates with this list; they also cover the shape a pure document reading
|
|
/// cannot produce at all — an **absent** key, which has no `rawText` to record. `deleted` is the
|
|
/// odd one out on purpose: its rule is *presence, not validity* (a malformed `deleted` still
|
|
/// deletes — `Lane`/`Card.isDeleted`), so nothing falls back to a default and the migration
|
|
/// reports it under its own defect anyway.
|
|
///
|
|
/// A document whose fields all read cleanly answers `[]`, which is the overwhelmingly common case
|
|
/// and costs one pass over the lenient fields.
|
|
public var coercedFields: [CoercedField] {
|
|
var found: [CoercedField] = []
|
|
func record(_ key: String, _ field: FieldValue<some Sendable & Equatable>) {
|
|
guard let raw = field.rawText else { return }
|
|
found.append(CoercedField(key: key, raw: raw))
|
|
}
|
|
record(FrontmatterKeys.title, title)
|
|
record(FrontmatterKeys.width, width)
|
|
record(FrontmatterKeys.collapsed, collapsed)
|
|
record(FrontmatterKeys.created, created)
|
|
record(FrontmatterKeys.modified, modified)
|
|
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)
|
|
return found
|
|
}
|
|
|
|
// MARK: - Structural (no coercion here — `IntegrityRules` decides what `.malformed` costs)
|
|
|
|
public var schema: FieldValue<Int> {
|
|
read(FrontmatterKeys.schema) { value, _ in if case let .int(value) = value { value } else { nil } }
|
|
}
|
|
|
|
/// A non-finite reading (`.nan`, `.inf`) has no place in the total order the tie-break and
|
|
/// midpoint math assume (01-storage-format.md § Frontmatter, settled) — it is `.malformed` here,
|
|
/// never a `.valid(Double.nan)` silently poisoning every comparison downstream. An `Int` reading
|
|
/// is always finite, so only the `.double` case needs the check.
|
|
///
|
|
/// Below the board root `.malformed` and `.missing` are one reading — append-at-end, coerce-tier
|
|
/// (re-ruled 2026-07-31, `IntegrityRules.resolvedOrder`) — but they stay two shapes here, because
|
|
/// the coerce record wants the text as written and only one of them has any.
|
|
public var order: FieldValue<Double> {
|
|
read(FrontmatterKeys.order) { value, _ in
|
|
switch value {
|
|
case let .int(value): Double(value)
|
|
case let .double(value): value.isFinite ? value : nil
|
|
default: nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Lenient (coerce where a sensible reading exists, else malformed → UI default)
|
|
|
|
/// Any scalar coerces to the text the author typed — a quoted string's own text (quotes and
|
|
/// escapes already resolved by the parser), or an unquoted scalar's exact source span
|
|
/// (`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 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
|
|
/// "≥ 1" is a validity bound on the coerced reading, not a gate on which readings are
|
|
/// sensible). Everything else — fractional, non-numeric, bool, a sequence/mapping — has no
|
|
/// integer reading at all and is malformed, rendering as the default 1.
|
|
public var width: FieldValue<Int> {
|
|
read(FrontmatterKeys.width) { value, _ in
|
|
switch value {
|
|
case let .int(value): value >= 1 ? value : 1
|
|
case let .double(value): Self.exactIntWidth(value)
|
|
case let .string(text): Double(text).flatMap(Self.exactIntWidth)
|
|
default: nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// **Whether the lane is folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) —
|
|
/// `width`'s reading one type over, and lenient in exactly its way.
|
|
///
|
|
/// A `true`/`false` scalar reads as itself, and so does any of YAML 1.1's other boolean spellings
|
|
/// (`yes`/`no`, `on`/`off`, `y`/`n`) written **quoted**: unquoted, the parser has already resolved
|
|
/// them to `.bool` for us, and a quoted one is the same word the author meant — the numeric string
|
|
/// `width: "2"` coercing to `2` is the precedent, and it is the one thing lenient reading means.
|
|
/// Everything else — a number, a sequence, a mapping, `collapsed: maybe` — has no boolean reading
|
|
/// at all and is malformed, **which renders as expanded**: the default is the absent key's, so an
|
|
/// unreadable value can only ever fold a lane by accident, and it never does.
|
|
///
|
|
/// Read-side only, like every other coercion here: the bytes stay exactly as the author wrote them
|
|
/// until the user (or an agent) changes the key, and `collapsed: false` is a perfectly good way to
|
|
/// write "expanded" by hand even though the app removes the key instead
|
|
/// (`FrontmatterKeys.collapsed`).
|
|
public var collapsed: FieldValue<Bool> {
|
|
read(FrontmatterKeys.collapsed) { value, _ in
|
|
switch value {
|
|
case let .bool(value): value
|
|
case let .string(text): Self.boolWord(text)
|
|
default: nil
|
|
}
|
|
}
|
|
}
|
|
|
|
public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
|
|
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
|
|
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } }
|
|
|
|
/// Schema-owned, not an unknown key: the app clears it on every app-mediated write.
|
|
public var modifiedBy: FieldValue<String> { read(FrontmatterKeys.modifiedBy, Self.string) }
|
|
|
|
/// A comment's self-reported `author` — lenient like every other string field, and **content**
|
|
/// rather than overlay: no app write ever clears it (01-storage-format.md § Enhanced schema; see
|
|
/// `FrontmatterKeys.author`). On any other kind the key is an ordinary unknown one; reading it
|
|
/// here costs the same one pass and keeps the coerce tier's trace complete for the one kind whose
|
|
/// field table names it.
|
|
public var author: FieldValue<String> { read(FrontmatterKeys.author, Self.string) }
|
|
|
|
/// The object's kind as written — `board`, `lane`, `card` (01-storage-format.md § Frontmatter,
|
|
/// re-ruled 2026-07-29). Lenient like every other string field: any scalar coerces to the text
|
|
/// the author typed, and **the value is never policed** — a reading outside the schema's three
|
|
/// is a perfectly good `.valid` here, and the one consumer that acts on it (the trash's
|
|
/// discriminator, `IntegrityRules.trashKind`) falls through to shape for anything it does not
|
|
/// recognize rather than correcting the file.
|
|
///
|
|
/// `.missing` — no key, or an explicit null — is what the on-touch backfill answers to.
|
|
public var kind: FieldValue<String> { read(FrontmatterKeys.kind, Self.string) }
|
|
|
|
// 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 }
|
|
let raw = rawValue(for: key) ?? value.description
|
|
if let typed = transform(value, raw) { return .valid(typed) }
|
|
return .malformed(raw: raw)
|
|
}
|
|
|
|
/// A quoted string reads as its own (already-unquoted, already-unescaped) text; any other
|
|
/// scalar reads as its raw source span — what the author typed. Only a sequence/mapping has
|
|
/// no sensible string reading.
|
|
private static func string(_ value: YAMLValue, raw: String) -> String? {
|
|
switch value {
|
|
case let .string(text): text
|
|
case .int, .double, .bool, .date: raw
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
/// An integer-valued double or numeric string coerces — below 1 to 1, at or above 1 to the
|
|
/// value itself; a fractional reading, a non-numeric one, or one outside `Int` range on the
|
|
/// high end has no sensible width reading at all. The high-end guard is what makes `Int(value)`
|
|
/// safe below; there is no matching low-end guard because anything below 1 short-circuits to
|
|
/// the literal `1` without ever converting the (possibly enormous negative) double to `Int`.
|
|
private static func exactIntWidth(_ value: Double) -> Int? {
|
|
guard value.truncatingRemainder(dividingBy: 1) == 0, value <= Double(Int.max) else { return nil }
|
|
return value >= 1 ? Int(value) : 1
|
|
}
|
|
|
|
/// YAML 1.1's boolean words, for a **quoted** scalar that spells one — the coercion `collapsed`
|
|
/// needs and no other field wants. Case-insensitive, because the grammar itself is (`True`, `ON`,
|
|
/// `Yes` are all booleans unquoted); anything else has no boolean reading.
|
|
private static func boolWord(_ text: String) -> Bool? {
|
|
switch text.lowercased() {
|
|
case "true", "yes", "on", "y": true
|
|
case "false", "no", "off", "n": false
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
/// A quoted timestamp reads the same as an unquoted one — same YAML 1.1 timestamp grammar,
|
|
/// a superset of ISO-8601. Anything that isn't a valid timestamp either way has no sensible
|
|
/// date reading and is malformed; per the tombstone rule (`Lane`/`Card.isDeleted`), a
|
|
/// malformed `deleted` still deletes — presence, not validity, is what counts.
|
|
private static func date(_ value: YAMLValue) -> Date? {
|
|
switch value {
|
|
case let .date(date): date
|
|
case let .string(text): Date.construct(from: Node.Scalar(text))
|
|
default: nil
|
|
}
|
|
}
|
|
}
|