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:
@@ -1,3 +1,9 @@
|
|||||||
|
**August 2026**
|
||||||
|
|
||||||
|
A board can now wear a background image, painted across the whole window with a frosted strip keeping the title bar legible.
|
||||||
|
|
||||||
|
The background field is now written as a mapping — *{color: green}* instead of a bare *green* — and a board's may name an image beside the color.
|
||||||
|
|
||||||
**July 2026**
|
**July 2026**
|
||||||
|
|
||||||
Version 2.0: Lanework's first release — a kanban app whose boards are ordinary folders of Markdown files on your Mac.
|
Version 2.0: Lanework's first release — a kanban app whose boards are ordinary folders of Markdown files on your Mac.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ MyBoard.kanban/ ← board = the document
|
|||||||
| `created` | ISO-8601 | no | Set at creation, with timezone |
|
| `created` | ISO-8601 | no | Set at creation, with timezone |
|
||||||
| `modified` | ISO-8601 | no | Updated on every app write **that rewrites this `index.md`** — see below |
|
| `modified` | ISO-8601 | no | Updated on every app write **that rewrites this `index.md`** — see below |
|
||||||
| `modified-by` | string | no | Self-reported writer identity, set by external writers only; the app clears it on every write — see below |
|
| `modified-by` | string | no | Self-reported writer identity, set by external writers only; the app clears it on every write — see below |
|
||||||
| `background` | string | no | Palette name or `#RRGGBB[AA]` — see 03-board-ui.md |
|
| `background` | mapping | no | `{color: …, image: …}` — `color` is a palette name or `#RRGGBB[AA]` hex (03-board-ui.md), `image` a file path. Both subkeys optional, either half may stand alone, unknown subkeys preserved verbatim like any unknown key. `image` is a path **relative to the board root** (absolute paths and any path escaping the root resolve to nothing) and is **board-level only**: lanes and cards read `color` and ignore the rest. **A mapping is the only shape** (ruled 2026-08-06, before anything shipped — so no legacy spelling, no version bump, no migration): a bare scalar `background: green` has no reading, rendering as no color, coerce-tier logged, bytes preserved — the standard lenient degrade. |
|
||||||
| `icon` | string | no | SF Symbol name, per-level defaults |
|
| `icon` | string | no | SF Symbol name, per-level defaults |
|
||||||
| `iconColor` | string | no | Palette name or hex |
|
| `iconColor` | string | no | Palette name or hex |
|
||||||
| `kind` | string | no | The object's kind — `board`, `lane`, `card` (`comment` — its storage schema now specified, Enhanced schema below). **Written at creation of every object** (re-ruled 2026-07-29 — consistency across the schema, even where position already answers). The value — never the key's mere presence — names the kind, and consumers that consult it trust the value outright: no stripping, no corroboration machinery. Only consequential inside `.trash/` today, where position can't answer (Deletion below); everywhere else it is redundant with position (level is position) and carried for uniformity. Missing on an older object, it **backfills on touch** — the integrity service's on-touch heal (Validation and healing below), never a scheduled sweep. |
|
| `kind` | string | no | The object's kind — `board`, `lane`, `card` (`comment` — its storage schema now specified, Enhanced schema below). **Written at creation of every object** (re-ruled 2026-07-29 — consistency across the schema, even where position already answers). The value — never the key's mere presence — names the kind, and consumers that consult it trust the value outright: no stripping, no corroboration machinery. Only consequential inside `.trash/` today, where position can't answer (Deletion below); everywhere else it is redundant with position (level is position) and carried for uniformity. Missing on an older object, it **backfills on touch** — the integrity service's on-touch heal (Validation and healing below), never a scheduled sweep. |
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
|
|||||||
|
|
||||||
### Capabilities (settled)
|
### Capabilities (settled)
|
||||||
|
|
||||||
- **`background`** on board / lane / card: palette name (kebab-case, hand-editable) or `#RRGGBB[AA]` hex. Board color paints the board window's content background (the surface behind and between lanes). Lane and card color are **edge accents, not fills** (settled in the pathfinder's treatment shootout — its settings matrix of C-series lane / K-series card variants landed on **C7 · full-column top edge** and **K1 · left edge stripe**): a lane's color paints a full-width band along its top edge, a card's a stripe along its left edge; the surfaces themselves keep the standard chrome, so colored title text never sits on a colored fill.
|
- **`background`** on board / lane / card: a mapping, `{color: …, image: …}`, and only a mapping (settled 2026-08-06 — 01-storage-format.md § Frontmatter; a bare scalar has no reading and paints nothing). `color` is a palette name (kebab-case, hand-editable) or a `#RRGGBB[AA]` hex; either subkey may stand alone. Board color paints the board window's content background (the surface behind and between lanes). **A board's background may also carry an image**: the `image` path is relative to the board root so the picture travels with the document. Color and image both paint the **full window** — the content runs under a transparent title bar, with a frosted strip across the title-bar/toolbar band keeping the chrome legible over them; the extended chrome applies only while the board has a background of its own, and a board without one keeps the standard chrome unchanged. The image draws over the color, scaled to fill and cropped, with the color standing in while it loads or if it can't be read; an unresolvable path paints nothing, the lenient degrade an unrecognized color already gets. **There is no in-app control for the image** — the raw file is the escape hatch (the stance custom hex held until the 2026-08-06 combo reversal; for images it stands) — and **the board-level ink rule still derives from the color reading alone**: an image makes no AA claim (10-accessibility.md), since a picture has no single luminance to threshold against. Lane and card color are **edge accents, not fills** (settled in the pathfinder's treatment shootout — its settings matrix of C-series lane / K-series card variants landed on **C7 · full-column top edge** and **K1 · left edge stripe**): a lane's color paints a full-width band along its top edge, a card's a stripe along its left edge; the surfaces themselves keep the standard chrome, so colored title text never sits on a colored fill.
|
||||||
- **The standard chrome is the pathfinder's surface stack** (settled 2026-08-06): the window keeps the neutral system background; every lane wears a quiet quaternary-wash plate (the trash plate's own figure — translucent, so a board-chosen color shows through and the board-level ink rule keeps its premise; opaque under Reduce Transparency, the trash precedent); every card sits on an opaque `controlBackgroundColor` plate — white over the washed lane in light appearance, a step *darker* than the window in dark. One plate value for every face a card draws (resting, replicas, placeholder, arriving), so a card is the same object wherever it renders.
|
- **The standard chrome is the pathfinder's surface stack** (settled 2026-08-06): the window keeps the neutral system background; every lane wears a quiet quaternary-wash plate (the trash plate's own figure — translucent, so a board-chosen color shows through and the board-level ink rule keeps its premise; opaque under Reduce Transparency, the trash precedent); every card sits on an opaque `controlBackgroundColor` plate — white over the washed lane in light appearance, a step *darker* than the window in dark. One plate value for every face a card draws (resting, replicas, placeholder, arriving), so a card is the same object wherever it renders.
|
||||||
- **`icon`**: SF Symbol per item with per-level defaults (board `rectangle.split.3x1`, lane `square.stack`, card `doc.text`).
|
- **`icon`**: SF Symbol per item with per-level defaults (board `rectangle.split.3x1`, lane `square.stack`, card `doc.text`).
|
||||||
- **`iconColor`**: resolved — **schema yes, control no**. The field renders when hand-written (tint palette name or hex); the app offers no control for it (Controls below).
|
- **`iconColor`**: resolved — **schema yes, control no**. The field renders when hand-written (tint palette name or hex); the app offers no control for it (Controls below).
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never
|
|||||||
| `tombstones.kanban` | A tombstoned lane and a tombstoned card, both still on disk and still in the snapshot, flagged (`isDeleted`) rather than removed. Also proves a tombstoned lane doesn't recursively flag its own un-deleted children. |
|
| `tombstones.kanban` | A tombstoned lane and a tombstoned card, both still on disk and still in the snapshot, flagged (`isDeleted`) rather than removed. Also proves a tombstoned lane doesn't recursively flag its own un-deleted children. |
|
||||||
| `duplicate-order-tie-break.kanban` | Three cards sharing one `order` in one lane, and two lanes sharing one `order` — both broken by folder name, ascending. |
|
| `duplicate-order-tie-break.kanban` | Three cards sharing one `order` in one lane, and two lanes sharing one `order` — both broken by folder name, ascending. |
|
||||||
| `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. |
|
| `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. |
|
||||||
| `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `background: 12345`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `background: {x: 1}`, `width: 1.5`), plus a `deleted` with an unusable timestamp that still tombstones. |
|
| `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `width: 1.5`, and `background: 12345` — a bare scalar, which `background` no longer has a reading for at all), plus `background: {x: 1}` — a legal mapping naming neither subkey, so no color and no trace — and a `deleted` with an unusable timestamp that still tombstones. |
|
||||||
| `duplicate-top-level-keys.kanban` | A top-level key written twice — at board, lane (the strict `order` field), and card level. Last occurrence wins; **not** a fail-fast case (settled, newer than the original card text). Round-tripped to prove the earlier occurrence survives on disk, invisible only to reads. |
|
| `duplicate-top-level-keys.kanban` | A top-level key written twice — at board, lane (the strict `order` field), and card level. Last occurrence wins; **not** a fail-fast case (settled, newer than the original card text). Round-tripped to prove the earlier occurrence survives on disk, invisible only to reads. |
|
||||||
| `board-level-deleted.kanban` | A board-level `deleted:` key — legal per the frontmatter table but meaningless; ignored + warned, rest of the board loads normally. |
|
| `board-level-deleted.kanban` | A board-level `deleted:` key — legal per the frontmatter table but meaningless; ignored + warned, rest of the board loads normally. |
|
||||||
| `optional-keys.kanban` | `order` and `schema` optional below the board root (re-ruled 2026-07-31). One lane holds a ranked card plus every order-less shape — no key, an explicit null, `order: banana`, `order: .nan` — which all read as append-at-end in folder-name order; the strip holds a ranked lane, a `schema`-less one, and an order-less one. Also the golden case for the minimum agent card: a card whose whole frontmatter is a title. |
|
| `optional-keys.kanban` | `order` and `schema` optional below the board root (re-ruled 2026-07-31). One lane holds a ranked card plus every order-less shape — no key, an explicit null, `order: banana`, `order: .nan` — which all read as append-at-end in folder-name order; the strip holds a ranked lane, a `schema`-less one, and an order-less one. Also the golden case for the minimum agent card: a card whose whole frontmatter is a title. |
|
||||||
|
|||||||
+3
-2
@@ -3,5 +3,6 @@ schema: 1
|
|||||||
order: 4096
|
order: 4096
|
||||||
background: {x: 1}
|
background: {x: 1}
|
||||||
---
|
---
|
||||||
A mapping has no sensible string reading — malformed, falls back to no
|
A mapping is a legal `background` — it carries `color` and `image` subkeys
|
||||||
color.
|
— but this one names neither, so there is no color to read and the card
|
||||||
|
falls back to none.
|
||||||
|
|||||||
+2
@@ -3,3 +3,5 @@ schema: 1
|
|||||||
order: 5120
|
order: 5120
|
||||||
background: 12345
|
background: 12345
|
||||||
---
|
---
|
||||||
|
`background` is a mapping and only a mapping, so a bare scalar has no
|
||||||
|
reading at all — malformed, no color, bytes preserved.
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
schema: 1
|
schema: 1
|
||||||
title: Wire up the loader's stray tolerance
|
title: Wire up the loader's stray tolerance
|
||||||
order: 2048
|
order: 2048
|
||||||
background: coral
|
background: {color: coral}
|
||||||
icon: flag.fill
|
icon: flag.fill
|
||||||
iconColor: orange
|
iconColor: orange
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ schema: 1
|
|||||||
order: 1024
|
order: 1024
|
||||||
title: Doing
|
title: Doing
|
||||||
width: 2
|
width: 2
|
||||||
background: '#3478F6'
|
background: {color: '#3478F6'}
|
||||||
icon: hammer.fill
|
icon: hammer.fill
|
||||||
iconColor: blue
|
iconColor: blue
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
schema: 1
|
schema: 1
|
||||||
order: 2048
|
order: 2048
|
||||||
title: Done
|
title: Done
|
||||||
background: green
|
background: {color: green}
|
||||||
---
|
---
|
||||||
Completed work lives here until someone clears it out.
|
Completed work lives here until someone clears it out.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ title: "Rich Demo Board"
|
|||||||
created: 2026-07-01T09:00:00Z
|
created: 2026-07-01T09:00:00Z
|
||||||
modified: 2026-07-26T16:41:38Z
|
modified: 2026-07-26T16:41:38Z
|
||||||
modified-by: claude
|
modified-by: claude
|
||||||
background: "#1E1E1E"
|
background: {color: "#1E1E1E"}
|
||||||
icon: rectangle.stack.fill
|
icon: rectangle.stack.fill
|
||||||
iconColor: purple
|
iconColor: purple
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,21 @@ struct BoardWindowHost: View {
|
|||||||
.onChange(of: boardSearch.isFocused) { _, _ in
|
.onChange(of: boardSearch.isFocused) { _, _ in
|
||||||
boardSearch.dismissTransientIfCleared(query: store.searchQuery)
|
boardSearch.dismissTransientIfCleared(query: store.searchQuery)
|
||||||
}
|
}
|
||||||
|
// **The window chrome follows the board's background** (03-board-ui.md § Styling ▸
|
||||||
|
// Capabilities): a board that paints one runs its content the full height of the frame
|
||||||
|
// under a transparent title bar, with `BoardView.boardBackground`'s frosted strip
|
||||||
|
// keeping the widget and the toolbar legible over it; a board that paints none keeps
|
||||||
|
// the standard chrome untouched.
|
||||||
|
//
|
||||||
|
// Here rather than in `configureWindow` because it is not a wiring fact but a *live*
|
||||||
|
// one: `background` is hand-editable, the watcher reloads on a change to `index.md`, and
|
||||||
|
// the chrome has to follow the reading in both directions. `initial: true` because the
|
||||||
|
// first render is already a level, not a change — this is the board's first statement
|
||||||
|
// about its chrome, and the loading half deliberately made none
|
||||||
|
// (`HostedWindowController.extendsUnderTitlebar`).
|
||||||
|
.onChange(of: BoardBackdrop.isCustom(store.snapshot, root: store.rootURL), initial: true) { _, custom in
|
||||||
|
windowController.setExtendsContentUnderTitlebar(custom)
|
||||||
|
}
|
||||||
// **The board settings sheet** (03-board-ui.md ▸ Board settings sheet) — presented from
|
// **The board settings sheet** (03-board-ui.md ▸ Board settings sheet) — presented from
|
||||||
// the board window's own content, which is what makes it modal to *this* board rather
|
// the board window's own content, which is what makes it modal to *this* board rather
|
||||||
// than to the app: "a board-scoped, titled, sectioned sheet on the board window".
|
// than to the app: "a board-scoped, titled, sectioned sheet on the board window".
|
||||||
|
|||||||
@@ -109,6 +109,24 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
|||||||
/// what the chrome draws from it.
|
/// what the chrome draws from it.
|
||||||
private var titleVisibility: NSWindow.TitleVisibility?
|
private var titleVisibility: NSWindow.TitleVisibility?
|
||||||
|
|
||||||
|
/// Whether this window's content runs the full height of the frame, under a transparent title
|
||||||
|
/// bar — **a board window carrying a custom background**, and nothing else (03-board-ui.md §
|
||||||
|
/// Styling ▸ Capabilities: the board's colour or image "paints the full window"; `BoardView
|
||||||
|
/// .boardBackground` draws the frosted strip that keeps the chrome legible over it).
|
||||||
|
///
|
||||||
|
/// `nil` leaves AppKit's own posture untouched, exactly as `titleVisibility` does — the welcome,
|
||||||
|
/// bootstrap and card windows have no opinion, and neither does a board window while it loads
|
||||||
|
/// (the flag is driven off the snapshot, which does not exist yet). `nil` and `false` therefore
|
||||||
|
/// render identically; they differ only in whether this controller has *said* anything, which is
|
||||||
|
/// what keeps the loading half from having to state a default it does not own.
|
||||||
|
///
|
||||||
|
/// A slot rather than a one-shot write, and **repeat-safe rather than install-once** — the
|
||||||
|
/// `hideTitle` pattern, for a stronger version of its reason: the value has to survive the
|
||||||
|
/// provisional-window swap (`detach()`), *and* it genuinely changes over a window's life. A
|
||||||
|
/// `background:` edited on disk reloads the snapshot, and the chrome follows it in both
|
||||||
|
/// directions.
|
||||||
|
private var extendsUnderTitlebar: Bool?
|
||||||
|
|
||||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
|
||||||
|
|
||||||
// MARK: Attachment
|
// MARK: Attachment
|
||||||
@@ -129,6 +147,7 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
|||||||
addTitlebarAccessoryIfPossible()
|
addTitlebarAccessoryIfPossible()
|
||||||
applyToolbarIfPossible()
|
applyToolbarIfPossible()
|
||||||
applyTitleVisibilityIfPossible()
|
applyTitleVisibilityIfPossible()
|
||||||
|
applyTitlebarExtensionIfPossible()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Puts the previous delegate back and takes the titlebar accessory and toolbar off the window —
|
/// Puts the previous delegate back and takes the titlebar accessory and toolbar off the window —
|
||||||
@@ -233,6 +252,39 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
|||||||
window.titleVisibility = titleVisibility
|
window.titleVisibility = titleVisibility
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Content under the title bar
|
||||||
|
|
||||||
|
/// Runs this window's content the full height of its frame, under a transparent title bar — or
|
||||||
|
/// puts the standard chrome back (see `extendsUnderTitlebar`).
|
||||||
|
///
|
||||||
|
/// Safe whenever the caller learns the answer — before the window exists (held, applied at
|
||||||
|
/// `attach`) or after (applied now) — and safe to call repeatedly with the same value, which
|
||||||
|
/// matters more here than for `hideTitle`: the board window drives this off its snapshot, so it
|
||||||
|
/// is called on every reload that changes the reading and on plenty that do not.
|
||||||
|
///
|
||||||
|
/// **Not undone at `detach`**, `titleVisibility`'s posture: the slot survives the provisional-
|
||||||
|
/// window swap and reapplies itself to whichever window attaches next, and a window that is
|
||||||
|
/// genuinely going away takes its chrome with it.
|
||||||
|
func setExtendsContentUnderTitlebar(_ flag: Bool) {
|
||||||
|
extendsUnderTitlebar = flag
|
||||||
|
applyTitlebarExtensionIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two AppKit knobs the effect needs, and they are one decision: `fullSizeContentView` is
|
||||||
|
/// what lets the content view reach under the title bar, and `titlebarAppearsTransparent` is
|
||||||
|
/// what stops the title bar from painting its own material over it. Either alone is a visible
|
||||||
|
/// half-state — an opaque bar over the board, or a board that stops at a bar that no longer
|
||||||
|
/// draws.
|
||||||
|
private func applyTitlebarExtensionIfPossible() {
|
||||||
|
guard let window, let extendsUnderTitlebar else { return }
|
||||||
|
window.titlebarAppearsTransparent = extendsUnderTitlebar
|
||||||
|
if extendsUnderTitlebar {
|
||||||
|
window.styleMask.insert(.fullSizeContentView)
|
||||||
|
} else {
|
||||||
|
window.styleMask.remove(.fullSizeContentView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
|
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
|
||||||
/// standard path runs — SwiftUI's own delegate gets its callbacks, tabbing behaves — with the
|
/// standard path runs — SwiftUI's own delegate gets its callbacks, tabbing behaves — with the
|
||||||
/// flag telling our own `windowShouldClose` to stand aside.
|
/// flag telling our own `windowShouldClose` to stand aside.
|
||||||
|
|||||||
@@ -345,17 +345,21 @@ extension BoardStore {
|
|||||||
/// the write — or removes the key, which is what "before" means for a field that was not there.
|
/// the write — or removes the key, which is what "before" means for a field that was not there.
|
||||||
///
|
///
|
||||||
/// A **malformed** prior reads as a removal, and that is the one place an inverse is not
|
/// A **malformed** prior reads as a removal, and that is the one place an inverse is not
|
||||||
/// byte-exact: the app cannot re-emit `background: [a, b]` through a document edit that only
|
/// byte-exact: the app cannot re-emit `background: [a, b]` — or a hand-written scalar
|
||||||
/// knows how to set scalars. It is also the case the forward write was designed to clear
|
/// `background: green`, which the schema stopped reading when the key became a mapping — through
|
||||||
/// ("choosing any well replaces it" — 03-board-ui.md § Styling ▸ Controls), so the undo lands the
|
/// a document edit that only writes the shapes the schema names. It is also exactly the case the
|
||||||
/// item on the app's own reading of that field rather than resurrecting a value nothing could
|
/// forward write was designed to clear ("choosing any well replaces it" — 03-board-ui.md §
|
||||||
/// read.
|
/// Styling ▸ Controls), so the undo lands the item on the app's own reading of that field rather
|
||||||
|
/// than resurrecting a value nothing could read.
|
||||||
|
///
|
||||||
|
/// The **mapping** case needs no branch of its own here and gets none: `setStyleValue` edits the
|
||||||
|
/// `color` subkey and leaves the rest (BackgroundField.swift), so an undo on a board with an
|
||||||
|
/// image restores the colour the board had — including restoring it to *absent* — without
|
||||||
|
/// disturbing the image the forward write already preserved. What the inverse does not promise
|
||||||
|
/// is the author's subkey order when the colour was absent before: a restored colour that had no
|
||||||
|
/// pair to go back to is appended, like any newly written subkey.
|
||||||
static func restore(_ prior: FieldValue<String>, to key: String, in document: inout FrontmatterDocument) {
|
static func restore(_ prior: FieldValue<String>, to key: String, in document: inout FrontmatterDocument) {
|
||||||
if let value = prior.value {
|
document.setStyleValue(prior.value, for: key)
|
||||||
document.set(key, to: .string(value))
|
|
||||||
} else {
|
|
||||||
document.remove(key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The style fields a gesture actually set — **one entry per dimension it did not `.keep`**, so a
|
/// The style fields a gesture actually set — **one entry per dimension it did not `.keep`**, so a
|
||||||
|
|||||||
@@ -1931,11 +1931,16 @@ public final class BoardStore: HealHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Through `setStyleValue` rather than `set`/`remove` directly, which is this gesture's whole
|
||||||
|
/// answer to `background` being a mapping (BackgroundField.swift): the colour is written *into*
|
||||||
|
/// the key rather than over it, so a board carrying `background: {color: …, image: …}` comes out
|
||||||
|
/// of a colour change still carrying its image. `icon` takes the plain scalar path through the
|
||||||
|
/// same call.
|
||||||
private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) {
|
private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) {
|
||||||
switch change {
|
switch change {
|
||||||
case .keep: break
|
case .keep: break
|
||||||
case let .set(value): document.set(key, to: .string(value))
|
case let .set(value): document.setStyleValue(value, for: key)
|
||||||
case .remove: document.remove(key)
|
case .remove: document.setStyleValue(nil, for: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
modifiedBy: boardDocument.modifiedBy,
|
||||||
deleted: boardDocument.deleted,
|
deleted: boardDocument.deleted,
|
||||||
background: boardDocument.background,
|
background: boardDocument.background,
|
||||||
|
backgroundImage: boardDocument.backgroundImage,
|
||||||
icon: boardDocument.icon,
|
icon: boardDocument.icon,
|
||||||
iconColor: boardDocument.iconColor,
|
iconColor: boardDocument.iconColor,
|
||||||
template: boardDocument.value(for: templateKey),
|
template: boardDocument.value(for: templateKey),
|
||||||
|
|||||||
@@ -88,6 +88,21 @@ public struct BoardModel: Sendable, Equatable {
|
|||||||
public let deleted: FieldValue<Date>
|
public let deleted: FieldValue<Date>
|
||||||
|
|
||||||
public let background: FieldValue<String>
|
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 icon: FieldValue<String>
|
||||||
public let iconColor: FieldValue<String>
|
public let iconColor: FieldValue<String>
|
||||||
|
|
||||||
|
|||||||
@@ -568,6 +568,19 @@ public enum FrontmatterKeys {
|
|||||||
public static let icon = "icon"
|
public static let icon = "icon"
|
||||||
public static let iconColor = "iconColor"
|
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
|
/// 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
|
/// all levels, re-ruled 2026-07-29). Written at creation of every object, backfilled on touch
|
||||||
/// when absent (`IntegrityRules.healOnTouch`), and never stripped.
|
/// when absent (`IntegrityRules.healOnTouch`), and never stripped.
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ extension FrontmatterDocument {
|
|||||||
record(FrontmatterKeys.modifiedBy, modifiedBy)
|
record(FrontmatterKeys.modifiedBy, modifiedBy)
|
||||||
record(FrontmatterKeys.author, author)
|
record(FrontmatterKeys.author, author)
|
||||||
record(FrontmatterKeys.background, background)
|
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.icon, icon)
|
||||||
record(FrontmatterKeys.iconColor, iconColor)
|
record(FrontmatterKeys.iconColor, iconColor)
|
||||||
record(FrontmatterKeys.kind, kind)
|
record(FrontmatterKeys.kind, kind)
|
||||||
@@ -143,10 +150,54 @@ extension FrontmatterDocument {
|
|||||||
/// (`title: 2048` reads as `"2048"`). Only a sequence or mapping — no scalar reading exists
|
/// (`title: 2048` reads as `"2048"`). Only a sequence or mapping — no scalar reading exists
|
||||||
/// — is malformed.
|
/// — is malformed.
|
||||||
public var title: FieldValue<String> { read(FrontmatterKeys.title, Self.string) }
|
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 icon: FieldValue<String> { read(FrontmatterKeys.icon, Self.string) }
|
||||||
public var iconColor: FieldValue<String> { read(FrontmatterKeys.iconColor, 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 —
|
/// 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
|
/// 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
|
/// part of the sensible reading**, 01-storage-format.md § Frontmatter, settled — the table's
|
||||||
@@ -190,6 +241,36 @@ extension FrontmatterDocument {
|
|||||||
|
|
||||||
// MARK: -
|
// 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> {
|
private func read<Value>(_ key: String, _ transform: (YAMLValue, String) -> Value?) -> FieldValue<Value> {
|
||||||
guard let value = value(for: key) else { return .missing }
|
guard let value = value(for: key) else { return .missing }
|
||||||
if case .null = value { return .missing }
|
if case .null = value { return .missing }
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ extension FrontmatterValue {
|
|||||||
return value
|
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 {
|
private static func quoted(_ value: String) -> String {
|
||||||
var out = "\""
|
var out = "\""
|
||||||
for scalar in value.unicodeScalars {
|
for scalar in value.unicodeScalars {
|
||||||
|
|||||||
@@ -90,19 +90,28 @@ enum Accommodations {
|
|||||||
/// appear").
|
/// appear").
|
||||||
///
|
///
|
||||||
/// The design's own example (the card face carousel's page dots) died with the carousel
|
/// The design's own example (the card face carousel's page dots) died with the carousel
|
||||||
/// (03-board-ui.md § Card face's no-carousel resettlement), so the rule's one surviving subject
|
/// (03-board-ui.md § Card face's no-carousel resettlement), so the rule's subjects on the board
|
||||||
/// on the board is the transient search bar's `.bar` material. It is stated as a type anyway
|
/// are the transient search bar's `.bar` material and the backdrop's title-bar frost — the
|
||||||
/// rather than inlined at that one call site, because "wherever they appear" is a standing rule
|
/// "next material to arrive" this type was stated for, and it found the answer already written.
|
||||||
/// and the next material to arrive should find the answer already written.
|
|
||||||
enum Underlay: Equatable {
|
enum Underlay: Equatable {
|
||||||
/// `Material.bar` — the find-bar's own backdrop, translucent over the board beneath it.
|
/// `Material.bar` — the find-bar's own backdrop, translucent over the board beneath it.
|
||||||
case glass
|
case glass
|
||||||
|
/// `Material.thin` — the title-bar frost over a custom board backdrop
|
||||||
|
/// (`BoardView.boardBackground`). Deliberately not `.bar` — the find-bar sits over lanes
|
||||||
|
/// the board's own plates have already calmed, where the frost sits directly on an image
|
||||||
|
/// the author may well have chosen *for* its busyness — and deliberately not the heavier
|
||||||
|
/// notches either, tried and retired: `.ultraThick` read as a cloudy plate where a backdrop
|
||||||
|
/// should still show through, and `.regular` still veiled it more than the chrome needs.
|
||||||
|
/// The thin weight carries the chrome, and the dissolve below it (`BoardView.frostStrip`)
|
||||||
|
/// is what keeps the strip from reading as a bar.
|
||||||
|
case frost
|
||||||
/// The window's own background colour, opaque.
|
/// The window's own background colour, opaque.
|
||||||
case solid
|
case solid
|
||||||
|
|
||||||
var style: AnyShapeStyle {
|
var style: AnyShapeStyle {
|
||||||
switch self {
|
switch self {
|
||||||
case .glass: AnyShapeStyle(.bar)
|
case .glass: AnyShapeStyle(.bar)
|
||||||
|
case .frost: AnyShapeStyle(.thinMaterial)
|
||||||
case .solid: AnyShapeStyle(Color(nsColor: .windowBackgroundColor))
|
case .solid: AnyShapeStyle(Color(nsColor: .windowBackgroundColor))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +121,12 @@ enum Accommodations {
|
|||||||
reduceTransparency ? .solid : .glass
|
reduceTransparency ? .solid : .glass
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The title-bar frost's own reading of the same rule — heavier glass, identical accommodation:
|
||||||
|
/// under Reduce Transparency both underlays take the one solid.
|
||||||
|
static func frost(reduceTransparency: Bool) -> Underlay {
|
||||||
|
reduceTransparency ? .solid : .frost
|
||||||
|
}
|
||||||
|
|
||||||
/// A **translucent wash** — a tint laid over whatever happens to be behind it, and the shape
|
/// A **translucent wash** — a tint laid over whatever happens to be behind it, and the shape
|
||||||
/// every non-material translucency on the board takes: every lane's plate, the trash column's
|
/// every non-material translucency on the board takes: every lane's plate, the trash column's
|
||||||
/// plate and hatched header, and the drag shadow's fill.
|
/// plate and hatched header, and the drag shadow's fill.
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import ImageIO
|
||||||
|
import SwiftUI
|
||||||
|
import os
|
||||||
|
|
||||||
|
// MARK: - BoardBackdrop
|
||||||
|
|
||||||
|
/// **The board background's image half** (03-board-ui.md § Styling ▸ Capabilities; the `background`
|
||||||
|
/// mapping's `image` subkey, 01-storage-format.md § Frontmatter).
|
||||||
|
///
|
||||||
|
/// ### The path is relative, and it stays inside the board
|
||||||
|
///
|
||||||
|
/// `image: sunset.jpg` names a file in the board folder; `image: art/sunset.jpg` names one in a
|
||||||
|
/// subfolder of it. An absolute path, or any path that climbs out with `..`, resolves to **nothing**
|
||||||
|
/// — the same lenient degrade as an unrecognized colour, and for the same two reasons. A `.kanban`
|
||||||
|
/// folder is a document: it is what gets copied, zipped, synced and handed to somebody else, and a
|
||||||
|
/// background pointing at `/Users/someone/Pictures` would silently stop working the moment it left
|
||||||
|
/// this Mac. And the sandbox would refuse the read anyway — the board's own security-scoped access
|
||||||
|
/// is the only thing this app holds — so the rule the containment check states is the rule the
|
||||||
|
/// system would enforce one layer down, stated where it can be explained instead of failing.
|
||||||
|
///
|
||||||
|
/// The check is **lexical**, which is what makes it testable without a filesystem, and it is not the
|
||||||
|
/// security boundary: a symlink inside the board pointing anywhere at all still resolves here and is
|
||||||
|
/// still refused by the sandbox when the bytes are asked for. That is the correct division — 01's
|
||||||
|
/// "symlinks are never traversed" governs what the *loader* renders as items, and this reads bytes
|
||||||
|
/// nobody has an identity claim on.
|
||||||
|
///
|
||||||
|
/// ### Nothing here decides whether the file is any good
|
||||||
|
///
|
||||||
|
/// A path that resolves, a file that is missing, and a file that is not an image all end the same
|
||||||
|
/// way: no image, no banner, no defect, bytes untouched. There is no editing UI for the field at all
|
||||||
|
/// (Controls: "the raw file is the escape hatch"), so the one person who can be wrong about it is
|
||||||
|
/// the one person looking at the folder.
|
||||||
|
enum BoardBackdrop {
|
||||||
|
|
||||||
|
/// The longest edge, in pixels, the backdrop is ever decoded at.
|
||||||
|
///
|
||||||
|
/// Generous enough for a 6K display's short side and for the Retina backing of any window a
|
||||||
|
/// board is realistically shown in, and small enough that a 60-megapixel photo dropped in the
|
||||||
|
/// folder never becomes a 240 MB decode on a window resize. ImageIO does the reduction while it
|
||||||
|
/// reads (`decode`), so the full-size bitmap is never materialized at all.
|
||||||
|
static let maximumPixelSize = 3072
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-backdrop")
|
||||||
|
|
||||||
|
/// Where `path` lands inside `root`, or `nil` when it lands nowhere this board may read.
|
||||||
|
///
|
||||||
|
/// Standardized before the comparison so `art/../sunset.jpg` is recognized as the file it names
|
||||||
|
/// — the check is about where the path *ends up*, not how it is spelled. The trailing separator
|
||||||
|
/// on the root is what keeps a sibling board named `Boards/Work.kanban.backup` from passing a
|
||||||
|
/// prefix test against `Boards/Work.kanban`.
|
||||||
|
///
|
||||||
|
/// `~` is not expanded and is not special: a file honestly named `~notes.png` sitting in the
|
||||||
|
/// board folder resolves, because only the shell ever meant anything else by that character.
|
||||||
|
static func imageURL(named path: String, inBoardRoot root: URL) -> URL? {
|
||||||
|
// An absolute path has to be rejected before it is appended, not after: appending `/etc/x`
|
||||||
|
// to a root yields `<root>/etc/x`, which *passes* containment while naming a file the author
|
||||||
|
// plainly did not mean.
|
||||||
|
guard !path.isEmpty, !path.hasPrefix("/") else { return nil }
|
||||||
|
let root = root.standardizedFileURL
|
||||||
|
let candidate = root.appendingPathComponent(path).standardizedFileURL
|
||||||
|
guard candidate.path.hasPrefix(root.path + "/") else { return nil }
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This board's backdrop image, where it has a readable one to name.
|
||||||
|
static func imageURL(for board: BoardModel, root: URL) -> URL? {
|
||||||
|
guard let path = board.backgroundImage.value else { return nil }
|
||||||
|
return imageURL(named: path, inBoardRoot: root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this board paints a background of its own — **the window-chrome predicate**
|
||||||
|
/// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one
|
||||||
|
/// runs its content under a transparent title bar, and a board without one keeps the standard
|
||||||
|
/// chrome exactly as it has always looked.
|
||||||
|
///
|
||||||
|
/// It asks the *resolved* image URL rather than merely whether the key reads, so a path that
|
||||||
|
/// could never paint anything — absolute, or climbing out of the board — leaves the chrome alone
|
||||||
|
/// instead of producing a transparent title bar over the standard background. It does **not**
|
||||||
|
/// ask whether the file exists: that is a disk touch, this is read on every board render, and a
|
||||||
|
/// declared-but-missing image renders as the frosted strip alone — which is the honest picture of
|
||||||
|
/// a board that asked for a backdrop it has not got.
|
||||||
|
static func isCustom(_ board: BoardModel, root: URL) -> Bool {
|
||||||
|
Palette.color(for: board.background) != nil || imageURL(for: board, root: root) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Reading the bytes
|
||||||
|
|
||||||
|
/// The file's identity as far as reloading is concerned — modification date and size.
|
||||||
|
///
|
||||||
|
/// Both, because either alone is forgeable by an ordinary copy: a file replaced within the
|
||||||
|
/// timestamp's resolution keeps its date, and a re-export at the same instant rarely keeps its
|
||||||
|
/// byte count too. Missing values (a file that is not there) compare equal to each other, which
|
||||||
|
/// is what stops a board naming a missing image from re-decoding on every reload.
|
||||||
|
struct Stamp: Equatable, Sendable {
|
||||||
|
var modified: Date?
|
||||||
|
var size: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stamp(of url: URL) -> Stamp {
|
||||||
|
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
|
||||||
|
return Stamp(modified: values?.contentModificationDate, size: values?.fileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes the file at `url`, downsampled to `maximumPixelSize` on its longest edge — or `nil`
|
||||||
|
/// for anything that is not a readable image.
|
||||||
|
///
|
||||||
|
/// **ImageIO's thumbnail path, not a full decode plus a resize**: `CGImageSourceCreateThumbnail
|
||||||
|
/// AtIndex` reads at a reduced scale, so the peak allocation is the *output* size rather than
|
||||||
|
/// the file's. `FromImageAlways` is what makes it a downsample rather than a lottery — without
|
||||||
|
/// it a JPEG carrying its own small embedded thumbnail would answer with that instead of the
|
||||||
|
/// picture. `WithTransform` applies the EXIF orientation, so a photo shot in portrait is not
|
||||||
|
/// laid on its side.
|
||||||
|
///
|
||||||
|
/// Never call this on the main actor; see `BoardBackdropImage`'s task.
|
||||||
|
static func decode(_ url: URL) -> CGImage? {
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
|
kCGImageSourceShouldCacheImmediately: true,
|
||||||
|
kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize,
|
||||||
|
]
|
||||||
|
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
||||||
|
logger.debug("board backdrop image could not be decoded")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BoardBackdropImage
|
||||||
|
|
||||||
|
/// The decoded backdrop, drawn to fill (03-board-ui.md § Styling ▸ Capabilities).
|
||||||
|
///
|
||||||
|
/// **Fill, cropped — never letterboxed and never stretched.** A background is a surface, so it
|
||||||
|
/// covers the window whatever its aspect ratio; the alternative would put bars of the underlying
|
||||||
|
/// colour along two edges and make the board look broken rather than styled.
|
||||||
|
///
|
||||||
|
/// ### The load is asynchronous, and that is the whole design of this view
|
||||||
|
///
|
||||||
|
/// A board is opened by double-clicking a folder, and the folder may contain a 60-megapixel
|
||||||
|
/// photograph. Decoding that on the main actor during a body evaluation is a visible hitch on open
|
||||||
|
/// and a worse one on every subsequent reload, so the work happens off it and the view simply has
|
||||||
|
/// nothing to draw until it lands — under the board's colour, which is already painted beneath.
|
||||||
|
///
|
||||||
|
/// The task is keyed on the URL and on the store's landed-reload count, which is the board's
|
||||||
|
/// FSEvents pulse: replacing `sunset.jpg` in Finder changes no *model* value, so the snapshot comes
|
||||||
|
/// back equal and `snapshotGeneration` deliberately does not move (`BoardStore.landedReloads`) —
|
||||||
|
/// keying on the generation would mean an edited image never reloaded. Every re-key costs one
|
||||||
|
/// `stat`; only a file that actually changed costs a decode.
|
||||||
|
struct BoardBackdropImage: View {
|
||||||
|
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
/// The board's landed-reload count — see the type's note. Not read from a store here because
|
||||||
|
/// this view has no other reason to hold one.
|
||||||
|
let reloads: Int
|
||||||
|
|
||||||
|
/// What is on screen, and what it was decoded from. One value rather than three `@State`s so a
|
||||||
|
/// URL, its stamp and its bitmap can never disagree about which file is being shown.
|
||||||
|
@State private var loaded: Loaded?
|
||||||
|
|
||||||
|
private struct Loaded {
|
||||||
|
let url: URL
|
||||||
|
let stamp: BoardBackdrop.Stamp
|
||||||
|
let image: CGImage
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// `Color.clear` establishes the frame the image fills and is what `clipped` trims against;
|
||||||
|
// the overlay is what overflows it. Decorative, because a board background is decoration in
|
||||||
|
// the precise sense 10-accessibility.md means — it carries no information VoiceOver could
|
||||||
|
// usefully say, and the ink rule keeps the text on it legible on its own.
|
||||||
|
Color.clear
|
||||||
|
.overlay {
|
||||||
|
if let loaded {
|
||||||
|
Image(decorative: loaded.image, scale: 1)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
|
.task(id: Key(url: url, reloads: reloads)) { await reload() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `.task` identity: the file, and the board's pulse.
|
||||||
|
private struct Key: Equatable {
|
||||||
|
let url: URL
|
||||||
|
let reloads: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-decodes when the bytes have changed, and only then.
|
||||||
|
///
|
||||||
|
/// `Task.detached` rather than a bare `await` on a `nonisolated` function, so the hop off this
|
||||||
|
/// view's actor is stated rather than inferred from whatever the language mode currently makes
|
||||||
|
/// of an async call. Cancellation is checked on the way back instead of forwarded into it: both
|
||||||
|
/// halves are short, and a stale bitmap assigned to a view that has gone away is the failure
|
||||||
|
/// worth preventing.
|
||||||
|
private func reload() async {
|
||||||
|
let url = url
|
||||||
|
let stamp = await Task.detached(priority: .utility) { BoardBackdrop.stamp(of: url) }.value
|
||||||
|
if let loaded, loaded.url == url, loaded.stamp == stamp { return }
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
let decoded = await Task.detached(priority: .userInitiated) { BoardBackdrop.decode(url) }.value
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
// A failure clears what was there: the file the board names is the file it shows, and
|
||||||
|
// holding the previous picture would make a broken path look like a working one.
|
||||||
|
loaded = decoded.map { Loaded(url: url, stamp: stamp, image: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -499,10 +499,11 @@ struct BoardSearchBar: View {
|
|||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
let presentation: BoardSearchPresentation
|
let presentation: BoardSearchPresentation
|
||||||
|
|
||||||
/// Reduce Transparency — **this bar is the board's one glass underlay** ("glass underlays go
|
/// Reduce Transparency — this bar is one of the board's two glass underlays, beside the
|
||||||
/// solid, wherever they appear", 10-accessibility.md; the design's own example, the card face
|
/// backdrop's title-bar frost ("glass underlays go solid, wherever they appear",
|
||||||
/// carousel's page dots, died with the carousel). `.bar` is a material, so under the setting it
|
/// 10-accessibility.md; the design's own example, the card face carousel's page dots, died with
|
||||||
/// becomes the opaque window background (`Accommodations.Underlay`).
|
/// the carousel). `.bar` is a material, so under the setting it becomes the opaque window
|
||||||
|
/// background (`Accommodations.Underlay`).
|
||||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||||
|
|
||||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ struct BoardView: View {
|
|||||||
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
|
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
|
||||||
@Environment(\.colorSchemeContrast) private var contrast
|
@Environment(\.colorSchemeContrast) private var contrast
|
||||||
|
|
||||||
|
/// Reduce Transparency, for the backdrop's title-bar frost — the one glass underlay this view
|
||||||
|
/// draws (10-accessibility.md: "glass underlays go solid, wherever they appear";
|
||||||
|
/// `Accommodations.frost`).
|
||||||
|
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||||
|
|
||||||
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
|
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
|
||||||
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
|
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
|
||||||
/// must go back to meaning create/rename rather than nothing at all.
|
/// must go back to meaning create/rename rather than nothing at all.
|
||||||
@@ -418,13 +423,42 @@ struct BoardView: View {
|
|||||||
/// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
|
/// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
|
||||||
/// its ≥ 4.5:1 rule to: text does sit on it.
|
/// its ≥ 4.5:1 rule to: text does sit on it.
|
||||||
///
|
///
|
||||||
|
/// ### Three layers, and the window is the frame
|
||||||
|
///
|
||||||
|
/// The colour is the underlay, the image draws over it, and a frosted strip sits on top of both
|
||||||
|
/// under the title bar. All of it runs the **full height of the window** — `ignoresSafeArea`
|
||||||
|
/// here is what the window's own `fullSizeContentView` flip is for (`HostedWindowController
|
||||||
|
/// .setExtendsContentUnderTitlebar`, driven from `BoardWindowHost`), and the two only ever move
|
||||||
|
/// together: a board with no background of its own draws none of this and keeps the standard
|
||||||
|
/// chrome exactly as it has always looked.
|
||||||
|
///
|
||||||
|
/// The **colour is painted even while an image is loading**, and stays painted underneath it: a
|
||||||
|
/// decode is asynchronous (`BoardBackdropImage`) and a window that flashed the system background
|
||||||
|
/// on open would be the hitch that work exists to avoid. It is also what a failed or missing
|
||||||
|
/// image degrades to, with nothing said about it.
|
||||||
|
///
|
||||||
|
/// The **frost** is the price of the extended chrome: the title bar's own material is gone, so
|
||||||
|
/// the traffic lights, the board-name widget and the toolbar would otherwise sit directly on a
|
||||||
|
/// saturated colour or a photograph. It is a glass underlay (`Accommodations.frost` — the
|
||||||
|
/// ladder's thin weight, both ends tried and retired: `.bar` reads as barely-there over a
|
||||||
|
/// busy image, `.regular` and up as a veil the backdrop shouldn't pay for) and takes the
|
||||||
|
/// standard accommodation: solid
|
||||||
|
/// under Reduce Transparency, "wherever they appear". Its geometry is `frostStrip`'s — full
|
||||||
|
/// strength through the top safe-area inset, dissolving over a short tail below it — with the
|
||||||
|
/// inset read from a `GeometryReader` that is itself inside the `ignoresSafeArea`: the proxy
|
||||||
|
/// still reports the inset it was told to ignore, which is exactly the title-bar-plus-toolbar
|
||||||
|
/// band and moves on its own when the toolbar's size class changes. Nothing here hit-tests, so
|
||||||
|
/// the widget and the toolbar above it are untouched.
|
||||||
|
///
|
||||||
|
/// ### The contrast rule is the colour's, and the image is outside it
|
||||||
|
///
|
||||||
/// The rule is enforced from the *text* side rather than here, because this view paints the
|
/// The rule is enforced from the *text* side rather than here, because this view paints the
|
||||||
/// surface and draws none of the glyphs on it. Whatever colour lands below — a palette name or a
|
/// surface and draws none of the glyphs on it. Whatever colour lands below — a palette name, a
|
||||||
/// hand-written hex, they reach the same place — has its text colour computed against the
|
/// hand-written hex, or the `color` subkey of the mapping form, they reach the same place — has
|
||||||
/// threshold by `BoardTextInk`, composited over the window background in the active appearance
|
/// its text colour computed against the threshold by `BoardTextInk`, composited over the window
|
||||||
/// and recomputed on an appearance flip; the two subtrees that sit on this fill
|
/// background in the active appearance and recomputed on an appearance flip; the two subtrees
|
||||||
/// (`LaneView.header` and `TrashLaneView.header` — their plates are translucent washes the
|
/// that sit on this fill (`LaneView.header` and `TrashLaneView.header` — their plates are
|
||||||
/// colour shows through, where every card carries its own opaque plate,
|
/// translucent washes the colour shows through, where every card carries its own opaque plate,
|
||||||
/// `BoardSurface.cardPlate`) take the answer as a `\.colorScheme` override.
|
/// `BoardSurface.cardPlate`) take the answer as a `\.colorScheme` override.
|
||||||
///
|
///
|
||||||
/// **One path, two verification stories** (`ContrastMath`): the twelve palette pairs are checked
|
/// **One path, two verification stories** (`ContrastMath`): the twelve palette pairs are checked
|
||||||
@@ -433,12 +467,69 @@ struct BoardView: View {
|
|||||||
/// cannot be settled by a table of colours alone); an arbitrary hex is checked only as it
|
/// cannot be settled by a table of colours alone); an arbitrary hex is checked only as it
|
||||||
/// renders, because its value arrives from a file.
|
/// renders, because its value arrives from a file.
|
||||||
///
|
///
|
||||||
|
/// **An image makes no AA claim at all**, and the ink does not try to derive one from it. Ink
|
||||||
|
/// still follows the `color` reading — the colour the author chose to sit under the picture, or
|
||||||
|
/// the default when they chose none — which is the same bytes-from-a-file posture an arbitrary
|
||||||
|
/// hex already has, one step further out: a photograph has no single luminance to threshold
|
||||||
|
/// against, the field has no in-app control that could warn about one, and a per-pixel answer
|
||||||
|
/// would change as the window resized. An author who lays text over a busy picture is doing what
|
||||||
|
/// the raw file exists to let them do.
|
||||||
|
///
|
||||||
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
|
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
|
||||||
/// the same lenient degrade as the other two levels, and the bytes stay as written.
|
/// the same lenient degrade as the other two levels, and the bytes stay as written.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var boardBackground: some View {
|
private var boardBackground: some View {
|
||||||
if let color = Palette.color(for: store.snapshot.background) {
|
let color = Palette.color(for: store.snapshot.background)
|
||||||
|
let image = BoardBackdrop.imageURL(for: store.snapshot, root: store.rootURL)
|
||||||
|
if color != nil || image != nil {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
ZStack(alignment: .top) {
|
||||||
color
|
color
|
||||||
|
if let image {
|
||||||
|
BoardBackdropImage(url: image, reloads: store.landedReloads)
|
||||||
|
}
|
||||||
|
frostStrip(inset: proxy.safeAreaInsets.top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
// A background is scenery: the strip's own empty-surface gestures — the click that
|
||||||
|
// clears the selection, the rubber band — live in `backdrop`, one layer in, and would be
|
||||||
|
// swallowed by anything here that answered a hit test.
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frost, full-strength through the title-bar band and dissolving over a short tail below
|
||||||
|
/// it — a scroll-edge dissolve rather than a shelf. The chrome sits on an even material the
|
||||||
|
/// whole way down, and the strip's bottom edge is nowhere in particular, so the backdrop reads
|
||||||
|
/// as one surface the chrome floats over rather than a bar laid across a picture. The tail is a
|
||||||
|
/// fraction of the band, so it scales with the toolbar's own height and only ever reaches into
|
||||||
|
/// the strip's outer padding, not the lanes.
|
||||||
|
///
|
||||||
|
/// Under Reduce Transparency the fade goes with the glass: "solid" means an honest opaque bar
|
||||||
|
/// with the standard chrome's own hard edge (`Accommodations.frost`), not a solid that thins
|
||||||
|
/// out — a partially transparent solid would be the setting's own defeat.
|
||||||
|
@ViewBuilder
|
||||||
|
private func frostStrip(inset: CGFloat) -> some View {
|
||||||
|
let underlay = Accommodations.frost(reduceTransparency: reduceTransparency)
|
||||||
|
if underlay == .solid {
|
||||||
|
Rectangle().fill(underlay.style).frame(height: inset)
|
||||||
|
} else if inset > 0 {
|
||||||
|
let tail = inset * 0.35
|
||||||
|
Rectangle()
|
||||||
|
.fill(underlay.style)
|
||||||
|
.frame(height: inset + tail)
|
||||||
|
.mask {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: .black, location: 0),
|
||||||
|
.init(color: .black, location: inset / (inset + tail)),
|
||||||
|
.init(color: .clear, location: 1),
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
let retitled = try fixture.snapshot()
|
||||||
try fixture.item(
|
try fixture.item(
|
||||||
".trash/\(trashedLaneID)",
|
".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)
|
let styled = BoardDiff.between(retitled, try fixture.snapshot(), includingTrash: true)
|
||||||
#expect(styled.lanes.isEmpty, "no accent is rendered, so nothing visible changed")
|
#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
|
// 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).
|
// 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)) {
|
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()
|
window.board.undo()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ struct CardDetailsKeyTests {
|
|||||||
created: 2026-01-01T09:00:00Z
|
created: 2026-01-01T09:00:00Z
|
||||||
modified: 2026-02-02T09:00:00Z
|
modified: 2026-02-02T09:00:00Z
|
||||||
modified-by: claude
|
modified-by: claude
|
||||||
background: mint
|
background: {color: mint}
|
||||||
icon: flag
|
icon: flag
|
||||||
iconColor: carnation
|
iconColor: carnation
|
||||||
project: overlay-rewritten
|
project: overlay-rewritten
|
||||||
@@ -99,7 +99,7 @@ struct CardDetailsKeyTests {
|
|||||||
schema: 1
|
schema: 1
|
||||||
title: Styled
|
title: Styled
|
||||||
order: 1024
|
order: 1024
|
||||||
background: mint
|
background: {color: mint}
|
||||||
icon: flag
|
icon: flag
|
||||||
iconColor: carnation
|
iconColor: carnation
|
||||||
created: 2026-01-01T09:00:00Z
|
created: 2026-01-01T09:00:00Z
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ struct CommitMessageSingleEventTests {
|
|||||||
let restyled = try compose { fixture in
|
let restyled = try compose { fixture in
|
||||||
try fixture.item(
|
try fixture.item(
|
||||||
"\(Ident.lane1)/\(Ident.card1)",
|
"\(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'")
|
#expect(restyled == "Restyle card 'Fix login'")
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ struct FixtureCoercionTests {
|
|||||||
let cardTitleSeq = "40000000-0000-4000-8000-000000000004"
|
let cardTitleSeq = "40000000-0000-4000-8000-000000000004"
|
||||||
let cardIconColorInt = "50000000-0000-4000-8000-000000000005"
|
let cardIconColorInt = "50000000-0000-4000-8000-000000000005"
|
||||||
let cardBackgroundMap = "60000000-0000-4000-8000-000000000006"
|
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 cardDeletedBad = "80000000-0000-4000-8000-000000000008"
|
||||||
|
|
||||||
let result = try loadFixture("Valid/coercion.kanban")
|
let result = try loadFixture("Valid/coercion.kanban")
|
||||||
@@ -371,8 +371,14 @@ struct FixtureCoercionTests {
|
|||||||
#expect(try card(cardTitleInt).title == .valid("2048"))
|
#expect(try card(cardTitleInt).title == .valid("2048"))
|
||||||
#expect(try card(cardTitleSeq).title == .malformed(raw: "[a, b]"))
|
#expect(try card(cardTitleSeq).title == .malformed(raw: "[a, b]"))
|
||||||
#expect(try card(cardIconColorInt).iconColor == .valid("42"))
|
#expect(try card(cardIconColorInt).iconColor == .valid("42"))
|
||||||
#expect(try card(cardBackgroundMap).background == .malformed(raw: "{x: 1}"))
|
// **`background` is a mapping and only a mapping** (01-storage-format.md § Frontmatter,
|
||||||
#expect(try card(cardBackgroundInt).background == .valid("12345"))
|
// 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)
|
let deletedBad = try card(cardDeletedBad)
|
||||||
#expect(deletedBad.deleted == .malformed(raw: "definitely-not-a-date"))
|
#expect(deletedBad.deleted == .malformed(raw: "definitely-not-a-date"))
|
||||||
|
|||||||
@@ -551,8 +551,8 @@ struct FrontmatterLenientFieldTests {
|
|||||||
|
|
||||||
@Test func wellFormedLenientValues() throws {
|
@Test func wellFormedLenientValues() throws {
|
||||||
#expect(try document("title: My Board").title == .valid("My Board"))
|
#expect(try document("title: My Board").title == .valid("My Board"))
|
||||||
#expect(try document("background: \"#ff8800\"").background == .valid("#ff8800"))
|
#expect(try document("background: {color: \"#ff8800\"}").background == .valid("#ff8800"))
|
||||||
#expect(try document("background: slate").background == .valid("slate"))
|
#expect(try document("background: {color: slate}").background == .valid("slate"))
|
||||||
#expect(try document("icon: tray.full").icon == .valid("tray.full"))
|
#expect(try document("icon: tray.full").icon == .valid("tray.full"))
|
||||||
#expect(try document("iconColor: teal").iconColor == .valid("teal"))
|
#expect(try document("iconColor: teal").iconColor == .valid("teal"))
|
||||||
#expect(try document("width: 3").width == .valid(3))
|
#expect(try document("width: 3").width == .valid(3))
|
||||||
@@ -565,7 +565,7 @@ struct FrontmatterLenientFieldTests {
|
|||||||
@Test func scalarsOfTheWrongTypeCoerceToTheirSourceText() throws {
|
@Test func scalarsOfTheWrongTypeCoerceToTheirSourceText() throws {
|
||||||
#expect(try document("title: 2048").title == .valid("2048"))
|
#expect(try document("title: 2048").title == .valid("2048"))
|
||||||
#expect(try document("title: true").title == .valid("true"))
|
#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("iconColor: true").iconColor == .valid("true"))
|
||||||
#expect(try document("icon: 2026-07-26T16:41:38Z").icon == .valid("2026-07-26T16:41:38Z"))
|
#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 {
|
@Test func aTrailingCommentIsNotPartOfACoercedValue() throws {
|
||||||
#expect(try document("title: 2048 # note").title == .valid("2048"))
|
#expect(try document("title: 2048 # note").title == .valid("2048"))
|
||||||
#expect(try document("title: true # note").title == .valid("true"))
|
#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"))
|
#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.
|
/// at all, so a read must not treat it as a comment.
|
||||||
@Test func aHashInsideAQuotedValueIsNotTrimmed() throws {
|
@Test func aHashInsideAQuotedValueIsNotTrimmed() throws {
|
||||||
#expect(try document("title: \"2048 # note\"").title == .valid("2048 # note"))
|
#expect(try document("title: \"2048 # note\"").title == .valid("2048 # note"))
|
||||||
#expect(try document("background: \"#ff8800\"").background == .valid("#ff8800"))
|
#expect(try document("background: {color: \"#ff8800\"}").background == .valid("#ff8800"))
|
||||||
#expect(try document("background: \"#ff8800\" # brand orange").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
|
/// 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]"))
|
#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,
|
/// 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
|
/// mapping, or scalar with no integer reading whatsoever stays malformed and renders as the
|
||||||
/// default 1.
|
/// default 1.
|
||||||
|
|||||||
@@ -460,11 +460,11 @@ struct ObjectKindWriteTests {
|
|||||||
try BoardWriter.updateIndex(
|
try BoardWriter.updateIndex(
|
||||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
operation: .style(title: nil)
|
operation: .style(title: nil)
|
||||||
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
|
) { $0.setStyleValue("fern", for: FrontmatterKeys.background) }
|
||||||
try BoardWriter.updateIndex(
|
try BoardWriter.updateIndex(
|
||||||
inItemFolder: fixture.url(Ident.lane1),
|
inItemFolder: fixture.url(Ident.lane1),
|
||||||
operation: .style(title: nil)
|
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)/\(Ident.card1)", in: fixture) == .valid("card"))
|
||||||
#expect(try kind(of: Ident.lane1, in: fixture) == .valid("lane"))
|
#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"))
|
let folder = try fixture.item("notes", Item.rich(order: "1024", title: "Hand-made"))
|
||||||
|
|
||||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) {
|
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)
|
#expect(try kind(of: "notes", in: fixture) == .missing)
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ private func makeBoard() throws -> WriterFixture {
|
|||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
try fixture.item("", boardIndex)
|
try fixture.item("", boardIndex)
|
||||||
try fixture.item(Ident.lane1, styled(order: "1024", title: "Todo"))
|
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.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.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third"))
|
||||||
try fixture.item(Ident.lane3, Item.uneditable)
|
try fixture.item(Ident.lane3, Item.uneditable)
|
||||||
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone"))
|
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"))
|
store.applyStyle(to: .items([card2]), background: .set("smokey-ocean"))
|
||||||
|
|
||||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
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("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(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
|
||||||
#expect(untouchedLines(after) == untouchedLines(before))
|
#expect(untouchedLines(after) == untouchedLines(before))
|
||||||
@@ -141,13 +141,13 @@ struct StyleWriteTests {
|
|||||||
store.applyStyle(to: .items([card1]), background: .set("dark-teal"), icon: .set("flag"))
|
store.applyStyle(to: .items([card1]), background: .set("dark-teal"), icon: .set("flag"))
|
||||||
|
|
||||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
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"))
|
#expect(after.contains("icon: flag"))
|
||||||
// "iconColor: resolved — schema yes, control no" (03 § Styling ▸ Capabilities): the field
|
// "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
|
// 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.
|
// carry it through untouched like any unknown key.
|
||||||
#expect(after.contains("iconColor: chalk"))
|
#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")
|
@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.begins == 1)
|
||||||
#expect(log.ends == 1)
|
#expect(log.ends == 1)
|
||||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").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: 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")
|
@Test("A value a target already carries writes nothing — per target and per dimension")
|
||||||
@@ -195,13 +195,13 @@ struct StyleWriteTests {
|
|||||||
log.attach(to: store)
|
log.attach(to: store)
|
||||||
let untouchedCard = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
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
|
// move. A well clicked twice must not stamp `modified` or mint a commit on what was already
|
||||||
// right (`setLaneWidth`'s rule).
|
// right (`setLaneWidth`'s rule).
|
||||||
store.applyStyle(to: .items([card1, card2]), background: .set("fern"))
|
store.applyStyle(to: .items([card1, card2]), background: .set("fern"))
|
||||||
|
|
||||||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == untouchedCard)
|
#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")
|
#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)
|
log.attach(to: store)
|
||||||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
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.
|
// the removal is a no-op too.
|
||||||
store.applyStyle(to: .items([card1]), background: .set("fern"), icon: .remove)
|
store.applyStyle(to: .items([card1]), background: .set("fern"), icon: .remove)
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ struct StyleWriteTests {
|
|||||||
store.applyStyle(to: .items([card2]), background: .set("shale"))
|
store.applyStyle(to: .items([card2]), background: .set("shale"))
|
||||||
|
|
||||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
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]"))
|
#expect(!after.contains("[a, b]"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ struct StyleWriteTests {
|
|||||||
store.applyStyle(to: .board, background: .set("intense-cool-shale"), icon: .set("square.stack"))
|
store.applyStyle(to: .board, background: .set("intense-cool-shale"), icon: .set("square.stack"))
|
||||||
|
|
||||||
let after = try fixture.indexText("")
|
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("icon: square.stack"))
|
||||||
#expect(after.contains("iconColor: carnation"))
|
#expect(after.contains("iconColor: carnation"))
|
||||||
#expect(after.contains("Board description."))
|
#expect(after.contains("Board description."))
|
||||||
@@ -258,6 +258,38 @@ struct StyleWriteTests {
|
|||||||
#expect(lane(lane1, in: model)?.background.isMissing == true)
|
#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")
|
@Test("Vanished and trashed targets are skipped silently")
|
||||||
func skipsTargetsThatRenderNowhere() throws {
|
func skipsTargetsThatRenderNowhere() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ schema: 1
|
|||||||
title: Styled
|
title: Styled
|
||||||
order: 3072
|
order: 3072
|
||||||
project: lanework # agent overlay
|
project: lanework # agent overlay
|
||||||
background: blue
|
background: {color: blue}
|
||||||
icon: star
|
icon: star
|
||||||
created: 2026-01-01T09:00:00Z
|
created: 2026-01-01T09:00:00Z
|
||||||
---
|
---
|
||||||
@@ -991,7 +991,7 @@ private enum Foreign {
|
|||||||
|
|
||||||
static func restyle(_ fixture: WriterFixture, _ path: String, background: String) throws {
|
static func restyle(_ fixture: WriterFixture, _ path: String, background: String) throws {
|
||||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .style(title: nil)) { document in
|
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")
|
@Suite("Accommodations ▸ Reduce Transparency")
|
||||||
struct ReduceTransparencyTests {
|
struct ReduceTransparencyTests {
|
||||||
|
|
||||||
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board's one
|
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board carries
|
||||||
/// surviving material is the transient search bar's `.bar` — the design's own example, the card
|
/// two materials — the transient search bar's `.bar` and the backdrop's title-bar frost — and
|
||||||
/// face carousel's page dots, died with the carousel (03-board-ui.md § Card face).
|
/// the rule is one rule: both take the same solid, whatever their weights without it.
|
||||||
@Test("The one glass underlay goes solid")
|
@Test("Both glass underlays go solid")
|
||||||
func glassGoesSolid() {
|
func glassGoesSolid() {
|
||||||
#expect(Accommodations.underlay(reduceTransparency: false) == .glass)
|
#expect(Accommodations.underlay(reduceTransparency: false) == .glass)
|
||||||
#expect(Accommodations.underlay(reduceTransparency: true) == .solid)
|
#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
|
/// 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 step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) {
|
||||||
try BoardWriter.updateIndex(
|
try BoardWriter.updateIndex(
|
||||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: .style(title: nil)
|
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 step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) {
|
||||||
try BoardWriter.updateIndex(
|
try BoardWriter.updateIndex(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
|
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
|
||||||
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. Every open passes through a real loading window — it appears immediately at its saved frame under the registry's cached name, its content a centered spinner behind a ~200 ms grace, while the tree walk runs off the main actor (one walk per board however many windows ask at once), and ⌘W during the walk genuinely cancels it. When an **attended** open refuses, that same content area transforms in place into the **decision surface** — never a sheet, never a chained dialog: the walk's defects grouped by class, each class stating the problem once and listing the affected files with Reveal in Finder and Open in Editor, one class-level choice preselected to its default and a per-file override behind a disclosure. Only honest choices are offered — unparseable frontmatter gets Open in Editor and Re-check (or Skip below the root), a file from a newer Lanework gets Skip alone (and blocks the whole board at the root), a board root with no `index.md` gets a minted index, a root with no `schema` gets a `schema: 1` stamp. Repair and Open applies every chosen fix in one write bracket — ordinary app writes, and on Pro boards a separate heal-authored repair commit — then re-runs the whole walk, opening on a clean result and re-aggregating into the *same* surface otherwise; Re-check re-walks without writing; Cancel (and ⌘W) retires to welcome's row. Skips are per-open consent that rides the session and is never persisted, and the opened board carries a warning-tone notice naming what was left out, each with its own Reveal in Finder. Restoration failures keep the retire-to-welcome-row landing — repair is an attended act, and launch never chains dialogs.
|
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. Every open passes through a real loading window — it appears immediately at its saved frame under the registry's cached name, its content a centered spinner behind a ~200 ms grace, while the tree walk runs off the main actor (one walk per board however many windows ask at once), and ⌘W during the walk genuinely cancels it. When an **attended** open refuses, that same content area transforms in place into the **decision surface** — never a sheet, never a chained dialog: the walk's defects grouped by class, each class stating the problem once and listing the affected files with Reveal in Finder and Open in Editor, one class-level choice preselected to its default and a per-file override behind a disclosure. Only honest choices are offered — unparseable frontmatter gets Open in Editor and Re-check (or Skip below the root), a file from a newer Lanework gets Skip alone (and blocks the whole board at the root), a board root with no `index.md` gets a minted index, a root with no `schema` gets a `schema: 1` stamp. Repair and Open applies every chosen fix in one write bracket — ordinary app writes, and on Pro boards a separate heal-authored repair commit — then re-runs the whole walk, opening on a clean result and re-aggregating into the *same* surface otherwise; Re-check re-walks without writing; Cancel (and ⌘W) retires to welcome's row. Skips are per-open consent that rides the session and is never persisted, and the opened board carries a warning-tone notice naming what was left out, each with its own Reveal in Finder. Restoration failures keep the retire-to-welcome-row landing — repair is an attended act, and launch never chains dialogs.
|
||||||
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
|
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
|
||||||
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
|
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, the colour written either way as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex — `background` as a mapping, `{color: fern}`, which is the one shape that field takes. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
|
||||||
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
||||||
|
|
||||||
- **Drag & drop** — cards, in either container, and lanes all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane carries exactly its cards either way, since the trash is board-level and there is nothing lane-nested to strip. Dragging a trash card onto a lane restores it at the drop position — an ordinary move — while dropping it on another board follows the same copy default every cross-board drag does, ⌘ forcing the true restore-move. The same gesture runs the other way: dropping a live card on the shown trash deletes it, exactly as ⌫ would, with the shadow always taking the topmost row — which the rank minting makes honest rather than arbitrary: every arrival really does land above the current top. A lane drag proposes the same delete over the column, and a trashed lane row drags back out to a strip slot the way a trash card drags back into a lane; a foreign board's item isn't deliverable there and neither is ⌥, since copying into the trash isn't a thing. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. The trash column and its cards are inert to them, and a read-only board or an open inline editor refuses them outright.
|
- **Drag & drop** — cards, in either container, and lanes all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane carries exactly its cards either way, since the trash is board-level and there is nothing lane-nested to strip. Dragging a trash card onto a lane restores it at the drop position — an ordinary move — while dropping it on another board follows the same copy default every cross-board drag does, ⌘ forcing the true restore-move. The same gesture runs the other way: dropping a live card on the shown trash deletes it, exactly as ⌫ would, with the shadow always taking the topmost row — which the rank minting makes honest rather than arbitrary: every arrival really does land above the current top. A lane drag proposes the same delete over the column, and a trashed lane row drags back out to a strip slot the way a trash card drags back into a lane; a foreign board's item isn't deliverable there and neither is ⌥, since copying into the trash isn't a thing. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. The trash column and its cards are inert to them, and a read-only board or an open inline editor refuses them outright.
|
||||||
@@ -23,7 +23,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a lane carries exactly its cards, copied or moved, because the trash is board-level and there is nothing lane-nested to strip; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The clipboard works on trash cards like on any card — ⌘C yields a live copy wherever you paste it, and ⌘X in the trash followed by ⌘V is the keyboard-native restore, a card into a lane and a trashed lane row after the anchor lane — while paste never targets the trash itself, and the read-only lock blocks cut without ever blocking copy, because copying out is a read.
|
- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a lane carries exactly its cards, copied or moved, because the trash is board-level and there is nothing lane-nested to strip; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The clipboard works on trash cards like on any card — ⌘C yields a live copy wherever you paste it, and ⌘X in the trash followed by ⌘V is the keyboard-native restore, a card into a lane and a trashed lane row after the anchor lane — while paste never targets the trash itself, and the read-only lock blocks cut without ever blocking copy, because copying out is a read.
|
||||||
|
|
||||||
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
|
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched. **A board can also wear a picture.** `background` is a mapping at every level — `{color: fern}` on a card or a lane — and a board's may name an image beside its colour: `background: {color: "#112233", image: art/sunset.jpg}` paints that image over that colour, scaled to fill, across the **whole window**: the content runs under a transparent title bar with a frosted strip keeping the toolbar and the board-name widget legible on top of it. The path is relative to the board folder, so the picture travels with the document when it is copied, zipped or synced (an absolute path, or one climbing out of the folder, simply paints nothing). Either half may stand alone, the colour shows through while a large photograph decodes off the main thread, and replacing the file in Finder swaps the backdrop live. There is no picker for it — like a hand-written hex, the raw file is the escape hatch — and a board with no background of its own keeps the standard window chrome exactly as before.
|
||||||
|
|
||||||
- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. **Lanes delete into the trash too**: the folder travels subtree-intact and shows as one distinct dimmed row carrying its title and held-card count — an opaque unit that never expands, whose cards aren't individually addressable, and which restores whole or purges whole (its confirmation counting the cards it would take with it). The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.
|
- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. **Lanes delete into the trash too**: the folder travels subtree-intact and shows as one distinct dimmed row carrying its title and held-card count — an opaque unit that never expands, whose cards aren't individually addressable, and which restores whole or purges whole (its confirmation counting the cards it would take with it). The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user