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,291 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The board background's **mapping form** — the write side that has to preserve what the app has no
|
||||
/// control over, and the path rule that decides where an `image:` may point (01-storage-format.md §
|
||||
/// Frontmatter; 03-board-ui.md § Styling ▸ Capabilities).
|
||||
///
|
||||
/// The *readings* live with their siblings in `FrontmatterTests`; what is here is everything that is
|
||||
/// new machinery rather than a new value: `FrontmatterDocument.setStyleValue` (BackgroundField.swift)
|
||||
/// and `BoardBackdrop.imageURL(named:inBoardRoot:)`.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private func document(_ frontmatter: String) throws -> FrontmatterDocument {
|
||||
try FrontmatterDocument.parse("---\n\(frontmatter)\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// The `background:` line as it now reads on disk, or `nil` when the key is gone.
|
||||
private func backgroundLine(_ document: FrontmatterDocument) -> String? {
|
||||
document.serialized()
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.first { $0.hasPrefix("background:") }
|
||||
.map(String.init)
|
||||
}
|
||||
|
||||
// MARK: - Writing into the mapping
|
||||
|
||||
@Suite("Board background ▸ the write preserves the mapping")
|
||||
struct BackgroundWriteTests {
|
||||
|
||||
/// The whole point of the field: the app owns the colour well and nothing else, so a colour
|
||||
/// change on a board carrying an image has to come back still carrying it.
|
||||
@Test("Setting a colour replaces the subkey and keeps the image")
|
||||
func setKeepsTheImage() throws {
|
||||
var document = try document("background: {color: fern, image: sunset.jpg}")
|
||||
document.setStyleValue("dark-teal", for: FrontmatterKeys.background)
|
||||
|
||||
#expect(document.background == .valid("dark-teal"))
|
||||
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||
#expect(backgroundLine(document) == "background: {color: \"dark-teal\", image: \"sunset.jpg\"}")
|
||||
}
|
||||
|
||||
/// A colour written into a mapping that had none joins it rather than replacing it — the
|
||||
/// image-only board is exactly the board the style editor is most likely to be opened on.
|
||||
@Test("Setting a colour on an image-only background adds the subkey")
|
||||
func setAddsTheSubkeyToAnImageOnlyMapping() throws {
|
||||
var document = try document("background: {image: sunset.jpg}")
|
||||
document.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||
|
||||
#expect(document.background == .valid("chalk"))
|
||||
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||
}
|
||||
|
||||
/// Unknown subkeys ride along like unknown keys do — nothing in the app knows what `blend:`
|
||||
/// means and nothing in the app is entitled to drop it.
|
||||
@Test("Unknown subkeys survive a colour change, in their own positions")
|
||||
func setPreservesUnknownSubkeys() throws {
|
||||
var document = try document("background: {blend: multiply, color: fern, opacity: 0.5, tags: [a, b]}")
|
||||
document.setStyleValue("obsidian", for: FrontmatterKeys.background)
|
||||
|
||||
#expect(backgroundLine(document)
|
||||
== "background: {blend: \"multiply\", color: \"obsidian\", opacity: 0.5, tags: [\"a\", \"b\"]}")
|
||||
}
|
||||
|
||||
/// The None well removes the *colour*, not the background: an image the user never chose in the
|
||||
/// app must not disappear because they cleared a colour (03-board-ui.md § Styling ▸ Controls).
|
||||
@Test("The None well drops the colour subkey alone")
|
||||
func removeDropsOnlyTheColour() throws {
|
||||
var document = try document("background: {color: fern, image: sunset.jpg}")
|
||||
document.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||
|
||||
#expect(document.background == .missing)
|
||||
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||
#expect(backgroundLine(document) == "background: {image: \"sunset.jpg\"}")
|
||||
}
|
||||
|
||||
/// A mapping the removal empties takes the key with it — `background: {}` is a key that says
|
||||
/// nothing, and the removal's contract is that the field is gone.
|
||||
@Test("A mapping emptied by the removal takes the key with it")
|
||||
func removeDropsAnEmptiedKey() throws {
|
||||
var document = try document("background: {color: fern}")
|
||||
document.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||
|
||||
#expect(document.background == .missing)
|
||||
#expect(!document.contains(FrontmatterKeys.background))
|
||||
#expect(backgroundLine(document) == nil)
|
||||
}
|
||||
|
||||
/// A block mapping is the same value as a flow one and is merged the same way. The spelling is
|
||||
/// what does not survive — the span editor rewrites a key's value as one line — which is the
|
||||
/// documented limit of the verbatim promise on the one key being written.
|
||||
@Test("A block mapping merges, collapsing to flow form")
|
||||
func blockMappingCollapsesToFlow() throws {
|
||||
var document = try FrontmatterDocument.parse(
|
||||
"---\nschema: 1\nbackground:\n color: fern\n image: sunset.jpg\nicon: tray\n---\nbody\n"
|
||||
)
|
||||
document.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||
|
||||
#expect(document.background == .valid("chalk"))
|
||||
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||
#expect(document.serialized() == """
|
||||
---
|
||||
schema: 1
|
||||
background: {color: "chalk", image: "sunset.jpg"}
|
||||
icon: tray
|
||||
---
|
||||
body
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
/// **The app always writes the mapping** (01-storage-format.md § Frontmatter): a key that was
|
||||
/// absent gets one, and a key holding a shape the schema cannot read — the retired scalar, a
|
||||
/// sequence — is *replaced* by one, which is the malformed-value-cleared posture ("choosing any
|
||||
/// well replaces it").
|
||||
@Test("A colour written onto an absent or unreadable key lands as a mapping")
|
||||
func alwaysWritesTheMapping() throws {
|
||||
var absent = try document("schema: 1")
|
||||
absent.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||
#expect(backgroundLine(absent) == "background: {color: \"chalk\"}")
|
||||
#expect(absent.background == .valid("chalk"))
|
||||
|
||||
var scalar = try document("background: fern")
|
||||
scalar.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||
#expect(backgroundLine(scalar) == "background: {color: \"chalk\"}")
|
||||
|
||||
var sequence = try document("background: [a, b]")
|
||||
sequence.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||
#expect(backgroundLine(sequence) == "background: {color: \"chalk\"}")
|
||||
}
|
||||
|
||||
/// A removal over a shape with no subkeys to keep is simply a removal — there is no mapping to
|
||||
/// preserve half of, and nothing was readable to begin with.
|
||||
@Test("A removal over a scalar or an absent key just removes it")
|
||||
func removalOverANonMappingRemovesTheKey() throws {
|
||||
var scalar = try document("background: fern")
|
||||
scalar.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||
#expect(!scalar.contains(FrontmatterKeys.background))
|
||||
|
||||
var absent = try document("schema: 1")
|
||||
absent.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||
#expect(!absent.contains(FrontmatterKeys.background))
|
||||
#expect(absent.serialized() == "---\nschema: 1\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// The other style keys are untouched by any of this: their values *are* strings, and a
|
||||
/// hand-written `icon: {a: 1}` is a malformed value the write exists to clear — never something
|
||||
/// to merge a `color:` subkey into.
|
||||
@Test("Title and icon keep the plain scalar path")
|
||||
func otherKeysStayScalar() throws {
|
||||
var titled = try document("title: Old")
|
||||
titled.setStyleValue("New", for: FrontmatterKeys.title)
|
||||
#expect(titled.title == .valid("New"))
|
||||
#expect(titled.serialized() == "---\ntitle: New\n---\nbody\n")
|
||||
|
||||
var mappedIcon = try document("icon: {a: 1}")
|
||||
mappedIcon.setStyleValue("tray", for: FrontmatterKeys.icon)
|
||||
#expect(mappedIcon.icon == .valid("tray"))
|
||||
#expect(mappedIcon.serialized() == "---\nicon: tray\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// The emitted mapping is read back by the very reader the app uses, values with YAML-significant
|
||||
/// characters included — the reason strings are always quoted in flow context.
|
||||
@Test("An awkward colour and path round-trip through the emitted mapping")
|
||||
func awkwardValuesRoundTrip() throws {
|
||||
var document = try document("background: {image: \"a, b}.jpg\"}")
|
||||
document.setStyleValue("#ff8800", for: FrontmatterKeys.background)
|
||||
|
||||
let reparsed = try FrontmatterDocument.parse(document.serialized())
|
||||
#expect(reparsed.background == .valid("#ff8800"))
|
||||
#expect(reparsed.backgroundImage == .valid("a, b}.jpg"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Where an image may point
|
||||
|
||||
@Suite("Board background ▸ the image path stays inside the board")
|
||||
struct BackgroundImagePathTests {
|
||||
|
||||
private let root = URL(fileURLWithPath: "/Users/someone/Boards/Work.kanban", isDirectory: true)
|
||||
|
||||
@Test("A plain name and a nested path resolve inside the board")
|
||||
func resolvesRelativePaths() {
|
||||
#expect(BoardBackdrop.imageURL(named: "sunset.jpg", inBoardRoot: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||
#expect(BoardBackdrop.imageURL(named: "art/backdrops/sunset.png", inBoardRoot: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/art/backdrops/sunset.png")
|
||||
}
|
||||
|
||||
/// The check is about where the path *ends up*, not how it is spelled: a climb that lands back
|
||||
/// inside the board is an ordinary file in it.
|
||||
@Test("A path that climbs and returns is still inside")
|
||||
func resolvesPathsThatStandardizeBackInside() {
|
||||
#expect(BoardBackdrop.imageURL(named: "art/../sunset.jpg", inBoardRoot: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||
#expect(BoardBackdrop.imageURL(named: "./sunset.jpg", inBoardRoot: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||
}
|
||||
|
||||
/// A `.kanban` folder is a document — it gets copied, zipped and handed to somebody else — so a
|
||||
/// background that only works on the Mac it was written on resolves to nothing at all.
|
||||
@Test("Escapes and absolute paths resolve to nothing")
|
||||
func rejectsEscapes() {
|
||||
#expect(BoardBackdrop.imageURL(named: "../escape.jpg", inBoardRoot: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(named: "art/../../escape.jpg", inBoardRoot: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(named: "/etc/passwd", inBoardRoot: root) == nil)
|
||||
// Appended rather than rejected, an absolute path would land as `<root>/etc/passwd` and pass
|
||||
// containment while naming a file the author plainly did not mean.
|
||||
#expect(BoardBackdrop.imageURL(named: "/sunset.jpg", inBoardRoot: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(named: "", inBoardRoot: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(named: ".", inBoardRoot: root) == nil)
|
||||
}
|
||||
|
||||
/// The trailing separator in the containment test, doing its job: a sibling whose name merely
|
||||
/// starts with this board's is a different board.
|
||||
@Test("A sibling folder with a prefixed name is not inside")
|
||||
func rejectsAPrefixedSibling() {
|
||||
#expect(BoardBackdrop.imageURL(named: "../Work.kanban.backup/sunset.jpg", inBoardRoot: root) == nil)
|
||||
}
|
||||
|
||||
/// `~` is not expanded and is not special: only a shell ever meant a home folder by it, and a
|
||||
/// file honestly named that way sits in the board like any other.
|
||||
@Test("A tilde is an ordinary character in a file name")
|
||||
func treatsTildeAsAnOrdinaryCharacter() {
|
||||
#expect(BoardBackdrop.imageURL(named: "~notes.png", inBoardRoot: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/~notes.png")
|
||||
}
|
||||
|
||||
/// The whole-model reading, which is what the view and the window chrome ask: no key, an
|
||||
/// unresolvable one, and a good one.
|
||||
@Test("The board-level reading follows the field and the path rule")
|
||||
func readsTheBoardsOwnField() throws {
|
||||
func board(_ frontmatter: String) throws -> BoardModel {
|
||||
BoardModel(
|
||||
rootURL: root,
|
||||
schema: 1,
|
||||
title: .missing,
|
||||
created: .missing,
|
||||
modified: .missing,
|
||||
modifiedBy: .missing,
|
||||
deleted: .missing,
|
||||
background: .missing,
|
||||
backgroundImage: try document(frontmatter).backgroundImage,
|
||||
icon: .missing,
|
||||
iconColor: .missing,
|
||||
template: nil,
|
||||
lanes: [],
|
||||
document: try document(frontmatter)
|
||||
)
|
||||
}
|
||||
|
||||
#expect(BoardBackdrop.imageURL(for: try board("background: {color: fern}"), root: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(for: try board("background: {image: ../x.jpg}"), root: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(for: try board("background: {image: sunset.jpg}"), root: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||
}
|
||||
|
||||
/// **The window-chrome predicate** (`BoardWindowHost`): a board paints a background of its own
|
||||
/// when a colour resolves or an image path lands inside the board — a path that could never
|
||||
/// paint anything leaves the standard chrome alone.
|
||||
@Test("The chrome predicate answers for colour, image, both and neither")
|
||||
func answersTheChromePredicate() throws {
|
||||
func board(_ frontmatter: String) throws -> BoardModel {
|
||||
let parsed = try document(frontmatter)
|
||||
return BoardModel(
|
||||
rootURL: root,
|
||||
schema: 1,
|
||||
title: .missing,
|
||||
created: .missing,
|
||||
modified: .missing,
|
||||
modifiedBy: .missing,
|
||||
deleted: .missing,
|
||||
background: parsed.background,
|
||||
backgroundImage: parsed.backgroundImage,
|
||||
icon: .missing,
|
||||
iconColor: .missing,
|
||||
template: nil,
|
||||
lanes: [],
|
||||
document: parsed
|
||||
)
|
||||
}
|
||||
|
||||
#expect(BoardBackdrop.isCustom(try board("schema: 1"), root: root) == false)
|
||||
#expect(BoardBackdrop.isCustom(try board("background: {color: fern}"), root: root))
|
||||
#expect(BoardBackdrop.isCustom(try board("background: {image: sunset.jpg}"), root: root))
|
||||
#expect(BoardBackdrop.isCustom(try board("background: {color: fern, image: sunset.jpg}"), root: root))
|
||||
// Neither half resolves: an unrecognized colour name and a path that leaves the board.
|
||||
#expect(BoardBackdrop.isCustom(try board("background: {color: mauve, image: /tmp/x.jpg}"), root: root) == false)
|
||||
}
|
||||
}
|
||||
@@ -541,7 +541,7 @@ struct ShownTrashDiffTests {
|
||||
let retitled = try fixture.snapshot()
|
||||
try fixture.item(
|
||||
".trash/\(trashedLaneID)",
|
||||
"---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\nbackground: blue\n---\n\n"
|
||||
"---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\nbackground: {color: blue}\n---\n\n"
|
||||
)
|
||||
let styled = BoardDiff.between(retitled, try fixture.snapshot(), includingTrash: true)
|
||||
#expect(styled.lanes.isEmpty, "no accent is rendered, so nothing visible changed")
|
||||
|
||||
@@ -483,7 +483,7 @@ struct CardSessionStalenessTests {
|
||||
// A foreign styling of the same card: the session wrote the body and nothing else, so the
|
||||
// step names no style field to be stale against (13 ▸ Rules, the field-level predicate).
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(cardPath), operation: .style(title: nil)) {
|
||||
$0.set(FrontmatterKeys.background, to: .string("blue"))
|
||||
$0.setStyleValue("blue", for: FrontmatterKeys.background)
|
||||
}
|
||||
|
||||
window.board.undo()
|
||||
|
||||
@@ -39,7 +39,7 @@ struct CardDetailsKeyTests {
|
||||
created: 2026-01-01T09:00:00Z
|
||||
modified: 2026-02-02T09:00:00Z
|
||||
modified-by: claude
|
||||
background: mint
|
||||
background: {color: mint}
|
||||
icon: flag
|
||||
iconColor: carnation
|
||||
project: overlay-rewritten
|
||||
@@ -99,7 +99,7 @@ struct CardDetailsKeyTests {
|
||||
schema: 1
|
||||
title: Styled
|
||||
order: 1024
|
||||
background: mint
|
||||
background: {color: mint}
|
||||
icon: flag
|
||||
iconColor: carnation
|
||||
created: 2026-01-01T09:00:00Z
|
||||
|
||||
@@ -170,7 +170,7 @@ struct CommitMessageSingleEventTests {
|
||||
let restyled = try compose { fixture in
|
||||
try fixture.item(
|
||||
"\(Ident.lane1)/\(Ident.card1)",
|
||||
"---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: blue\n---\n\n"
|
||||
"---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: {color: blue}\n---\n\n"
|
||||
)
|
||||
}
|
||||
#expect(restyled == "Restyle card 'Fix login'")
|
||||
|
||||
@@ -352,7 +352,7 @@ struct FixtureCoercionTests {
|
||||
let cardTitleSeq = "40000000-0000-4000-8000-000000000004"
|
||||
let cardIconColorInt = "50000000-0000-4000-8000-000000000005"
|
||||
let cardBackgroundMap = "60000000-0000-4000-8000-000000000006"
|
||||
let cardBackgroundInt = "70000000-0000-4000-8000-000000000007"
|
||||
let cardBackgroundScalar = "70000000-0000-4000-8000-000000000007"
|
||||
let cardDeletedBad = "80000000-0000-4000-8000-000000000008"
|
||||
|
||||
let result = try loadFixture("Valid/coercion.kanban")
|
||||
@@ -371,8 +371,14 @@ struct FixtureCoercionTests {
|
||||
#expect(try card(cardTitleInt).title == .valid("2048"))
|
||||
#expect(try card(cardTitleSeq).title == .malformed(raw: "[a, b]"))
|
||||
#expect(try card(cardIconColorInt).iconColor == .valid("42"))
|
||||
#expect(try card(cardBackgroundMap).background == .malformed(raw: "{x: 1}"))
|
||||
#expect(try card(cardBackgroundInt).background == .valid("12345"))
|
||||
// **`background` is a mapping and only a mapping** (01-storage-format.md § Frontmatter,
|
||||
// ruled 2026-08-06). So the two background cards say opposite things about one key:
|
||||
// `{x: 1}` is a perfectly legal mapping that simply names neither subkey — no colour, no
|
||||
// trace, the unknown subkey riding along like any unknown key — while the bare scalar
|
||||
// `12345` has no reading at all, which is the golden pin on the retired shape.
|
||||
#expect(try card(cardBackgroundMap).background == .missing)
|
||||
#expect(try card(cardBackgroundScalar).background == .malformed(raw: "12345"))
|
||||
#expect(try card(cardBackgroundScalar).background.rawText == "12345")
|
||||
|
||||
let deletedBad = try card(cardDeletedBad)
|
||||
#expect(deletedBad.deleted == .malformed(raw: "definitely-not-a-date"))
|
||||
|
||||
@@ -551,8 +551,8 @@ struct FrontmatterLenientFieldTests {
|
||||
|
||||
@Test func wellFormedLenientValues() throws {
|
||||
#expect(try document("title: My Board").title == .valid("My Board"))
|
||||
#expect(try document("background: \"#ff8800\"").background == .valid("#ff8800"))
|
||||
#expect(try document("background: slate").background == .valid("slate"))
|
||||
#expect(try document("background: {color: \"#ff8800\"}").background == .valid("#ff8800"))
|
||||
#expect(try document("background: {color: slate}").background == .valid("slate"))
|
||||
#expect(try document("icon: tray.full").icon == .valid("tray.full"))
|
||||
#expect(try document("iconColor: teal").iconColor == .valid("teal"))
|
||||
#expect(try document("width: 3").width == .valid(3))
|
||||
@@ -565,7 +565,7 @@ struct FrontmatterLenientFieldTests {
|
||||
@Test func scalarsOfTheWrongTypeCoerceToTheirSourceText() throws {
|
||||
#expect(try document("title: 2048").title == .valid("2048"))
|
||||
#expect(try document("title: true").title == .valid("true"))
|
||||
#expect(try document("background: 42").background == .valid("42"))
|
||||
#expect(try document("background: {color: 42}").background == .valid("42"))
|
||||
#expect(try document("iconColor: true").iconColor == .valid("true"))
|
||||
#expect(try document("icon: 2026-07-26T16:41:38Z").icon == .valid("2026-07-26T16:41:38Z"))
|
||||
}
|
||||
@@ -575,7 +575,7 @@ struct FrontmatterLenientFieldTests {
|
||||
@Test func aTrailingCommentIsNotPartOfACoercedValue() throws {
|
||||
#expect(try document("title: 2048 # note").title == .valid("2048"))
|
||||
#expect(try document("title: true # note").title == .valid("true"))
|
||||
#expect(try document("background: 42\t# tabbed").background == .valid("42"))
|
||||
#expect(try document("background: {color: 42}\t# tabbed").background == .valid("42"))
|
||||
#expect(try document("icon: 2026-07-26T16:41:38Z # when").icon == .valid("2026-07-26T16:41:38Z"))
|
||||
}
|
||||
|
||||
@@ -583,8 +583,8 @@ struct FrontmatterLenientFieldTests {
|
||||
/// at all, so a read must not treat it as a comment.
|
||||
@Test func aHashInsideAQuotedValueIsNotTrimmed() throws {
|
||||
#expect(try document("title: \"2048 # note\"").title == .valid("2048 # note"))
|
||||
#expect(try document("background: \"#ff8800\"").background == .valid("#ff8800"))
|
||||
#expect(try document("background: \"#ff8800\" # brand orange").background == .valid("#ff8800"))
|
||||
#expect(try document("background: {color: \"#ff8800\"}").background == .valid("#ff8800"))
|
||||
#expect(try document("background: {color: \"#ff8800\"} # brand orange").background == .valid("#ff8800"))
|
||||
}
|
||||
|
||||
/// A sequence or mapping has no scalar reading at all; the raw text it falls back to stops at
|
||||
@@ -608,6 +608,115 @@ struct FrontmatterLenientFieldTests {
|
||||
#expect(try document("title: [a, b]").title == .malformed(raw: "[a, b]"))
|
||||
}
|
||||
|
||||
// MARK: `background` is a mapping and only a mapping
|
||||
|
||||
/// **The retired scalar** (01-storage-format.md § Frontmatter, ruled 2026-08-06 before anything
|
||||
/// shipped — one shape, no legacy spelling, no migration): `background: green` has no reading at
|
||||
/// all. It is `.malformed` like any other unreadable value, which renders as no colour and
|
||||
/// leaves the bytes exactly as written.
|
||||
@Test func aScalarBackgroundHasNoReading() throws {
|
||||
#expect(try document("background: green").background == .malformed(raw: "green"))
|
||||
#expect(try document("background: \"#ff8800\"").background == .malformed(raw: "\"#ff8800\""))
|
||||
#expect(try document("background: 42").background == .malformed(raw: "42"))
|
||||
// The raw stops at the comment like every other read, since a comment is the line's.
|
||||
#expect(try document("background: green # my colour").background == .malformed(raw: "green"))
|
||||
}
|
||||
|
||||
/// One unreadable value is reported **once**: the colour reading owns the key's shape, and the
|
||||
/// image stays silent about a file that never wrote a mapping to name a picture in.
|
||||
@Test func onlyTheColourReportsANonMappingShape() throws {
|
||||
#expect(try document("background: green").backgroundImage == .missing)
|
||||
#expect(try document("background: [red, blue]").backgroundImage == .missing)
|
||||
#expect(try document("background: 42").backgroundImage == .missing)
|
||||
#expect(try document("schema: 1").backgroundImage == .missing)
|
||||
#expect(try document("background: green").coercedFields
|
||||
== [CoercedField(key: "background", raw: "green")])
|
||||
}
|
||||
|
||||
/// The mapping form, both halves present — flow and block spellings are one YAML value and
|
||||
/// therefore one reading.
|
||||
@Test func aMappingBackgroundReadsBothSubkeys() throws {
|
||||
let flow = try document("background: {color: \"#112233\", image: sunset.jpg}")
|
||||
#expect(flow.background == .valid("#112233"))
|
||||
#expect(flow.backgroundImage == .valid("sunset.jpg"))
|
||||
|
||||
let block = try FrontmatterDocument.parse(
|
||||
"---\nbackground:\n color: fern\n image: art/sunset.jpg\n---\nbody\n"
|
||||
)
|
||||
#expect(block.background == .valid("fern"))
|
||||
#expect(block.backgroundImage == .valid("art/sunset.jpg"))
|
||||
}
|
||||
|
||||
/// Either half may be absent, and an absent half is `.missing` — not malformed. An image-only
|
||||
/// background is a board with no colour, which is the level's default and not a fallback.
|
||||
@Test func eitherSubkeyMayBeAbsent() throws {
|
||||
let colorOnly = try document("background: {color: chalk}")
|
||||
#expect(colorOnly.background == .valid("chalk"))
|
||||
#expect(colorOnly.backgroundImage == .missing)
|
||||
|
||||
let imageOnly = try document("background: {image: sunset.jpg}")
|
||||
#expect(imageOnly.background == .missing)
|
||||
#expect(imageOnly.backgroundImage == .valid("sunset.jpg"))
|
||||
|
||||
let empty = try document("background: {}")
|
||||
#expect(empty.background == .missing)
|
||||
#expect(empty.backgroundImage == .missing)
|
||||
}
|
||||
|
||||
/// An explicit null subkey reads exactly like an absent one — `FieldValue.missing` already
|
||||
/// treats `background: null` that way, and a subkey is no different.
|
||||
@Test func nullSubkeysReadAsMissing() throws {
|
||||
let nulls = try document("background: {color: null, image: ~}")
|
||||
#expect(nulls.background == .missing)
|
||||
#expect(nulls.backgroundImage == .missing)
|
||||
}
|
||||
|
||||
/// Unknown subkeys are tolerated on the way in exactly as unknown *keys* are — they mean
|
||||
/// nothing to either reading and cost it nothing.
|
||||
@Test func unknownSubkeysAreTolerated() throws {
|
||||
let extra = try document("background: {opacity: 0.5, color: fern, blend: multiply}")
|
||||
#expect(extra.background == .valid("fern"))
|
||||
#expect(extra.backgroundImage == .missing)
|
||||
}
|
||||
|
||||
/// Inside the mapping the subvalues coerce like any other scalar, quoted or not; a subvalue with
|
||||
/// no scalar reading at all is malformed, quoting the subvalue rather than the whole span —
|
||||
/// a subkey has no source span of its own to quote.
|
||||
@Test func subkeyScalarsCoerceAndCollectionsAreMalformed() throws {
|
||||
#expect(try document("background: {color: 42}").background == .valid("42"))
|
||||
#expect(try document("background: {image: \"sun set.jpg\"}").backgroundImage == .valid("sun set.jpg"))
|
||||
#expect(try document("background: {color: [a, b]}").background == .malformed(raw: "[a, b]"))
|
||||
#expect(try document("background: {image: {a: 1}}").backgroundImage == .malformed(raw: "{a: 1}"))
|
||||
// The other half of a mapping with one bad subkey still reads perfectly well.
|
||||
#expect(try document("background: {color: [a, b], image: sunset.jpg}").backgroundImage == .valid("sunset.jpg"))
|
||||
}
|
||||
|
||||
/// **The coerce tier's trace covers both readings** (01-storage-format.md § Frontmatter: "every
|
||||
/// silent recovery leaves a trace"). Both are filed under the key the schema spells, and are told
|
||||
/// apart by the subvalue each quotes — so a mapping whose colour reads fine and whose image does
|
||||
/// not is still visible.
|
||||
@Test func bothBackgroundReadingsReachTheCoerceRecord() throws {
|
||||
#expect(try document("background: {color: fern, image: [a, b]}").coercedFields
|
||||
== [CoercedField(key: "background", raw: "[a, b]")])
|
||||
#expect(try document("background: {color: [a], image: [b]}").coercedFields
|
||||
== [CoercedField(key: "background", raw: "[a]"), CoercedField(key: "background", raw: "[b]")])
|
||||
// A sequence is one unreadable value and is reported once — the image reading stays silent
|
||||
// about a shape that never claimed to name one.
|
||||
#expect(try document("background: [red, blue]").coercedFields
|
||||
== [CoercedField(key: "background", raw: "[red, blue]")])
|
||||
#expect(try document("background: {image: sunset.jpg}").coercedFields.isEmpty)
|
||||
}
|
||||
|
||||
/// The engine reads the shape; it never rewrites it. A mapping background round-trips
|
||||
/// byte-identically like every other value the app did not touch.
|
||||
@Test func aMappingBackgroundRoundTripsVerbatim() throws {
|
||||
let text = "---\nschema: 1\nbackground:\n color: fern\n image: art/sunset.jpg\n blend: multiply\n---\nbody\n"
|
||||
let document = try FrontmatterDocument.parse(text)
|
||||
#expect(document.serialized() == text)
|
||||
#expect(document.background == .valid("fern"))
|
||||
#expect(document.backgroundImage == .valid("art/sunset.jpg"))
|
||||
}
|
||||
|
||||
/// Only a fractional or non-numeric reading has no sensible width at all — a sequence,
|
||||
/// mapping, or scalar with no integer reading whatsoever stays malformed and renders as the
|
||||
/// default 1.
|
||||
|
||||
@@ -460,11 +460,11 @@ struct ObjectKindWriteTests {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
|
||||
) { $0.setStyleValue("fern", for: FrontmatterKeys.background) }
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url(Ident.lane1),
|
||||
operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
|
||||
) { $0.setStyleValue("fern", for: FrontmatterKeys.background) }
|
||||
|
||||
#expect(try kind(of: "\(Ident.lane1)/\(Ident.card1)", in: fixture) == .valid("card"))
|
||||
#expect(try kind(of: Ident.lane1, in: fixture) == .valid("lane"))
|
||||
@@ -498,7 +498,7 @@ struct ObjectKindWriteTests {
|
||||
let folder = try fixture.item("notes", Item.rich(order: "1024", title: "Hand-made"))
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) {
|
||||
$0.set(FrontmatterKeys.background, to: .string("fern"))
|
||||
$0.setStyleValue("fern", for: FrontmatterKeys.background)
|
||||
}
|
||||
|
||||
#expect(try kind(of: "notes", in: fixture) == .missing)
|
||||
|
||||
@@ -51,9 +51,9 @@ private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", boardIndex)
|
||||
try fixture.item(Ident.lane1, styled(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: fern", "iconColor: chalk"]))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: {color: fern}", "iconColor: chalk"]))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second"))
|
||||
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: chalk", "icon: tray"]))
|
||||
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: {color: chalk}", "icon: tray"]))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third"))
|
||||
try fixture.item(Ident.lane3, Item.uneditable)
|
||||
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone"))
|
||||
@@ -120,7 +120,7 @@ struct StyleWriteTests {
|
||||
store.applyStyle(to: .items([card2]), background: .set("smokey-ocean"))
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||
#expect(after.contains("background: smokey-ocean"))
|
||||
#expect(after.contains("background: {color: \"smokey-ocean\"}"))
|
||||
#expect(!after.contains("icon:"), "the untouched dimension writes no key at all")
|
||||
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
|
||||
#expect(untouchedLines(after) == untouchedLines(before))
|
||||
@@ -141,13 +141,13 @@ struct StyleWriteTests {
|
||||
store.applyStyle(to: .items([card1]), background: .set("dark-teal"), icon: .set("flag"))
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||
#expect(after.contains("background: dark-teal"))
|
||||
#expect(after.contains("background: {color: \"dark-teal\"}"))
|
||||
#expect(after.contains("icon: flag"))
|
||||
// "iconColor: resolved — schema yes, control no" (03 § Styling ▸ Capabilities): the field
|
||||
// renders when hand-written and the app offers no control for it, so a style write must
|
||||
// carry it through untouched like any unknown key.
|
||||
#expect(after.contains("iconColor: chalk"))
|
||||
#expect(!after.contains("background: fern"), "the old value is replaced, not duplicated")
|
||||
#expect(!after.contains("fern"), "the old value is replaced, not duplicated")
|
||||
}
|
||||
|
||||
@Test("The None and default wells remove their key rather than writing a blank value")
|
||||
@@ -182,8 +182,8 @@ struct StyleWriteTests {
|
||||
|
||||
#expect(log.begins == 1)
|
||||
#expect(log.ends == 1)
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("background: light-cayenne"))
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: light-cayenne"))
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("background: {color: \"light-cayenne\"}"))
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: {color: \"light-cayenne\"}"))
|
||||
}
|
||||
|
||||
@Test("A value a target already carries writes nothing — per target and per dimension")
|
||||
@@ -195,13 +195,13 @@ struct StyleWriteTests {
|
||||
log.attach(to: store)
|
||||
let untouchedCard = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
// `card1` is already `fern` and `card2` has no background at all: only the second file may
|
||||
// `card1` is already `{color: fern}` and `card2` has no background at all: only the second file may
|
||||
// move. A well clicked twice must not stamp `modified` or mint a commit on what was already
|
||||
// right (`setLaneWidth`'s rule).
|
||||
store.applyStyle(to: .items([card1, card2]), background: .set("fern"))
|
||||
|
||||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == untouchedCard)
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: fern"))
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: {color: \"fern\"}"))
|
||||
#expect(log.begins == 1, "the batch still opens exactly one bracket for the target that moved")
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ struct StyleWriteTests {
|
||||
log.attach(to: store)
|
||||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
// Both dimensions already read this way: `background: fern` is set and `icon` is absent, so
|
||||
// Both dimensions already read this way: the colour is already `fern` and `icon` is absent, so
|
||||
// the removal is a no-op too.
|
||||
store.applyStyle(to: .items([card1]), background: .set("fern"), icon: .remove)
|
||||
|
||||
@@ -234,7 +234,7 @@ struct StyleWriteTests {
|
||||
store.applyStyle(to: .items([card2]), background: .set("shale"))
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||
#expect(after.contains("background: shale"))
|
||||
#expect(after.contains("background: {color: \"shale\"}"))
|
||||
#expect(!after.contains("[a, b]"))
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ struct StyleWriteTests {
|
||||
store.applyStyle(to: .board, background: .set("intense-cool-shale"), icon: .set("square.stack"))
|
||||
|
||||
let after = try fixture.indexText("")
|
||||
#expect(after.contains("background: intense-cool-shale"))
|
||||
#expect(after.contains("background: {color: \"intense-cool-shale\"}"))
|
||||
#expect(after.contains("icon: square.stack"))
|
||||
#expect(after.contains("iconColor: carnation"))
|
||||
#expect(after.contains("Board description."))
|
||||
@@ -258,6 +258,38 @@ struct StyleWriteTests {
|
||||
#expect(lane(lane1, in: model)?.background.isMissing == true)
|
||||
}
|
||||
|
||||
/// **The mapping form, through the real writer** (03-board-ui.md § Styling ▸ Capabilities; the
|
||||
/// unit-level claims are `BackgroundWriteTests`'). The app has a control for the colour and none
|
||||
/// for the image, so the whole gesture — well, then None — has to leave the image standing.
|
||||
@Test("A colour change on a board carrying an image preserves the image, and None drops only the colour")
|
||||
func preservesABackgroundImage() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", """
|
||||
---
|
||||
schema: 1
|
||||
title: Board
|
||||
background: {color: fern, image: art/sunset.jpg}
|
||||
---
|
||||
Board description.
|
||||
|
||||
""")
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.applyStyle(to: .board, background: .set("dark-teal"))
|
||||
|
||||
var model = try load(fixture)
|
||||
#expect(model.background == .valid("dark-teal"))
|
||||
#expect(model.backgroundImage == .valid("art/sunset.jpg"))
|
||||
#expect(try fixture.indexText("").contains("Board description."))
|
||||
|
||||
store.applyStyle(to: .board, background: .remove)
|
||||
|
||||
model = try load(fixture)
|
||||
#expect(model.background.isMissing)
|
||||
#expect(model.backgroundImage == .valid("art/sunset.jpg"), "the None well removes the colour, not the picture")
|
||||
}
|
||||
|
||||
@Test("Vanished and trashed targets are skipped silently")
|
||||
func skipsTargetsThatRenderNowhere() throws {
|
||||
let fixture = try makeBoard()
|
||||
|
||||
@@ -41,7 +41,7 @@ schema: 1
|
||||
title: Styled
|
||||
order: 3072
|
||||
project: lanework # agent overlay
|
||||
background: blue
|
||||
background: {color: blue}
|
||||
icon: star
|
||||
created: 2026-01-01T09:00:00Z
|
||||
---
|
||||
@@ -991,7 +991,7 @@ private enum Foreign {
|
||||
|
||||
static func restyle(_ fixture: WriterFixture, _ path: String, background: String) throws {
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.background, to: .string(background))
|
||||
document.setStyleValue(background, for: FrontmatterKeys.background)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,13 +262,15 @@ struct IncreaseContrastTests {
|
||||
@Suite("Accommodations ▸ Reduce Transparency")
|
||||
struct ReduceTransparencyTests {
|
||||
|
||||
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board's one
|
||||
/// surviving material is the transient search bar's `.bar` — the design's own example, the card
|
||||
/// face carousel's page dots, died with the carousel (03-board-ui.md § Card face).
|
||||
@Test("The one glass underlay goes solid")
|
||||
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board carries
|
||||
/// two materials — the transient search bar's `.bar` and the backdrop's title-bar frost — and
|
||||
/// the rule is one rule: both take the same solid, whatever their weights without it.
|
||||
@Test("Both glass underlays go solid")
|
||||
func glassGoesSolid() {
|
||||
#expect(Accommodations.underlay(reduceTransparency: false) == .glass)
|
||||
#expect(Accommodations.underlay(reduceTransparency: true) == .solid)
|
||||
#expect(Accommodations.frost(reduceTransparency: false) == .frost)
|
||||
#expect(Accommodations.frost(reduceTransparency: true) == .solid)
|
||||
}
|
||||
|
||||
/// The washes are not glass — they composite at an alpha rather than sampling a backdrop — but
|
||||
|
||||
@@ -146,7 +146,7 @@ struct WriteFidelityMinimalTouchTests {
|
||||
try step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.background, to: .string("blue")) }
|
||||
) { $0.setStyleValue("blue", for: FrontmatterKeys.background) }
|
||||
}
|
||||
try step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) {
|
||||
try BoardWriter.updateIndex(
|
||||
|
||||
Reference in New Issue
Block a user